@qvac/skills 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bundled.js CHANGED
@@ -5,12 +5,12 @@ export const SKILLS = {
5
5
  "apple-notes/SKILL.md": "---\nname: apple-notes\ndescription: Read, search, create, edit, append to, and delete notes in the macOS Notes app through bundled AppleScript operations.\ntools: [exec(osascript)]\nplatform: [darwin]\nmetadata:\n {\n 'openclaw':\n {\n 'requires': { 'bins': ['osascript'] },\n 'setup':\n {\n 'summary': 'Apple Notes works through macOS automation (osascript), which ships with macOS — nothing to install. The first command triggers a one-time permission prompt to let this app control Notes.',\n 'routes':\n [\n {\n 'kind': 'instructions',\n 'label': 'Allow Notes automation',\n 'description': \"macOS asks once for permission to control Notes. Commands fail until it's granted.\",\n 'steps':\n [\n 'Use the skill once — macOS shows a permission prompt to allow control of Notes.',\n 'Click Allow.',\n 'If it was denied, enable it under System Settings → Privacy & Security → Automation.'\n ]\n }\n ]\n }\n }\n }\n---\n\n# Apple Notes (osascript)\n\nDrive the macOS Notes app through the bundled AppleScript operations, one per\n`exec` call: `osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n`{{SKILL_DIR}}` resolves to this skill's absolute directory. Never use\n`osascript -e` or any other AppleScript file: the exec grant permits only the\nbundled Notes operations.\n\n## Load the Recipe File First\n\nThis file carries no commands. The working recipes live in two reference files —\nload the one for the job with the `skill` tool BEFORE calling `exec`, then copy\nits command and change only the arguments:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Finding or reading notes** — \"list my notes\", \"what notes do I have\",\n \"find / search notes about X\", \"read / open / summarize note X\": call the\n `skill` tool with `name: \"apple-notes\"` and `file: \"references/read.md\"`.\n- **Changing notes** — \"add a note\", \"add to note X\", \"rewrite / replace note\n X\", \"delete note X\": call the `skill` tool with `name: \"apple-notes\"` and\n `file: \"references/write.md\"`. It also covers how to get the note id that\n edit and delete need.\n\nMost requests are ONE command. Run that single command, read its output, then\nanswer — do not chain extra searches to \"double-check\" or enumerate. An\nempty-query search already lists every note; never enumerate notes by trying\nseveral queries.\n\n## When to Use\n\n- User explicitly mentions \"Notes\" (capital N), \"Notes app\", or \"Apple Notes\".\n- User wants a personal note that syncs to iCloud and shows up on their iPhone/iPad.\n- Note creation needs rich content (formatting, attachments) that Reminders cannot hold.\n\n## When NOT to Use\n\n- User wants a to-do or reminder (use the `apple-reminders` skill).\n- User wants a markdown vault, backlinks, or knowledge graph (use the `obsidian` skill).\n- User wants project tracking or shared docs (use Notion or another tool).\n- User just wants to dump text to a file (use the filesystem tools).\n\n## Setup\n\n- No install required. `osascript` ships with macOS.\n- Grant automation permission the first time: macOS prompts the user to allow\n the parent terminal/app to control Notes. If denied, tell the user to enable it\n under System Settings → Privacy & Security → Automation.\n\n## Output Policy\n\n- Note bodies are HTML: strip tags or convert to plain text before answering\n unless the user asked for the raw markup. If the note is empty, say so.\n- Present search results as note names; keep the raw ids for follow-up\n edit/delete calls rather than showing them to the user.\n- `execution error: Not authorized to send Apple events` → tell the user to\n grant Automation permission in System Settings.\n- If the Notes app is not open and the operation fails, tell the user to open\n Notes once (it usually auto-launches on first call).\n",
6
6
  "apple-notes/append-note.applescript": "-- Append an HTML fragment to a note found by name. argv: noteName, htmlFragment\non run argv\n set noteName to item 1 of argv\n set extra to item 2 of argv\n tell application \"Notes\"\n set targetNote to first note whose name is noteName\n set body of targetNote to (body of targetNote) & extra\n end tell\nend run\n",
7
7
  "apple-notes/cli.schema.json": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"x-positionals\": [\"script\"],\n \"x-rest\": \"args\",\n \"x-resource-files\": [\n \"create-note.applescript\",\n \"edit-note.applescript\",\n \"append-note.applescript\",\n \"read-note.applescript\",\n \"search-notes.applescript\",\n \"delete-note.applescript\"\n ],\n \"x-resource-effects\": {\n \"read-note.applescript\": \"read\",\n \"search-notes.applescript\": \"read\"\n },\n \"required\": [\"script\", \"args\"],\n \"properties\": {\n \"script\": {\n \"type\": \"string\",\n \"description\": \"absolute path to a bundled Apple Notes operation\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"minItems\": 1,\n \"maxItems\": 3,\n \"description\": \"argv passed to the bundled script\"\n }\n }\n}\n",
8
- "apple-notes/create-note.applescript": "-- Create a Notes note. argv: name, htmlBody, [folder]\n-- Notes writes the `name` property as the body's first line, so creating with\n-- both name and body renders the title twice. Create from body, then rename.\non run argv\n set noteName to item 1 of argv\n set noteBody to item 2 of argv\n tell application \"Notes\"\n if (count of argv) > 2 then\n set newNote to make new note at folder (item 3 of argv) with properties {body:noteBody}\n else\n set newNote to make new note with properties {body:noteBody}\n end if\n set name of newNote to noteName\n end tell\nend run\n",
8
+ "apple-notes/create-note.applescript": "-- Create a Notes note. argv: name, htmlBody, [folder]\n-- A note's title is its first line. iCloud notes carry no separate `name` and\n-- refuse `set name` (-10006) after the note already exists, which reported a\n-- created note as failed. So the title goes into the body as its heading.\non run argv\n set noteName to item 1 of argv\n set noteBody to item 2 of argv\n set heading to \"<h1>\" & noteName & \"</h1>\"\n if noteBody does not start with heading then set noteBody to heading & noteBody\n tell application \"Notes\"\n if (count of argv) > 2 then\n set newNote to make new note at folder (item 3 of argv) with properties {body:noteBody}\n else\n set newNote to make new note with properties {body:noteBody}\n end if\n return id of newNote\n end tell\nend run\n",
9
9
  "apple-notes/delete-note.applescript": "-- Delete a note by its stable CoreData id. argv: noteId\n-- Id-based, not name-based: names collide, and delete is destructive. The note\n-- moves to Recently Deleted (restorable for 30 days). Get the id from\n-- search-notes.applescript.\non run argv\n set noteId to item 1 of argv\n tell application \"Notes\"\n delete note id noteId\n end tell\nend run\n",
10
10
  "apple-notes/edit-note.applescript": "-- Replace a note's body by CoreData id. argv: noteId, replacementHtmlBody\n-- The replacement body must include the title as its first heading; replacing\n-- body without it also replaces the note's visible title.\non run argv\n set noteId to item 1 of argv\n set replacementBody to item 2 of argv\n tell application \"Notes\"\n set body of note id noteId to replacementBody\n end tell\nend run\n",
11
11
  "apple-notes/read-note.applescript": "-- Read a note's HTML body. argv: noteRef — a CoreData id (from search-notes,\n-- exact and preferred) or a note name. Name matching tries an exact title, then\n-- a contains match (Notes derives the title from rich text, so it can carry\n-- stray whitespace). Both paths — an id that no longer resolves and a name\n-- that matches nothing — report cleanly instead of raising an AppleScript\n-- error (-1728 / -1719) on a missing note or an empty selection.\non run argv\n set noteRef to item 1 of argv\n tell application \"Notes\"\n if noteRef starts with \"x-coredata://\" then\n -- An id copied from a truncated search line, or one whose note was\n -- deleted, no longer resolves; report cleanly rather than raising -1728.\n try\n return body of note id noteRef\n on error\n return \"No note found matching: \" & noteRef\n end try\n end if\n set matches to notes whose name is noteRef\n if (count of matches) is 0 then\n set matches to notes whose name contains noteRef\n end if\n if (count of matches) is 0 then\n return \"No note found matching: \" & noteRef\n end if\n return body of item 1 of matches\n end tell\nend run\n",
12
12
  "apple-notes/references/read.md": "# Finding and Reading Notes\n\nTwo bundled operations cover every read request. Run one per `exec` call as\n`osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n\n| Request | Command |\n| ----------------------------------------- | --------------------------------------------------- |\n| \"list my notes\" / \"what notes do I have\" | `search-notes.applescript \"\"` — run it exactly ONCE |\n| \"find / search notes about X\" | `search-notes.applescript \"X\"` |\n| \"read / open / summarize note X\" | search first, then `read-note.applescript \"<id>\"` |\n\n## Search Notes\n\nPass a search term to `search-notes.applescript` (argv: query). It returns one\nmatch per line as `<id>\\t<name>` for every note whose name OR body contains the\nterm. Use the `name` to read a note, or the `id` to edit or delete it — this is\nthe only bundled way to obtain a note's stable id.\n\n```bash\nosascript {{SKILL_DIR}}/search-notes.applescript \"deadline\"\nosascript {{SKILL_DIR}}/search-notes.applescript \"\"\n```\n\nAn empty query (`\"\"`) matches every note, so it doubles as \"list all notes\" — a\nsingle call returns the complete list. Run search ONCE per request; do not fire\nseveral searches (`\"\"`, then `\"meeting\"`, then `\"\"` again) to enumerate or\nre-confirm. A large library can exceed the default output cap: pass\n`maxOutputChars` (up to 65536) on the exec call, and if the output still ends in\n`… [truncated]`, tell the user the list is partial rather than presenting it as\nthe whole library.\n\n## Read a Note\n\nPass a note reference to `read-note.applescript` (argv: noteRef). It returns the\nnote's HTML body. The reference is either a CoreData id or a note name:\n\n- **Prefer the id** from `search-notes.applescript` — it is exact. To read a\n specific note, search first, then read by the id from that result.\n- A **name** works too, but is a fallback: Notes derives titles from rich text,\n so exact-match can miss. Do not invent a name; use one search returned.\n\n```bash\nosascript {{SKILL_DIR}}/read-note.applescript \"x-coredata://.../ICNote/p67\"\nosascript {{SKILL_DIR}}/read-note.applescript \"Meeting Notes\"\n```\n\nWhen the user names one note, read exactly ONE note — the single best match\nfrom search — then answer and STOP. Do not read other notes to \"explore\" or\ncompare, and never walk a previous list of notes reading them one by one. Only\nread more than one note when the user explicitly asked about several.\n\nA reference that matches nothing — a name with no match, or an id that no\nlonger resolves (deleted, or copied from a truncated search line) — returns\n`No note found matching: ...` (not an error); search again and retry with the id\nfrom that result. The body is HTML; strip tags or convert to plain text before\nanswering unless the user asked for the raw markup. A long note can exceed the\ndefault output cap: pass `maxOutputChars` (up to 65536) on the exec call, and if\nthe body still ends in `… [truncated]`, tell the user it is partial rather than\nsummarizing it as complete.\n\n## Common Mistakes\n\n- Using `title of note` — Notes has no `title` property. Read or search `name`.\n- Running several searches for one request — one `search-notes \"\"` is the whole list.\n- Reading every note from a list when the user asked about one.\n",
13
- "apple-notes/references/write.md": "# Creating, Editing, and Deleting Notes\n\nFour bundled operations cover every change. Run one per `exec` call as\n`osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n\n| Request | Command |\n| ------------------------------- | ---------------------------------------------------------------- |\n| \"add a note\" | `create-note.applescript \"Title\" '<h1>Title</h1>...'` |\n| \"add to note X\" | `append-note.applescript \"X\" '<p>...</p>'` |\n| \"rewrite / replace note X\" | search for its id, then `edit-note.applescript \"<id>\" '<h1>…'` |\n| \"delete note X\" | search for its id, then `delete-note.applescript \"<id>\"` |\n\n## Getting a Note's Id\n\nEdit and delete are id-based, not name-based (names collide). Get the stable\nCoreData id from `search-notes.applescript` (argv: query) — it returns one\n`<id>\\t<name>` line per matching note. Run it ONCE, then use the id.\n\n```bash\nosascript {{SKILL_DIR}}/search-notes.applescript \"Daily Log\"\n```\n\n## Create a Note\n\nApple Notes bodies are HTML, so they contain double quotes (e.g. `href=\"...\"`).\nALWAYS pass the title and body as ARGUMENTS to `create-note.applescript` (argv:\nname, body, optional folder). Wrap the HTML body in SINGLE quotes at the shell\nlevel so its double quotes need no escaping. Never embed a body inside\n`osascript -e '...'`: three nested quoting layers drop the closing `\"`/`}` and\nproduce `syntax error: Expected \"}\"` and no note.\n\nPlain note in the default folder:\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"What is Bitcoin?\" '<h1>What is Bitcoin?</h1><p>Bitcoin is <strong>digital gold</strong>. See <a href=\"https://bitcoin.org/\">bitcoin.org</a>.</p>'\n```\n\nIn a specific folder (pass the folder name as a third argument):\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"Sprint Planning\" '<h1>Sprint Planning</h1><p>Items:</p><ul><li>Item 1</li></ul>' \"Work\"\n```\n\nA note whose whole content is one line of text: that text IS the title, so the\nbody is the `<h1>` alone. Adding a `<p>` copy of it shows the text twice.\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"Buy milk and eggs\" '<h1>Buy milk and eggs</h1>'\n```\n\nRules:\n\n- Use `<h1>` for the title (it shows as the note's first line), `<p>` for\n paragraphs, `<ul>/<li>` for lists, `<b>/<i>` for emphasis. The `name`\n argument names the note without adding a line to the body.\n- Write every piece of content ONCE. Never follow the `<h1>` title with a `<p>`\n that restates it — the note then shows the same text twice.\n- The HTML body MUST be wrapped in single quotes `'...'`. HTML attribute quotes\n (`\"`) are then safe and need NO backslash escaping.\n- If the body must contain an apostrophe `'`, close-escape-reopen it:\n `...don'\\''t...`.\n\n## Append to a Note\n\nPass the note name and the HTML fragment to append to\n`append-note.applescript` (argv: noteName, htmlFragment):\n\n```bash\nosascript {{SKILL_DIR}}/append-note.applescript \"Daily Log\" '<p>New entry: ...</p>'\n```\n\n## Edit a Note\n\nGet the id first (see above), then pass the id and the COMPLETE replacement\nbody to `edit-note.applescript` (argv: noteId, replacementBody):\n\n```bash\nosascript {{SKILL_DIR}}/edit-note.applescript \"x-coredata://.../ICNote/p67\" '<h1>Daily Log</h1><p>Updated content</p>'\n```\n\nThe replacement body must include the title as its first heading or line;\nreplacing `body` without it also replaces the note's visible title.\n\n## Delete a Note\n\nGet the id first (see above), then pass it to `delete-note.applescript` (argv:\nnoteId):\n\n```bash\nosascript {{SKILL_DIR}}/delete-note.applescript \"x-coredata://.../ICNote/p67\"\n```\n\nDeletion moves the note to Recently Deleted; the user can restore it from Notes\nfor 30 days. Confirm the target with the user before deleting — there is no undo\nprompt.\n\n## Common Mistakes\n\n- Treating the body as plain text — it is HTML. Use HTML tags in the body argument.\n- Repeating the title inside the body — write it once as the leading `<h1>`.\n- Embedding an HTML body inside `osascript -e '...'` — the nested quotes break.\n Use the bundled script and pass name/body as arguments.\n- Forgetting to wrap the HTML body argument in single quotes — its `\"` attribute\n quotes will then break the shell command.\n- Editing or deleting by name — both need the id from `search-notes.applescript`.\n",
13
+ "apple-notes/references/write.md": "# Creating, Editing, and Deleting Notes\n\nFour bundled operations cover every change. Run one per `exec` call as\n`osascript {{SKILL_DIR}}/<file>.applescript <args...>`.\n\n| Request | Command |\n| ------------------------------- | ---------------------------------------------------------------- |\n| \"add a note\" | `create-note.applescript \"Title\" '<h1>Title</h1>...'` |\n| \"add to note X\" | `append-note.applescript \"X\" '<p>...</p>'` |\n| \"rewrite / replace note X\" | search for its id, then `edit-note.applescript \"<id>\" '<h1>…'` |\n| \"delete note X\" | search for its id, then `delete-note.applescript \"<id>\"` |\n\n## Getting a Note's Id\n\nEdit and delete are id-based, not name-based (names collide). Get the stable\nCoreData id from `search-notes.applescript` (argv: query) — it returns one\n`<id>\\t<name>` line per matching note. Run it ONCE, then use the id.\n\n```bash\nosascript {{SKILL_DIR}}/search-notes.applescript \"Daily Log\"\n```\n\n## Create a Note\n\nApple Notes bodies are HTML, so they contain double quotes (e.g. `href=\"...\"`).\nALWAYS pass the title and body as ARGUMENTS to `create-note.applescript` (argv:\nname, body, optional folder). Wrap the HTML body in SINGLE quotes at the shell\nlevel so its double quotes need no escaping. Never embed a body inside\n`osascript -e '...'`: three nested quoting layers drop the closing `\"`/`}` and\nproduce `syntax error: Expected \"}\"` and no note.\n\nOn success the command prints the new note's id (`x-coredata://…`) and nothing\nelse. That id IS the confirmation: the note exists, so answer the user — never\nrun the command again to check or \"retry\". Only a non-zero exit with an\n`execution error` means no note was created.\n\nThe note's title is its first line, so the script makes sure the body starts\nwith `<h1>name</h1>`, adding it when the body does not already begin with it.\nWrite the title once as the leading `<h1>` and the script changes nothing.\n\nPlain note in the default folder:\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"What is Bitcoin?\" '<h1>What is Bitcoin?</h1><p>Bitcoin is <strong>digital gold</strong>. See <a href=\"https://bitcoin.org/\">bitcoin.org</a>.</p>'\n```\n\nIn a specific folder (pass the folder name as a third argument):\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"Sprint Planning\" '<h1>Sprint Planning</h1><p>Items:</p><ul><li>Item 1</li></ul>' \"Work\"\n```\n\nA note whose whole content is one line of text: that text IS the title, so the\nbody is the `<h1>` alone. Adding a `<p>` copy of it shows the text twice.\n\n```bash\nosascript {{SKILL_DIR}}/create-note.applescript \"Buy milk and eggs\" '<h1>Buy milk and eggs</h1>'\n```\n\nRules:\n\n- Use `<h1>` for the title (it shows as the note's first line), `<p>` for\n paragraphs, `<ul>/<li>` for lists, `<b>/<i>` for emphasis. The `name`\n argument names the note without adding a line to the body.\n- Write every piece of content ONCE. Never follow the `<h1>` title with a `<p>`\n that restates it — the note then shows the same text twice.\n- The HTML body MUST be wrapped in single quotes `'...'`. HTML attribute quotes\n (`\"`) are then safe and need NO backslash escaping.\n- If the body must contain an apostrophe `'`, close-escape-reopen it:\n `...don'\\''t...`.\n\n## Append to a Note\n\nPass the note name and the HTML fragment to append to\n`append-note.applescript` (argv: noteName, htmlFragment):\n\n```bash\nosascript {{SKILL_DIR}}/append-note.applescript \"Daily Log\" '<p>New entry: ...</p>'\n```\n\n## Edit a Note\n\nGet the id first (see above), then pass the id and the COMPLETE replacement\nbody to `edit-note.applescript` (argv: noteId, replacementBody):\n\n```bash\nosascript {{SKILL_DIR}}/edit-note.applescript \"x-coredata://.../ICNote/p67\" '<h1>Daily Log</h1><p>Updated content</p>'\n```\n\nThe replacement body must include the title as its first heading or line;\nreplacing `body` without it also replaces the note's visible title.\n\n## Delete a Note\n\nGet the id first (see above), then pass it to `delete-note.applescript` (argv:\nnoteId):\n\n```bash\nosascript {{SKILL_DIR}}/delete-note.applescript \"x-coredata://.../ICNote/p67\"\n```\n\nDeletion moves the note to Recently Deleted; the user can restore it from Notes\nfor 30 days. Confirm the target with the user before deleting — there is no undo\nprompt.\n\n## Common Mistakes\n\n- Treating the body as plain text — it is HTML. Use HTML tags in the body argument.\n- Repeating the title inside the body — write it once as the leading `<h1>`.\n- Embedding an HTML body inside `osascript -e '...'` — the nested quotes break.\n Use the bundled script and pass name/body as arguments.\n- Forgetting to wrap the HTML body argument in single quotes — its `\"` attribute\n quotes will then break the shell command.\n- Editing or deleting by name — both need the id from `search-notes.applescript`.\n",
14
14
  "apple-notes/search-notes.applescript": "-- Find notes whose name or body contains a term. argv: query\n-- An empty query returns EVERY note (list-all) — AppleScript's `contains \"\"` is\n-- false, so the empty case is handled explicitly rather than via the filter.\n-- Emits one match per line as `id<tab>name`, so the caller can then read by\n-- name or edit/delete by the stable id (there is no other bundled way to get\n-- a note's id). The query binds as data via argv, never into the script text.\non run argv\n set q to item 1 of argv\n set out to \"\"\n tell application \"Notes\"\n if q is \"\" then\n set matches to every note\n else\n set matches to every note whose name contains q or body contains q\n end if\n repeat with n in matches\n set out to out & (id of n) & tab & (name of n) & linefeed\n end repeat\n end tell\n return out\nend run\n",
15
15
  "apple-reminders/SKILL.md": "---\nname: apple-reminders\ndescription: Manage Apple Reminders through the local remindctl CLI — view, create, complete, and delete reminders, and manage lists, with date filters and JSON/plain output. macOS only.\ntools: [exec(remindctl)]\nplatform: [darwin]\nmetadata:\n {\n \"openclaw\":\n {\n \"requires\": { \"bins\": [\"remindctl\"] },\n \"install\":\n [\n {\n \"id\": \"homebrew\",\n \"kind\": \"shell\",\n \"bins\": [\"brew\"],\n \"manual\": true,\n \"label\": \"Install Homebrew\",\n \"command\": \"/bin/bash -c \\\"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\\\"\"\n },\n {\n \"id\": \"brew\",\n \"kind\": \"brew\",\n \"formula\": \"steipete/tap/remindctl\",\n \"bins\": [\"remindctl\"],\n \"label\": \"Install remindctl via Homebrew\"\n },\n {\n \"id\": \"authorize\",\n \"kind\": \"shell\",\n \"command\": \"remindctl authorize\",\n \"label\": \"Grant Reminders access\"\n }\n ],\n \"setup\":\n {\n \"summary\": \"Apple Reminders works through the remindctl CLI. Install it with Homebrew, then grant Reminders access once.\",\n \"routes\":\n [\n {\n \"kind\": \"install\",\n \"label\": \"Install remindctl\",\n \"helpUrl\": \"https://github.com/steipete/remindctl\",\n \"steps\":\n [\n \"Install Homebrew if you don't have it.\",\n \"Run: brew install steipete/tap/remindctl\",\n \"Run: remindctl authorize and allow Reminders access.\",\n \"Reopen this Skills page — apple-reminders should show as ready.\"\n ]\n }\n ]\n }\n }\n }\n---\n\n# Apple Reminders\n\nManage Apple Reminders through the local `remindctl` CLI. The CLI talks to the\nmacOS Reminders app and its lists, so reminders created here sync to the user's\niPhone and iPad.\n\n## Load the Recipe File First\n\nThis file carries no commands. The working commands live in two reference files\n— load the one for the job with the `skill` tool BEFORE calling `exec`, then\ncopy its command and change only the arguments:\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Viewing reminders and lists** — \"what are my reminders\", \"what's due\n today / tomorrow / this week\", \"what's overdue\", \"show my lists\", \"show my\n Work reminders\": call the `skill` tool with `name: \"apple-reminders\"` and\n `file: \"references/view.md\"`.\n- **Changing reminders and lists** — \"remind me to X\", \"add X to my Y list\",\n \"mark N done\", \"delete reminder\", \"create / delete a list\": call the `skill`\n tool with `name: \"apple-reminders\"` and `file: \"references/edit.md\"`. It also\n covers finding the id a complete or delete needs.\n\nMost requests are ONE command. Run that single command with `exec`, then answer\nfrom its output.\n\n## Always Use the remindctl CLI — Never Shell Out\n\nReminders live in the macOS Reminders database. ALWAYS view and edit them with\n`remindctl` commands via the `exec` tool. NEVER use `cat`, `ls`, `find`, `grep`,\n`sqlite3`, filesystem paths, or AppleScript to reach the Reminders store — those\nbypass `remindctl` and are wrong even when they appear to work. If a `remindctl`\ncommand fails, correct its arguments and retry the `remindctl` command; do not\nswitch to shell or file tools.\n\nUse `exec` only, one `remindctl` command per call. Do not chain with `&&`, `;`,\nor pipes.\n\n## When to Use\n\n- The user explicitly mentions \"reminder\" or the \"Reminders app\".\n- Creating personal to-dos with due dates that should sync to iPhone/iPad.\n- Viewing or managing Apple Reminders lists.\n\n## When NOT to Use\n\n- Calendar events or appointments — those are not Reminders.\n- Project or work task tracking — use Notion, GitHub Issues, or the task queue.\n- The user says \"remind me\" but means a local alert or notification in this\n app, not the Reminders app — ask: \"Do you want this in Apple Reminders (syncs\n to your phone) or as a local alert here?\" and use this skill only for the\n Apple Reminders answer.\n- Notes — use Obsidian, Notion, or Apple Notes.\n\n## Setup and Availability\n\n- macOS only. On any other platform, report that Apple Reminders is unavailable\n and stop.\n- Requires the `remindctl` binary: `brew install steipete/tap/remindctl`.\n- The Reminders app must have granted access. Check with `remindctl status`;\n request access with `remindctl authorize`.\n- If a command fails because `remindctl` is missing or access is not granted,\n report that clearly and ask the user to install or authorize — do not fall\n back to another tool.\n\n## Output Policy\n\n- Keep results small: the reminder title, its list, and its due date.\n- After a successful command, finish with a concise visible answer.\n- Do not dump every list or the full reminder database unless the user\n explicitly asks.\n",
16
16
  "apple-reminders/cli.schema.json": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"x-positionals\": [\n \"date\"\n ],\n \"properties\": {\n \"date\": {\n \"type\": \"string\"\n },\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n },\n \"today\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"tomorrow\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"week\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"overdue\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"all\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-effect\": \"read\"\n },\n \"list\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"create\": {\n \"type\": \"boolean\"\n },\n \"delete\": {\n \"type\": \"boolean\"\n },\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-positionals\": [\n \"name\"\n ]\n },\n \"add\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"list\": {\n \"type\": \"string\"\n },\n \"due\": {\n \"type\": \"string\"\n },\n \"json\": {\n \"type\": \"boolean\"\n },\n \"plain\": {\n \"type\": \"boolean\"\n },\n \"quiet\": {\n \"type\": \"boolean\"\n }\n },\n \"x-positionals\": [\n \"title\"\n ]\n },\n \"complete\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"ids\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"x-rest\": \"ids\"\n },\n \"delete\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"force\": {\n \"type\": \"boolean\"\n },\n \"ids\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"x-positionals\": [\n \"id\"\n ],\n \"x-rest\": \"ids\"\n },\n \"status\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {},\n \"x-effect\": \"read\"\n },\n \"authorize\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {}\n }\n }\n}\n",
@@ -74,11 +74,11 @@ export const SKILLS = {
74
74
  "presentations/references/read.md": "# Reading a Deck to Answer in Chat (python-pptx)\n\nWhen the user asks what an attached `.pptx` *says* — a summary, a question\nanswered, specific content pulled out — the deliverable is your reply in the\nchat, not a file. This is a **read request**: one no-`outputs` read call,\nstaged by the deck's real `attachmentId`, is the only `exec` of the turn — no\nbuild call follows it.\n\n**You cannot summarize in the call that reads.** The words in `command` are\nfixed before the program runs, so one call cannot inform itself: any summary\nwritten into it was written blind — recalled or invented, not read. Python only\n*transports* the slides; the summarizing happens in your reply, after the\nresult comes back.\n\n## Staging the deck\n\nThe id comes from wherever the deck entered the chat: the `exec` result that\ndelivered it, or — for a deck the **user uploaded** — the `[Attached file …]`\nline on their message, which names every non-image upload:\n\n```\n[Attached file \"quarterly.pptx\" (application/vnd.openxmlformats-officedocument.presentationml.presentation) — attachmentId: 4f9c2ab1]\n```\n\nCopy that id verbatim — never placeholders like `att_deck` or any id you made\nup. A `.pptx` is never staged id-less: the id-less form resolves to an uploaded\n*image*, so it cannot reach a deck. If no attachment id for the deck is\navailable anywhere in the chat, ask the user to attach it again — the working\ndirectory is fresh on every call, so a file from an earlier call is gone unless\nstaged again by its id.\n\n## The exec call\n\nThe read call declares **no `outputs`** — it builds nothing, it only reports:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-pptx==1.0.2\"],\n \"inputs\": [{ \"attachmentId\": \"<id of the deck in this chat>\", \"path\": \"deck.pptx\" }],\n \"maxOutputChars\": 24000,\n \"command\": \"...\"\n}\n```\n\n- `packages` — pin exactly `python-pptx==1.0.2`; this exact version ships with\n the app and installs with no network; any other version has to be downloaded,\n which fails on a device that is offline.\n- `inputs` — the staged deck, under a unique bare filename; reference\n `Presentation(\"deck.pptx\")` by that name only.\n- `maxOutputChars` — raises the stdout cap so the whole deck comes back in one\n result — without it stdout is capped at 8 KB. Keep the sample's 24000\n (default 8192, max 65536). Only a read call prints slide text.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;`.\n\n## The recipe\n\nThe read call's whole program is the loop — layout name and every line:\n\n```python\nfrom pptx import Presentation\n\nprs = Presentation(\"deck.pptx\") # the staged input — never Presentation()\nfor index, slide in enumerate(prs.slides):\n lines = [s.text_frame.text.replace(\"\\n\", \" \") for s in slide.shapes if s.has_text_frame]\n print(f\"{index}: [{slide.slide_layout.name}] {' | '.join(lines)}\")\n```\n\nThe `replace` is load-bearing: a multi-paragraph body embeds `\"\\n\"` between its\nbullets, and an embedded newline would split one slide across several printed\nlines. Flattened, every printed line is exactly one slide, starting with its\nindex and layout name.\n\nA bare `Presentation()` opens the bundled blank template, not the user's file —\nthe first line is always the staged path. Loop every slide and print every\nshape guarded by `shape.has_text_frame`: the titles are headings, and the\nsubstance is in the bodies underneath them.\n\nRead through `slide.shapes` **only**. `slide.placeholders` is not a second place\nto look — every placeholder is already in `slide.shapes`, the same shape reached\nby a narrower door, so looping both prints the whole deck twice and doubles what\nyou have to read back. Nor can you dedupe your way out of it: python-pptx builds\na fresh wrapper on each access, so the title reached through `shapes` and the\ntitle reached through `placeholders` are `==`-distinct objects over one XML\nelement — `in`, `is` and `set()` all fail to spot the repeat. One loop over\n`slide.shapes`, guarded by `shape.has_text_frame`, is the whole read.\n\n## Finish: answer in the chat\n\n**A successful read ends tool use.** When the result prints the slides, reply\nwith the summary or the answer as chat text. **Scale the reply to the deck**: a\nsummary is much shorter than what it summarizes — a handful of slides earns\nthree to five sentences, and only a long deck earns sections. Restating every\nslide is not a summary. Do **not**:\n\n- call `exec` again to \"re-check\", \"read more\", or read the same deck a second\n time;\n- build a summary `.pptx` the user never asked for — an unrequested file is a\n failed turn, not a bonus.\n\nIf stdout ends with `… [truncated]`, the deck is longer than the cap: answer\nfrom what came back and say the answer covers the deck up to that point. Do not\nrerun the read — it prints the same beginning again.\n\nIf the user asks for the summary **as a file**, that is a read followed by a\nbuild: the read call first, then one build call that writes the new deck from\nthe slides you actually read. The build call follows `references/create.md` —\nload it; the read still declares no `outputs`.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'pptx'` means `packages` was missing or\n wrong — add `[\"python-pptx==1.0.2\"]` and rerun. Never try to install it.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_deck`). Re-copy the exact\n id from the tool result or the `[Attached file …]` line that names the deck;\n if neither exists, ask the user to attach it again instead of retrying.\n- Every line showing up twice in the read output means the loop walked\n `slide.shapes` *and* `slide.placeholders`. Placeholders are already shapes —\n drop the second loop; one loop over `slide.shapes` is the whole read.\n- A read result ending in `… [truncated]` means the deck outgrew the cap:\n answer from what came back — do not rerun the read.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`text_frame`, not `textframe`) must stay. Do not switch to `python -c`\n or change the package pin.\n",
75
75
  "spotify/SKILL.md": "---\nname: spotify\ndescription: Play, search, and control music on Spotify — songs, artists, albums, playlists, and playback.\nemoji: 🎵\ntools: [http_request]\nplatform: [darwin, linux, win32, ios, android]\ncredentials: [spotify_access_token]\nallow_list: [https://api.spotify.com/v1/]\nmetadata:\n {\n \"openclaw\":\n {\n \"requires\":\n {\n \"credentials\": [\"spotify_access_token\"],\n \"credentialChecks\":\n { \"spotify_access_token\": { \"url\": \"https://api.spotify.com/v1/me\" } }\n }\n }\n }\n---\n\n# Spotify\n\nUse `http_request` against `https://api.spotify.com/v1`. The Spotify credential is attached automatically to every `api.spotify.com` request — **never include an `auth` block**. Never invent track/album/artist URIs — search first and copy `uri` from the JSON response.\n\n```json\n{\n \"url\": \"https://api.spotify.com/v1/search\",\n \"method\": \"GET\",\n \"query\": { \"q\": \"Radiohead Creep\", \"type\": \"track\", \"limit\": 5 }\n}\n```\n\n## Hard rules\n\n- A bare song/artist/album/playlist name is enough — search immediately, take the top match, and tell the user what you picked. Do not ask \"which one?\" before searching.\n- Always search before playing by name. Play carries URIs only in the JSON `body` (`\"uris\": [\"…\"]`), never as query parameters.\n- A bare `PUT /me/player/play` with no body only resumes paused playback — it never plays a requested song. For an album/artist/playlist use `{ \"context_uri\": \"<uri>\" }` instead of `uris`.\n- Do not ask about tokens or setup up front. A **401** means Spotify isn't connected (tell the user to run `/connect spotify`). A **404** from `/me/player` means no active device (tell them to open Spotify).\n\n## Recipe: play a song by name\n\n1. Search:\n\n```json\n{\n \"url\": \"https://api.spotify.com/v1/search\",\n \"method\": \"GET\",\n \"query\": { \"q\": \"Radiohead Creep\", \"type\": \"track\", \"limit\": 5 }\n}\n```\n\n2. Copy `tracks.items[0].uri` into the play body:\n\n```json\n{\n \"url\": \"https://api.spotify.com/v1/me/player/play\",\n \"method\": \"PUT\",\n \"body\": { \"uris\": [\"spotify:track:70LcF31zb1H0PyJoS1Sx1r\"] }\n}\n```\n\n## Other operations\n\nAll paths are under `https://api.spotify.com/v1`.\n\n| Ask | Method and path |\n| --------------- | ----------------------------------------------------------------------- |\n| What's playing? | `GET /me/player/currently-playing` |\n| Pause | `PUT /me/player/pause` |\n| Resume | `PUT /me/player/play` (no body) |\n| Next track | `POST /me/player/next` |\n| Add to queue | `POST /me/player/queue` with `query`: `{ \"uri\": \"spotify:track:<id>\" }` |\n| My playlists | `GET /me/playlists` |\n| Top tracks | `GET /me/top/tracks` with `query`: `{ \"time_range\": \"medium_term\" }` |\n| Recently played | `GET /me/player/recently-played` |\n| List devices | `GET /me/player/devices` |\n\n## Notes\n\n- Keep responses small — they truncate past 8KB, and raw JSON burns tokens on small models. Keep `limit` at 5. On playlist/track endpoints request only what you need with `fields` (e.g. `query`: `{ \"fields\": \"items(track(name,artists(name),uri))\" }`). Read just the top item unless the user asked for a list.\n- Present results as a short numbered list — track, artist, album, duration — and devices as `1. Name (active/idle)`. Never dump raw JSON to the user.\n- Playback commands return **204 No Content** on success (empty body). A **204** from currently-playing means nothing is playing.\n- **403** with `PREMIUM_REQUIRED` → user needs Spotify Premium. Other **403**s are usually app-scope/Development Mode limits — report and stop.\n- Never print the token or the `Authorization` header, and don't claim a write succeeded without a successful response in this turn.\n",
76
76
  "weather/SKILL.md": "---\nname: weather\ndescription: Get current weather and short forecasts for cities via wttr.in.\ntools: [http_request]\nplatform: [darwin, linux, win32, ios, android]\nallow_list: [https://wttr.in/]\nversion: 3\n# Tuned on Qwen3.5-2B with an offline eval (33 prompts x mention/prose routes), QVAC-24701.\n# v2: few-shot table. 2 repeats: overall 51/132 -> 77/130, \"Nassau, Bahamas\" 1/4 -> 4/4, city+country 2/24 -> 18/24,\n# ambiguous city 0/20 -> 6/20; 4B control 41/66 -> 59/66. Rules-first layouts lost on suffix slips (?3, ?1T).\n# v3: + country row. 3 repeats: v2 110/198 -> v3 121/198 (city+country 25 -> 31/36, forecast 17 -> 23/30).\n# Aliases, a second ambiguity row, and dropping ?T were tried and did not help (73, 74, 54 of ~130).\n---\n\n# Weather\n\nOne `http_request` to wttr.in, then answer from the body. Always call it exactly like this, with `\"method\": \"GET\"`:\n\n```json\n{ \"url\": \"https://wttr.in/Nassau,+Bahamas?format=3\", \"method\": \"GET\" }\n```\n\nMatch the user's request to a row for the URL:\n\n| User asks | Call |\n| --- | --- |\n| \"weather in Nassau, Bahamas\" | `https://wttr.in/Nassau,+Bahamas?format=3` |\n| \"weather in London\" / \"London today\" | `https://wttr.in/London?format=3` |\n| \"Berlin tomorrow\" / \"this weekend\" | `https://wttr.in/Berlin?2T` |\n| \"Rome for the next 3 days\" | `https://wttr.in/Rome?T` |\n| \"how hot is it in Georgia, the country\" | `https://wttr.in/Tbilisi,+Georgia?format=3` (a country → its capital) |\n| \"weather in Nassau\" (Nassau exists in the Bahamas and in New York) | no call — ask: \"Which Nassau do you mean, the Bahamas or New York?\" |\n| \"what's the weather?\" (no place) | no call — ask which city |\n\n## Rules\n\n1. **The location is exactly what the user wrote, spaces as `+`.** Keep a country or state they gave (`Nassau,+Bahamas`, not `Nassau`). Never add one they did not give. A country → its capital (`Tbilisi`).\n2. **Ambiguous city names — Nassau, Springfield, Portland, Cambridge, Georgia, San Jose, Birmingham — with no country or state → do not call, ask which one.**\n3. **The URL ends in `?format=3`, `?2T` or `?T`. Nothing else.** `?format=3` is the default for now/today/current. Write it in full: `London?3`, `London?format=3&format=3` and a bare `London` are all wrong.\n4. **Status not 200, or body `location not found` → say the place could not be found and ask for a more specific name. Do not retry other cities. Never state a temperature you did not receive.**\n5. wttr.in stops at 3 days: for \"next week\" say so and offer `?T`.\n\nA `200` body like `Nassau, Bahamas: 🌦️ +30°C` is the answer: give that temperature and condition in one plain sentence.\n",
77
- "word/SKILL.md": "---\nname: word\ndescription: Create, edit, or read Word (.docx) documents with python-docx — deliver documents as chat attachments, or read an attached one to summarize it or answer questions in the chat. Can embed images generated in the chat. Opens in Pages and Google Docs too.\naliases: [docx, word-document, memo]\npreload_on_name: false\ntools: [exec(python)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"setup\":\n {\n \"summary\": \"Runs python-docx in the in-process Python runtime, from packages that ship with the app. The first use waits for the runtime to start.\"\n }\n }\n }\n---\n\n# Word\n\nBuild, edit, or read `.docx` documents by running python-docx through the\n`exec` tool with `language: \"python\"`. Declare a produced document in\n`outputs` and it comes back as a chat attachment the user can save. To answer\n_from_ a document instead of building one, run a read call no `outputs` —\nand reply in the chat.\n\n## Load the Recipe File First\n\nThis file contains no Python. The working recipes live in four reference\nfiles load the one for the job with the `skill` tool BEFORE writing any\nPython, then copy its recipe and change the content:\n\nEach load is a real `skill` tool call printing the call as JSON or text in\nyour reply loads nothing.\n\n- **Creating a new document** (no existing `.docx` involved; may embed\n images): call the `skill` tool with `name: \"word\"` and\n `file: \"references/create.md\"`.\n- **Changing some of the facts, points, bullets, items, or paragraphs** of an\n existing `.docx` — \"replace the first 10 facts\", \"change fact 3\", \"swap the\n bullets for these\", \"reword paragraph 7\": call the `skill` tool with\n `name: \"word\"` and `file: \"references/replace.md\"`. This is the file even\n when the user says edit, replace, change, update, or rewrite; it runs two\n bundled scripts and no Python is written.\n- **Any other edit of an existing document** (extend it, trim it, rework a\n whole section, resize the text, embed an image into it): call the `skill`\n tool with `name: \"word\"` and `file: \"references/edit.md\"`.\n- **Reading a document to answer in chat** (a summary, a question answered,\n content pulled out no file delivered): call the `skill` tool with\n `name: \"word\"` and `file: \"references/read.md\"`.\n- **A summary delivered as a file** is a read followed by a build: load both\n `references/read.md` and `references/create.md`.\n\nNever write the Python from memory. The recipes carry rules (exact version\npins, attachment staging, run-level formatting, in-place replacement, the only\nworking removal idiom) that fail in non-obvious ways when improvised; loading\nthe file is one cheap read-only call.\n\n## When to Use\n\n- The user asks for a document, report, letter, memo, `.docx`, or Word file.\n- The user attaches a `.docx` and wants its content changed, replaced in part,\n extended, trimmed, or reworked.\n- The user attaches a `.docx` and asks what it says — a summary, a question\n answered, or content pulled out into the chat.\n- The user wants a document that embeds images generated in this chat.\n\n## When NOT to Use\n\n- The user wants text in the chat and no document is involved — just write it.\n Summarizing or answering from an attached `.docx` **is** this skill: load\n `references/read.md`.\n- The user wants slides or a deck — that is the presentations skill.\n- The user wants a spreadsheet or a PDF — python-docx writes only `.docx`.\n\n## What This Skill Cannot Do\n\nSay so instead of faking these; a fake is worse than a clear \"not supported\":\n\n- **No table of contents.** A real TOC is a Word field that Word itself computes;\n python-docx cannot insert one. Do not fake a TOC by typing headings and page\n numbers — the page numbers would be wrong. Offer headings (`Heading 1..9`)\n instead; Word can generate a TOC from them later.\n- **No tracked changes or comments.** There is no revisions API. Edits land as\n plain content; say that when the user asks for a redline.\n- **No legacy `.doc`.** Only `.docx`. A `.doc` output name is rejected — name it\n `.docx`.\n- **No PDF export and no rendering.** The runtime cannot convert or preview the\n document; it can only write the file.\n\n## Rules for Every Job\n\n**You build it, not the user.** Deliver the document, never the recipe. Do NOT\nprint the python source in chat, do NOT tell the user to install python-docx,\nrun a script, or open a terminal — they have no terminal in this chat and the\ncode would not run there. The document exists only if an `exec` call with\n`outputs` succeeds and returns the attachment; falling back to \"here is the\nscript, run it yourself\" is a failed turn.\n\n**Success = stop.** When `exitCode` is `0` and the result's `attachments`\nlists the `.docx`, the document is done. Do not call `exec` again for the same\nrequest — not to \"confirm\", not to \"improve\", not to \"add the image\" after the\nfact. Exactly one successful _build_ `exec` per document request — a\nno-`outputs` read that precedes a build delivers nothing and is not one of\nthem, but it belongs before the build, never after it. Reply with a single\nline: file name + the count line from stdout. If the result has\n`missingOutputs` instead, the file was never written: read stderr first — an\n`AssertionError` there means a guard stopped the save on purpose (see the edit\nrecipe); only when stderr is clean check the `save()` name matches the\ndeclared output and rerun once.\n\n**Failures are fixed in the code, not around it.** An error in your code is\nnever a fault in python-docx or in the runtime; fix the Python against the\nloaded reference file's recipes and Errors and call `exec` again. If two\nconsecutive calls fail with the same error, re-read the traceback\nline-by-line before a third — retrying the identical `command`, or a version\nwith only cosmetic changes, is a loop, not a fix. Do not switch package pins\n(keep `python-docx==1.2.0`), do not wrap source in `python -c` / `pip` /\nshell, do not \"debug\" with `os.listdir` or no-op scripts while `outputs`\nstill lists the document, and do not write the document as markdown/chat text\ninstead of a `.docx`. Never search the web about an error; the answer is\nalways in the `exec` result you already have.\n\n**The runtime is sealed.** There is no shell — `ls`, `cat`, and `file` raise\n`SyntaxError` because `command` is Python source — and no network:\n`requests`, `urllib`, and `socket` all fail. The working directory starts\nempty on every call: a file from an earlier call is gone unless staged again,\nand a file you write but do not declare in `outputs` is discarded. The `exec`\nresult is the only account of what happened — there is no filesystem to check\nand no shell to check it with.\n\n**Never overwrite a staged input.** Edits always save a new output name,\nderived from the document edited — `report.docx` becomes `report_revised.docx`,\nnever a fresh name taken from the new content.\n\n**An edit changes the document in place.** `add_paragraph` and `add_heading`\nappend at the end and nowhere else, so replacing content that is already there\nmeans rewriting those paragraphs, not adding new ones. Delivering the original\nwith the new version appended is a failed turn the edit recipe carries the\nguards that catch it.\n",
78
- "word/references/create.md": "# Creating a Word Document (python-docx)\n\nCreate a new `.docx` from scratch by running Python through the `exec` tool.\nA new document needs **no** `inputs` — do not invent attachment ids — unless\nit embeds an image (see Embedding Images). **Exactly one** `exec` call per\nuser request when that call succeeds.\n\n**A document that already exists in this chat is never rebuilt here.** \"Add a\nsection\", \"reword this\", \"extend the doc\" — any request that starts from an\nexisting `.docx` is an EDIT: load `references/edit.md` and stage the document\nby its `attachmentId`. Building a fresh document for an edit request throws\naway everything the user already has.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"outputs\": [\"report.docx\"],\n \"command\": \"...\"\n}\n```\n\n- `language` — always `\"python\"`.\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. Pin the version; an unpinned\n install resolves a potentially different library version. This exact version\n ships with the app and installs with no network; any other version has to be\n downloaded, which fails on a device that is offline.\n- `outputs` — `[\"report.docx\"]`. `save(\"report.docx\")` must match the declared\n output name. A file you write but do not declare here is discarded. A `.doc`\n output name is rejected — name it `.docx`.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. Its first line is the first line of Python\n that runs: there is no shell and no interpreter to invoke, and no\n installer — packages are declared in `packages`.\n\n## Embedding Images\n\nTwo kinds of image input, told apart by where the file came from:\n\n**Tool-produced images** (`generate_image` output): stage them with the exact\n`attachmentId` from the tool result — never placeholders like `att_image` or\nany id you made up.\n\n**Images the user uploaded** (\"use this photo\"): there is no id to copy — an\nuploaded image never shows one. Stage it with `path` only and **no\n`attachmentId` key**; the first id-less entry is the first image of the user's\nlatest message, the second is its second image, and so on. Id-less entries\nresolve _images only_.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"path\": \"photo.png\" }],\n \"outputs\": [\"report.docx\"],\n \"command\": \"...\"\n}\n```\n\nStaged files land in the working directory under the bare `path` names —\nreference `doc.add_picture(\"photo.png\", …)` by that name only. Paths must be\nunique bare filenames. `attachment … not found in this chat` means you\ninvented an id or the file is not attached: re-copy the exact id from the tool\nresult, or for a document with no image drop `inputs` entirely.\n\nIf the image was staged in `inputs`, embed it in **that** single build with\n`doc.add_picture` — never deliver a document and then rebuild to add the\nimage. Soft-failing (`try`/`except` around the picture) and saving without it\nis a failed turn, not a success.\n\n**Image URLs do not work — never download.** Your Python code has **no\nnetwork access**: `requests`, `urllib`, and `socket` all fail with a network\nerror, and `http_request` returns truncated text, never image bytes. When the\nuser gives an image URL, do not try to fetch it from Python and do not retry\nthrough other tools — that is a dead end. Say the link cannot be downloaded\nand ask the user to attach the image itself, or offer `generate_image` for a\nsimilar visual. Then build the document with the staged attachment as above.\n\n## The Recipe\n\nStart from this. It is a complete, working document — a title, headings,\nparagraphs with bold and italic runs, a bulleted list, and a table — saved\nunder the declared output name. Copy it and change the content; do not\nassemble a document from memory.\n\n**Keep the source multi-line.** A `for`/`if`/`with` after a semicolon is a\n`SyntaxError` — paste the block with real newlines, not `stmt; for x in y: …`.\n\n**Hold content in plain lists of strings, and walk them.** Every list of bullets\nis a flat `[\"…\", \"…\"]`, and every table is a list of row lists. Do not reach for\na dict, a tuple of mixed widths, or a nested comprehension to hold document\ncontent — those are where a `SyntaxError` or a\n`ValueError: too many values to unpack` comes from, and they buy nothing here.\n\n**Keep every underscore in API names.** `add_heading`, `add_paragraph`,\n`add_run`, `add_table`, `add_row`, `add_picture`, `add_page_break` — stripping\nthem to `addheading` / `addparagraph` fails. Copy identifiers exactly as written\nbelow:\n\n```python\nfrom docx import Document\nfrom docx.shared import Inches, Pt, RGBColor # one import line covers sizes, widths, colors\n\ndoc = Document()\n\ndoc.add_heading(\"Quarterly Report\", level=0)\ndoc.add_paragraph(\"Prepared by the finance team.\")\n\ndoc.add_heading(\"Summary\", level=1)\np = doc.add_paragraph(\"Revenue grew \")\nstrong = p.add_run(\"18 percent\")\nstrong.bold = True\np.add_run(\" against a \")\nemphasis = p.add_run(\"flat\")\nemphasis.italic = True\np.add_run(\" cost base.\")\n\ndoc.add_heading(\"Highlights\", level=1)\nfor point in [\n \"New retail partners in two regions\",\n \"Churn down for the third quarter\",\n \"Support backlog cleared\",\n]:\n doc.add_paragraph(point, style=\"List Bullet\")\n\ndoc.add_heading(\"Key Figures\", level=1)\nfigures = [\n [\"Metric\", \"Q3\", \"Q4\"], # first list is the header row\n [\"Revenue\", \"$1.2M\", \"$1.4M\"],\n [\"Costs\", \"$0.9M\", \"$0.9M\"],\n]\ntable = doc.add_table(rows=1, cols=len(figures[0]))\ntable.style = \"Table Grid\"\nfor index, cells in enumerate(figures):\n row = table.rows[0].cells if index == 0 else table.add_row().cells\n for column, value in enumerate(cells):\n row[column].text = value\n\ndoc.save(\"report.docx\") # must match the declared output exactly\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\n## Write a document, not markdown\n\nA `.docx` carries real styles, so the structure is the style — never the\npunctuation. Markdown written into text stays there verbatim and reads as a\ntypo in the finished document:\n\n- **No markdown characters in any string.** `#`, `##`, `-`, `*`, `1.`, `**bold**`\n and backticks all render literally. `add_heading(\"Security\", level=2)` — never\n `add_heading(\"- Security\", level=2)` or `\"## Security\"`. A numbered list is\n `style=\"List Number\"`, which numbers itself; a typed `\"1. \"` prefix double-numbers.\n- **No typed rules or line breaks.** A row of dashes or underscores as a section\n divider is just those characters on the page, and a leading `\"\\n\"` is a blank\n line inside the paragraph. Headings already separate sections.\n- **Every section title is a heading.** A first section called \"Introduction\" or\n \"Overview\" goes through `add_heading(..., level=1)` like every other one; as a\n plain `add_paragraph` it renders as body text and the document looks unstructured.\n- **No blank paragraphs for spacing.** `add_paragraph(\"\")` leaves a visible gap —\n the heading and body styles already carry their own space before and after.\n- **Bold is for a few words, not a sentence.** A fully bold paragraph reads as a\n formatting mistake; bold the term, then continue in a normal run.\n\n## One paragraph, one string\n\n`add_paragraph` takes a single text string, optionally with `style=` — nothing\nelse. Several sentences passed positionally raise\n`TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments but 4\nwere given`. Join them into one string, or open the paragraph with the first\npiece and add the rest as runs:\n\n```python\np = doc.add_paragraph(\"As of 2026, Bitcoin is widely held. \")\np.add_run(\"Adoption keeps growing.\")\n```\n\n**The text you pass to `add_paragraph` is already the paragraph's first run.** A\nrun added afterwards _appends_ — repeating any of those words writes them twice\ninto the document (`\"…finite supplyfinite supply\"`). Each run carries the next\nwords and only those, so give a mixed-format paragraph an empty start and add\nevery piece as its own run:\n\n```python\np = doc.add_paragraph()\np.add_run(\"Digital scarcity \")\ntail = p.add_run(\"and a finite supply\")\ntail.italic = True\n```\n\n## Bold and italic live on runs, never on paragraphs\n\n`paragraph.bold = True` raises no error and changes **nothing** in the file — a\nparagraph has no bold; the assignment lands on the Python object and is silently\ndiscarded on save. Formatting belongs to runs:\n\n```python\np = doc.add_paragraph(\"normal, then \")\nstrong = p.add_run(\"bold\")\nstrong.bold = True\np.add_run(\" and \")\nemphasis = p.add_run(\"italic\")\nemphasis.italic = True\n```\n\nTwo rules make that shape the only one to write:\n\n- **`add_run` takes the text and nothing else.** `p.add_run(\"x\", bold=True)`\n raises `TypeError: Paragraph.add_run() got an unexpected keyword argument\n'bold'` — create the run, then set the attribute.\n- **Never chain an attribute onto the `add_run(...)` call.** Name the run on one\n line and format it on the next, as above. A run that needs no formatting is a\n bare `p.add_run(\"plain text\")` and the line ends there — a trailing `.` left\n over from a half-written chain is `SyntaxError: invalid syntax`.\n- **Runs join with no gap between them.** The next run starts exactly where the\n last one ended, so the separating space belongs inside one of the strings —\n `\"…without intermediaries. \"` then `\"It was invented\"`, never\n `\"…intermediaries.\"` followed by `\"It was invented\"`.\n\n**`add_run` belongs to the paragraph, not to a run.** Keep the paragraph in a\nvariable and call `p.add_run(...)` for every run in it — chaining a second run off\nthe first raises `AttributeError: 'Run' object has no attribute 'add_run'`. A run\nowns `.text`, `.bold`, `.italic` and `.font`, and nothing else: it has no\n`add_run`, no `add_paragraph`, and no `.style`.\n\nA run is also not a string: `p.add_run(\" \") * 2` raises\n`TypeError: unsupported operand type(s) for *: 'Run' and 'int'`. Put any repeated\ntext inside the string itself — and reach for neither, since spacing is the\nstyle's job, not padding you type.\n\nCharacter detail goes through `run.font` — size, color:\n\n```python\nfrom docx.shared import Pt, RGBColor\n\np = doc.add_paragraph()\nrun = p.add_run(\"Key finding\")\nrun.font.size = Pt(14)\nrun.font.color.rgb = RGBColor(0x1A, 0x73, 0xE8) # RGB in all caps\n```\n\n`Pt`, `Inches`, and `RGBColor` all import from `docx.shared` — there is no\n`docx.util` and no `docx.dml.color`; those are python-pptx paths and fail here.\n\n## Styles must exist in the document\n\n`style=\"List Bullet\"` names a style **inside the document**. A missing name\nraises `KeyError: \"no style with name 'List Bullet'\"` at `add_paragraph` time.\n\nA **new** `Document()` ships these styles — safe to use without checking:\n`Title`, `Heading 1` … `Heading 9`, `Normal`, `List Bullet` (+ ` 2`, ` 3`),\n`List Number` (+ ` 2`, ` 3`), `Intense Quote`, and the table style `Table Grid`.\nDo not invent other names for a new document. (An uploaded document carries\nonly its own styles — when editing one, load `references/edit.md` for the\nguard.)\n\n## Headings and lists\n\n- `doc.add_heading(text, level=N)` — level `0` is the document title style,\n `1`–`9` map to `Heading 1`–`Heading 9`. Any other level raises\n `ValueError: level must be in range 0-9`.\n- Bullets: one `add_paragraph(point, style=\"List Bullet\")` per point, over a flat\n list of plain strings. Never pack several points into one paragraph with `\\n` —\n a `\\n` is a soft line break inside the same list item, not a new bullet. A\n bullet that needs a label and a detail is one string (`\"Limited supply — 21\nmillion coins\"`), never a dict entry or a tuple.\n- Numbered lists: `style=\"List Number\"`. Indent a level with `List Bullet 2` /\n `List Number 2`.\n\n## Tables\n\nWrite the whole table as a list of row lists — header first — then let the code\nabove derive everything from it. **Always `rows=1` and `cols=len(rows[0])`**:\n\n```python\nrows = [\n [\"Item\", \"Status\"], # header\n [\"Search\", \"Shipped\"],\n [\"Export\", \"In review\"],\n]\ntable = doc.add_table(rows=1, cols=len(rows[0]))\ntable.style = \"Table Grid\" # borders; omit for invisible grid\nfor index, cells in enumerate(rows):\n row = table.rows[0].cells if index == 0 else table.add_row().cells\n for column, value in enumerate(cells):\n row[column].text = value\n```\n\nThat shape exists because the two hand-written alternatives both fail:\n\n- **`rows=` is a count of blank rows created immediately, not a maximum.**\n `add_table(rows=4, …)` followed by `add_row()` per entry leaves three empty\n rows sitting between the header and the data, plainly visible in the finished\n document. `rows=1` is the header; every other row comes from `add_row()`.\n- **Unpacking a row into fixed names breaks the moment a row is a different\n width.** `for name, q3, q4 in data:` raises\n `ValueError: too many values to unpack (expected 3, got 4)`, and hand-counting\n `cols=` against the data is the same mistake one step earlier. Index the cells\n instead, and take the column count from the header.\n\nAddress cells as `table.cell(row, col)` or `table.rows[r].cells[c]` — they are\nthe same cell. Rows only grow at the bottom: there is no insert-at.\n`table.rows[9]` on a 4-row table raises `IndexError`. Write text with\n`cell.text = \"…\"`; for formatting inside a cell go through `cell.paragraphs[0]`\nand its runs like any other paragraph.\n\n## Images and page breaks\n\n`doc.add_picture(name, width=…)` appends the image in its own paragraph. Pass\nonly one of `width`/`height`; passing both distorts the picture.\n\n```python\nfrom docx.shared import Inches\n\ndoc.add_picture(\"figure1.png\", width=Inches(5.5))\ndoc.add_page_break()\n```\n\n**Do not soft-fail images or imports.** Never wrap `add_picture` or an import in\n`try`/`except` that prints a warning and continues. A missing file must raise so\nyou fix it and rerun — a document saved without the requested image is a failed\nturn, not a success.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `TypeError: 'Table' object is not subscriptable` — a table was indexed\n directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or\n `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.\n- `KeyError: \"no style with name '…'\"` — the style is not in this document. For\n a new document use only the names listed under Styles.\n- `NameError: name 'RGBColor' is not defined` (or `Pt`, `Inches`) — the import\n line is missing that name. Keep the sample's single\n `from docx.shared import Inches, Pt, RGBColor` rather than importing one at a time.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to\n `python -c` or change the package pin.\n- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`\n about positional arguments the call passes the wrong number of them — usually\n several strings where one is allowed. Fix either against this file's examples,\n reading the line number in the traceback. Do not retry the same call, and do\n not switch to a shell.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). For a new document,\n omit `inputs` entirely and rerun. Only stage real ids from prior tool results.\n- Never print the document's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints exactly one line (e.g. `9\nparagraphs, 1 table(s)`).\n- Never pass an absolute path to `save()`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.docx`, the document is\ndone — the `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already\nattached to the chat for the user to open or save, exactly like a\n`generate_image` result. Stop tool use and reply with a single line: file name\n\n- the count line from stdout. Exactly one successful `exec` per request. If\n the result has `missingOutputs` instead, the file was never written: check the\n `save()` name matches the declared output and rerun once.\n",
79
- "word/references/edit.md": "# Editing an Existing Word Document (python-docx)\n\n**Stop here if the request changes some of the facts, points, bullets, items,\nor paragraphs** — \"replace the first 10 facts\", \"change fact 3\", \"swap the\nbullets\", \"reword paragraph 7\". That job is `references/replace.md`: call the\n`skill` tool with `name: \"word\"` and `file: \"references/replace.md\"` now, and\ndo not use anything in this file for it. No Python is written for that job.\n\nChange, replace, extend, trim, or rework a `.docx` that is already in this\nchat by running Python through the `exec` tool: stage it as an input, modify\nparagraphs and tables, and save a **new** output such as\n`existing_revised.docx`. Never overwrite the staged input.\n\n## Staging the Document\n\nStage the document as an input **by its `attachmentId`** and open it with\n`Document(\"existing.docx\")`. The id comes from wherever the document entered\nthe chat:\n\n- **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.\n- **Uploaded by the user** — the `[Attached file …]` line on their message names\n it, when the message carries one:\n\n ```\n [Attached file \"report.docx\" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]\n ```\n\nCopy the id verbatim — never placeholders like `att_doc`, `att_image`, or any\nid you made up. A `.docx` is **never** staged id-less: an id-less input\nresolves to an uploaded _image_, so it can never reach a document. If no\n`attachmentId` for the document appears anywhere in the chat, say you cannot\nopen that file for editing and ask the user to attach it again — do not invent\nan id, do not stage it id-less, and do not retry. An attachment from an\nearlier turn can be used when its attachment id is available in the\nconversation.\n\n**The file only exists if this same `exec` call stages it.** The working\ndirectory starts empty on every call, so an edit needs an `inputs` entry\nnaming the attachment, and `Document(\"existing.docx\")` must use that entry's\nexact `path`. Opening a name that was never staged raises\n`PackageNotFoundError: Package not found at '…'` — the fix is the missing\n`inputs`, never a different file name.\n\n## The exec call\n\nImages can be staged alongside the document. Tool-produced images\n(`generate_image` output) take the exact `attachmentId` from the tool result;\nan image the user uploaded is staged with `path` only and **no\n`attachmentId` key** — the first id-less entry is the first image of the\nuser's latest message, and so on. Id-less entries resolve _images only_.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [\n {\n \"attachmentId\": \"<id from the exec result or the [Attached file …] line>\",\n \"path\": \"existing.docx\"\n },\n { \"path\": \"photo.png\" }\n ],\n \"outputs\": [\"existing_revised.docx\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. Pin the version; this exact\n version ships with the app and installs with no network; any other version\n has to be downloaded, which fails on a device that is offline.\n- `inputs` — staged files land in the working directory under the bare `path`\n names — reference `Document(\"existing.docx\")` /\n `doc.add_picture(\"photo.png\", …)` by that name only. Paths must be unique\n bare filenames.\n- `outputs` — the new file to deliver; a file you write but do not declare\n here is discarded. Never the staged input's name. **Name it after the\n document you edited**, not after the change: keep the input's stem and add a\n marker — `report.docx` edited is `report_revised.docx`. A fresh name picked\n from the new content (`cats.docx` for an edit of `parrot_facts.docx`) reads\n as a second, unrelated document and hides the fact that an edit happened at\n all.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. There is no shell and no installer — packages\n are declared in `packages`.\n\nIf the image was staged in `inputs`, embed it in **that** single build with\n`doc.add_picture(\"photo.png\", width=Inches(5.5))` — never deliver a document\nand then rebuild to add the image. **Do not soft-fail images or imports**:\nnever wrap `add_picture` or an import in `try`/`except` that prints a warning\nand continues — a document saved without the requested image is a failed\nturn, not a success. Pass only one of `width`/`height`; passing both distorts\nthe picture.\n\n**Image URLs do not work — never download.** Your Python code has **no\nnetwork access**: `requests`, `urllib`, and `socket` all fail with a network\nerror, and `http_request` returns truncated text, never image bytes. Say the\nlink cannot be downloaded and ask the user to attach the image itself, or\noffer `generate_image` for a similar visual.\n\n## Editing: Work the Objects, Save a New Name\n\nOne call does the whole edit: open, change, verify the document actually\nchanged, save. Keep the fingerprint lines exactly as written — they are what\nstops an edit that silently matched nothing (or a read that only inspected)\nfrom delivering an unchanged copy of the user's document at `exitCode 0`. The\nfingerprint covers the body **and** the styles part, so a style-only change —\nthe resize recipe below — counts as a change too:\n\n```python\nimport hashlib\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nfingerprint = hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest()\n\nfor paragraph in doc.paragraphs:\n if paragraph.text == \"Prepared by the finance team.\":\n paragraph.text = \"Prepared by the finance team. Revised after board review.\"\n\ntable = doc.tables[0]\nrow = table.add_row().cells\nrow[0].text = \"Margin\"\nrow[1].text = \"25%\"\nrow[2].text = \"36%\"\n\ndoc.add_heading(\"Appendix\", level=1)\ndoc.add_paragraph(\"Margins recovered as one-off costs rolled out of the base.\")\n\nassert (\n hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest() != fingerprint\n), \"nothing changed — the edit matched nothing or never ran; fix it, never deliver an unchanged copy\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\n**An assert that fires is a failed turn to diagnose, not a document to\ndeliver**: the usual cause is a paragraph match on text that is not exactly\nthere — print the real `.text` values in the rerun, fix the match, and never\ndelete the assert to get a file out.\n\n**Keep it an `assert`, never a `print` or an `if`.** Two `print` lines showing\nthe old and new hashes let a no-op save and deliver anyway, which is the one\nthing the assert exists to stop. They also invite a second miscoding: taking\nboth hashes together, before the change. Then they match whatever the edit did,\nand the run reports \"nothing changed\" over a document that changed correctly.\nTake the second hash after the last mutation and before `save`, and let the\nassert raise.\n\n**A printed line is never a reason to call `exec` again.** The guard is the\nassert: if it did not fire and the result carries an attachment, the document is\ndelivered and the turn is over, whatever stdout says about it. Re-running to\ncheck saves the same edit under a second name, and the user gets two documents\nfor one request.\n\n`doc.paragraphs` walks only the document body — text inside tables, headers, and\nfooters is **not** in it. Table text is reached through `doc.tables`; match\nparagraphs by their exact `.text` before rewriting them, and remember the\nformatting-loss rule below.\n\nThe `add_heading`/`add_paragraph` pair above appends an **Appendix** because\nthat is what the sample edit asks for. Copy that shape only when the user\ngenuinely wants new content at the end. Substituting content that is already\nin the document — \"change the first five points\", \"rewrite section 2\" — is a\ndifferent job with its own recipe and its own guards: see Replacing Content In\nPlace.\n\nWhen an edit adds substantial new content — new sections, formatted runs,\nbulleted lists, whole tables — the writing rules apply unchanged: load\n`references/create.md` too and copy its shapes (no markdown characters in\nstrings, bold/italic on runs never paragraphs, one string per `add_paragraph`,\ntables built from a header row with `rows=1`).\n\n### Setting `paragraph.text` erases formatting\n\nAssigning `paragraph.text = \"…\"` replaces **all** runs with one plain run: every\nbold, italic, size, and color in that paragraph is gone. Fine for plain\nparagraphs; on a formatted paragraph edit the runs instead, or accept the loss\ndeliberately. This is the top footgun when editing an uploaded document.\n\n### Styles must exist in the document\n\n`style=\"List Bullet\"` names a style **inside the document**. A missing name\nraises `KeyError: \"no style with name 'List Bullet'\"` at `add_paragraph` time.\nAn **uploaded** document carries only its own styles — one written by another\ntool may lack even `List Bullet`. When editing, guard once and fall back:\n\n```python\nnames = [s.name for s in doc.styles]\nbullet = \"List Bullet\" if \"List Bullet\" in names else None\ndoc.add_paragraph(\"point one\", style=bullet) # style=None → Normal\n```\n\n## Replacing Content In Place\n\nThis recipe is for whole *sections* (a heading plus its body). Changing some\nof the facts, points, bullets, or paragraphs is `references/replace.md` —\nnever hand-write a loop for that.\n\n`add_paragraph`, `add_heading`, and `add_picture` **always append at the end of\nthe document.** None of them takes a position. \"Change the first five points\",\n\"rewrite section 2\", \"swap these facts for those\" are all *replacements*, and\nreaching for `add_*` silently turns them into an append: the original content\nstays where it is, the new content lands after the closing line, and the\ndocument comes back longer than it started with both versions in it. That is a\nfailed turn, not a partial success — it is the most common way this skill goes\nwrong.\n\n**A section is a heading plus everything under it, up to the next heading of\nthe same or higher rank** — which may be one paragraph, or six bullets, or a\nwhole subsection, or nothing at all. Never assume it is exactly one paragraph:\nrewriting the heading and the single paragraph after it leaves the rest of the\nold section sitting under its new title, which is the same contradiction an\nappend produces and is just as invisible in the result. Work out where each\nsection ends before changing anything.\n\nRank matters as much as position. `Heading 2` under a `Heading 1` is a\nsubsection, not the next section, so \"replace the first two sections\" on a\ndocument with subheadings must not consume the parent's own subheading as\nsection two — the same rule the removal recipe below follows. `rank()` reads\nthe level off the style name, and only the shallowest rank counts as a section\nstart.\n\n**A heading shallower than every other heading is the document's title, not its\nfirst section.** A document headed `Heading 1` and sectioned `Heading 2` — the\nshape most attached documents have — would otherwise have exactly one\n\"section\": the title, spanning everything under it. Replacing that section\nreplaces the entire document, and nothing about the result says so. The `if`\ndrops such a heading before sections are picked, and the whole-document assert\nrefuses the span even if one is somehow selected.\n\n**One heading is dropped, never a chain of them.** It is an `if`, not a\n`while`: a document is titled once. Stripping repeatedly walks down the\noutline — on a `Heading 1` title over a `Heading 2` phase holding `Heading 3`\nweeks it drops the title, then the phase, and the weeks become the \"sections\",\nso replacing the first two rewrites the weeks and leaves the phase untouched.\nThat is the silent wrong target this section exists to prevent. Stopping after\none leaves the phase as the only section, and asking for a second raises an\nerror that says so.\n\nTake one snapshot of `doc.paragraphs` and index into it. **Every string in\n`NEW` is a placeholder** — the sample fills it with report sections so the\nshape is readable, and you replace all of it with the content this request asks\nfor. Shipping a sample string in the user's document is a failed turn:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nparas = doc.paragraphs # one snapshot — index into THIS list\nblocks = list(doc.element.body) # paragraphs AND tables, in document order\n\nNEW = [ # placeholders — you write every string here\n (\"Regional Performance\", \"Revenue grew in every region except EMEA, where the quarter closed flat.\"),\n (\"Cost Base\", \"Headcount costs fell as the contractor pool wound down, and the saving held.\"),\n]\nTARGET = range(len(NEW)) # which sections to replace — here the first len(NEW)\n\ndef rank(paragraph): # \"Heading 2\" -> 2; a bare \"Heading\" is rank 1\n tail = paragraph.style.name.split()[-1]\n return int(tail) if tail.isdigit() else 1\n\nheads = [i for i, p in enumerate(paras) if p.style.name.startswith(\"Heading\")]\nif len(heads) > 1 and all(rank(paras[heads[0]]) < rank(paras[i]) for i in heads[1:]):\n heads = heads[1:] # a lone heading above all the rest is the title\ntop = min((rank(paras[i]) for i in heads), default=1)\nstarts = [i for i in heads if rank(paras[i]) == top] # sections, never their subsections\nends = [next((j for j in heads if j > i and rank(paras[j]) <= top), len(paras)) for i in starts]\n\nassert NEW, \"NEW is empty — write the replacement content before running the edit\"\nassert len(TARGET) == len(NEW), f\"TARGET names {len(TARGET)} sections but NEW has {len(NEW)} items\"\nassert len(starts) > max(TARGET), f\"TARGET reaches section {max(TARGET) + 1}, but the document has {len(starts)}\"\nat = [blocks.index(p._element) for p in paras] # where each paragraph sits among the blocks\nfor k in TARGET:\n assert ends[k] > starts[k] + 1, f\"section {paras[starts[k]].text!r} has no body paragraph to replace\"\n assert (starts[k], ends[k]) != (heads[0], len(paras)), f\"section {paras[starts[k]].text!r} spans the whole document — that is a rewrite, not a section replacement\"\n span = blocks[at[starts[k]] : at[ends[k]] if ends[k] < len(paras) else len(blocks)]\n assert not any(el.tag.endswith(\"}tbl\") for el in span), f\"section {paras[starts[k]].text!r} holds a table — this recipe replaces paragraphs only\"\n\n# measured from the document, before anything changes — never from what the loop below does\nbefore = len(paras)\nold_body = [p.text for k in TARGET for p in paras[starts[k] + 1 : ends[k]]]\ndoomed = [(p.text, p._element) for k in TARGET for p in paras[starts[k] + 2 : ends[k]]]\nexpected = before - len(old_body) + len(NEW) # each replaced section keeps exactly one body paragraph\n\nfor k, (title, body) in zip(TARGET, NEW):\n paras[starts[k]].text = title # the heading keeps its own style\n paras[starts[k] + 1].text = body\n paras[starts[k] + 1].style = doc.styles[\"Normal\"] # the reused paragraph may have been a bullet\n for p in paras[starts[k] + 2 : ends[k]]: # whatever else the section held\n p._element.getparent().remove(p._element)\n\nassert len(doc.paragraphs) == expected, f\"expected {expected} paragraphs, got {len(doc.paragraphs)} — an old section was not fully replaced, or content was appended\"\nfor text, el in doomed:\n assert el.getparent() is None, f\"an old paragraph is still in the document: {text[:40]!r}\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{len(NEW)} of {len(starts)} sections replaced, {before} -> {len(doc.paragraphs)} paragraphs\")\n```\n\n`TARGET` names the sections to replace, once, and `zip` pairs each new item with\nthe section it overwrites. Replacing a different range is a change to that one\nline — `TARGET = range(2, 5)` for \"sections 3 through 5\", with three items in\n`NEW` to match. Keep it bound in a single place: a range written twice drifts\napart the moment one copy is edited, and every guard below reads `TARGET`\nrather than assuming the range starts at zero.\n\nAssigning `paras[head].text` keeps that paragraph's style, because the style\nlives on the paragraph and not on its runs: a `Heading 2` stays a `Heading 2`.\nOnly the run-level formatting inside it is lost, per the rule above. The body\nparagraph is the opposite case — it is reused, so it arrives carrying whatever\nstyle the old body had, which is why the sample sets it back to `Normal`. Set it\nto something else when the new body should be a bullet or a quote, and guard the\nname as shown under Styles.\n\n**Keep the document's own numbering.** The sample titles carry no `1.`, `2.`\nprefix because the document it edits does not number itself, and a typed prefix\non a `List Number` paragraph double-numbers. When the headings you are\noverwriting *do* carry manual numbers, take each number from the position being\noverwritten so the sequence continues — replacing sections 3 through 5 writes\n`3.`, `4.`, `5.`, never restarting at `1.`\n\n**Every assert, exactly as written — and measured before the loop runs.**\n`old_body` and `expected` come from the document's own structure, never from\nwhat the loop reports about itself. That is the whole point: a loop that\nrewrites only the paragraph after each heading, the mistake this recipe exists\nto prevent, would tally its own work as complete. Derived up front, the numbers\ncontradict it. None of these failures is distinguishable from success by\n`exitCode 0` plus an attachment:\n\n- **`assert NEW`** catches an empty content list. Without it `max(TARGET)`\n raises a bare `ValueError`, and were it not for that the run would save an\n untouched copy of the user's document at `exitCode 0`.\n- **`len(TARGET) == len(NEW)`** catches a target range and a content list that\n drifted apart. `zip` would silently pair only the shorter of the two.\n- **`len(starts) > max(TARGET)`** catches a range reaching past the last\n section. It reads `TARGET`, not `len(NEW)`, because the range need not start\n at zero — a `len(NEW)` check passes on `range(2, 5)` over four sections and\n the run then dies on an `IndexError` that names nothing.\n- **the whole-document assert** refuses a section running from the first\n heading to the last paragraph. That is not a replacement, it is a rewrite:\n every other check passes while the document is emptied down to one heading\n and one paragraph. It anchors on `heads[0]`, not paragraph 0 — a document\n whose only heading sits under a draft notice, a date, or a byline still has\n exactly one section, and anchoring on index 0 would wave it through.\n- **`ends[k] > starts[k] + 1`** catches a section with no body paragraph — a\n heading followed straight by a table, or the last heading in the document.\n There is nothing under it to rewrite. It runs before any mutation, so a bad\n target changes nothing.\n- **the `}tbl` assert** catches a table inside a section being replaced.\n `doc.paragraphs` does not see tables, so the loop below cannot remove one:\n without this the old table survives under the new heading with every other\n check passing. Say the table has to be rebuilt, or target a different section.\n- **the `expected` assert** catches an old section left partly in place *and*\n new content appended, because `expected` is what the paragraph count must be\n once each replaced section holds exactly one body paragraph.\n- **the `getparent() is None` assert** catches an old paragraph the loop was\n supposed to drop but left attached. Compare **elements, not text**: text\n comparison cannot tell a paragraph that survived from an identical one\n standing legitimately elsewhere, and a document that repeats a line — three\n status sections each reading `Nothing to report.` — would fail a correct edit\n with no way to satisfy the assert. Identity has no such collision, and it\n needs no special case for blank paragraphs.\n\nAn assert that fires is a failed turn to diagnose, never a document to deliver.\n\n### When the replacement needs more than one paragraph\n\nThe loop above reuses one paragraph per section and drops the rest. When a\nreplacement needs an **extra** paragraph, insert it before the paragraph that\nshould follow it. `insert_paragraph_before` is the only insert there is, and it\nis a method on the paragraph you want to push down:\n\n```python\nanchor = paras[ends[k]] # the next section's heading\nextra = anchor.insert_paragraph_before(\"A second body paragraph.\", style=\"Normal\")\n```\n\nIt takes the same style names as `add_paragraph` (`\"Heading 2\"`, `\"List\nBullet\"`, `None` for Normal) and returns the new paragraph, so runs can be\nformatted on it. Inserting a whole new section is this call once per paragraph,\neach against the heading it goes above. A section at the very end of the\ndocument has no next heading to anchor to — `ends[k]` is `len(paras)` — so\nappend there with `doc.add_paragraph`, the one case where appending is right.\n\nInserting does not disturb the `paras` snapshot: it is a plain Python list\nholding the paragraphs that already existed, so every index taken before the\ninsert still points at the same paragraph afterwards. Only a fresh\n`doc.paragraphs` shifts.\n\nCount what you insert and fold it into `expected` rather than dropping the\nguard — `expected = before - len(old_body) + len(NEW) + added` — so an\naccidental append is still caught.\n\n## Removing Content\n\npython-docx has **no delete API.** There is no `doc.remove_paragraph` and no\n`paragraph.delete`, and `doc.paragraphs` is rebuilt on every access, so\n`doc.paragraphs.remove(p)` edits a throwaway list and changes nothing in the file.\nRemoving anything means dropping its XML element from the parent — this one line\nis the whole technique, and there is no alternative to it:\n\n```python\np._element.getparent().remove(p._element)\n```\n\nCode that finds the paragraphs and never runs that line — a `for`/`if` that\nmatches the text and falls through, or a comment like\n`# Find and remove paragraphs containing \"Conclusion\"` standing in for the\nremoval — saves a document byte-identical to the input at `exitCode 0`, with an\nattachment that looks like a success. Nothing in the result says the edit was a\nno-op, which is why the sample below asserts the count changed before it saves.\n\nBecause `doc.paragraphs` is a fresh list each time, `for p in doc.paragraphs:`\nwalks a snapshot and removing inside the loop is safe.\n\n**A whole section** — a heading plus everything under it, up to the next heading\nof the same or higher rank — is that line plus a flag. Track the heading's level,\nor a sub-heading inside the section ends the removal early and orphans the\nparagraphs below it:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\n\nTARGET = \"Conclusion\" # the heading text that opens the section\n\nbefore = len(doc.paragraphs)\ndepth = None # the target heading's level while removing\nfor p in doc.paragraphs:\n style = p.style.name # a style object — compare through .name\n if style.startswith(\"Heading\"):\n tail = style.split()[-1]\n level = int(tail) if tail.isdigit() else 1 # \"Heading 2\" -> 2\n if depth is not None and level <= depth:\n depth = None # a sibling heading closes the section\n if p.text.strip() == TARGET:\n depth = level\n if depth is not None:\n p._element.getparent().remove(p._element)\n\nafter = len(doc.paragraphs)\nassert after < before, f\"removed nothing ({before} -> {after}) — the match never fired\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{before} -> {after} paragraphs\")\n```\n\nFind the heading through `p.style.name`, never the text alone — a body paragraph\nthat mentions \"Conclusion\" is not the section heading. Removing individual\nparagraphs is the same loop without the flag: match them, and call the removal\nline on each one.\n\n**Assert the count changed, before you save.** Keep the\n`assert after < before` line exactly where the sample puts it — between the loop\nand `doc.save(...)` — and do not soften it to a `print` or an `if`. It is what\nmakes a no-op impossible to deliver: the assert raises, `save` never runs, so no\nfile is written and the result comes back with `missingOutputs` instead of an\nattachment. Without it a removal that never fired still saves the unchanged\ndocument, and the run is indistinguishable from a real edit — `exitCode 0`, an\nattachment, and nothing anywhere saying the document is a copy of the input.\n\nAn assert that fires is a **failed turn to diagnose**, never a result to report.\nIt means the match did not fire: wrong heading text, a heading style the document\ndoes not use, or text living in a table, header, or footer, which\n`doc.paragraphs` never walks. Fix the match and rerun — do not delete the assert\nto get a file out.\n\nThe printed `before -> after` line is then just the reply line (`16 -> 12\nparagraphs`), not the check. Both live inside the build, so this takes no extra\ncall: the assert and the `print` are in the same `exec` that does the removal.\n\nThis assert is also why \"Success = stop\" needs no second call on a destructive\nedit: `exitCode: 0` plus an attachment cannot on its own tell a real edit from\na copy of the input, because a removal that never fired produces both. The\nbuild itself closes that gap — it asserts the count changed before `save`, so\na no-op returns `missingOutputs` rather than a convincing attachment. A\ndelivered document is still never reopened to \"verify\" it; the fix for a\nfailed assert is a corrected build, never an `exec` opened to inspect what was\nalready delivered.\n\n**Table rows** have no delete API either, and take the same idiom on the row's own\nelement. `table.rows` iterates a snapshot just as `doc.paragraphs` does, so\nremoving inside the loop is safe — and the count gets the same assert, because a\nrow matched by its cell text can miss exactly the way a paragraph can:\n\n```python\ntable = doc.tables[0]\n\nbefore = len(table.rows)\nfor row in table.rows:\n if row.cells[0].text == \"Discontinued\":\n row._element.getparent().remove(row._element)\nassert len(table.rows) < before, f\"no row matched ({before} rows unchanged)\"\n```\n\nRemoving a row by position needs no assert — `table.rows[9]` on a 4-row table\nraises `IndexError` rather than quietly doing nothing:\n\n```python\nrow = table.rows[2]\nrow._element.getparent().remove(row._element)\n```\n\nRows only grow at the bottom: there is no insert-at, and no delete either\noutside this idiom. Address cells as `table.cell(row, col)` or\n`table.rows[r].cells[c]` — they are the same cell; for formatting inside a\ncell go through `cell.paragraphs[0]` and its runs like any other paragraph.\n\n**Table columns cannot be removed.** A column is not one element — it is an entry\nin the table grid plus one cell in every row — and a horizontally merged cell is a\nsingle `<w:tc>` shared across two grid positions, so removing \"the second cell\" of\nevery row deletes that merged cell whole and leaves its row a column short. The\ndocument opens visibly ragged and nothing raises. Rebuild the table with the\ncolumns you want instead, or say the column has to be dropped in Word.\n\n## Reading What Is Already in the Document\n\nA document exposes exactly two collections — `doc.paragraphs` and `doc.tables`.\nEverything else is derived by filtering them; there is no `doc.headings`, no\n`doc.sections_by_title`, no `doc.text`. A `Paragraph` has `.text`, `.style` and\n`.runs`, and no `.paragraphs` of its own.\n\n**`paragraph.style` is a style object, not a string** — compare through\n`.name`, or you get\n`AttributeError: 'ParagraphStyle' object has no attribute 'startswith'`:\n\n```python\nheadings = [p for p in doc.paragraphs if p.style.name.startswith(\"Heading\")]\nbody = [p for p in doc.paragraphs if p.style.name == \"Normal\"]\n```\n\nMost edits need no inspection at all — go straight to the change. When a look\nis genuinely needed first (an exact `.text` to match, a style name), that call\nonly prints: **an inspection never saves and declares no `outputs`** — a save\nwithout the change delivers a stale copy of the user's document. The new file\ncomes only from the one call that changes it. Reading for the _user_ — a\nsummary or an answer delivered as chat text — is its own flow with its own\ncall shape: load `references/read.md`.\n\n## Resizing Text: Set the Styles, Never Scale `run.font.size`\n\n`run.font.size` is `None` whenever the size comes from the paragraph's style,\nwhich is the normal case for a document you did not hand-size. **`None` does not\nmean zero.** Reading it as a number and scaling it writes a 0pt font, and 0pt\ntext is invisible in Word and Pages — the document opens looking blank, with no\nerror anywhere to tell you why:\n\n```python\nsize = run.font.size.pt if run.font.size else 0\nrun.font.size = Pt(size * 1.5) # WRONG: 0 * 1.5 = 0pt, invisible text\n```\n\n\"Make the font bigger\" is a change to the **styles**, because every run without\nits own size inherits from them. Set absolute point sizes on the styles the\ndocument actually uses, and the whole document — body, tables, headers — follows\nin four lines:\n\n```python\nfrom docx import Document\nfrom docx.shared import Pt\n\ndoc = Document(\"existing.docx\")\n\ndoc.styles[\"Normal\"].font.size = Pt(14) # body text; 11pt is the default\ndoc.styles[\"List Bullet\"].font.size = Pt(14)\ndoc.styles[\"Heading 1\"].font.size = Pt(20)\ndoc.styles[\"Title\"].font.size = Pt(32)\n\ndoc.save(\"larger.docx\")\nprint(f\"{len(doc.paragraphs)} paragraphs resized\")\n```\n\n**Keep the hierarchy.** Raise every style you touch, not one size for all of\nthem — a title and a heading set to the body size read as unstyled text. Body\naround 14pt pairs with roughly 20pt headings and a 32pt title, and the same\nratios hold at any size the user asks for.\n\nOnly touch a style the document has — guard with the `doc.styles` check above\nwhen unsure. If a specific run really must be sized on its own, assign an\nabsolute `Pt(...)` value; never one derived from the size you read back.\n\n## Errors\n\n- `PackageNotFoundError: Package not found at '…'` — the document was never\n staged, or an id-less entry staged an image under a `.docx` path. Add\n `inputs: [{ \"attachmentId\": \"<id from the exec result or the [Attached file …]\nline>\", \"path\": \"existing.docx\" }]` and open that exact path. If no id is\n available, ask the user to attach the file again rather than guessing a name.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). Re-copy the exact id\n from the `exec` result or the `[Attached file …]` line that names the file;\n if no id appears anywhere in the chat, ask the user to re-attach.\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `KeyError: \"no style with name '…'\"` — the style is not in this document.\n Check `doc.styles` and fall back as shown under Styles.\n- `NameError: name 'Pt' is not defined` (or `Inches`, `RGBColor`) — the import\n line is missing that name; they all import from `docx.shared`.\n- `AssertionError: removed nothing (16 -> 16)` means the removal matched nothing:\n either the loop never fired or it never called\n `p._element.getparent().remove(p._element)`; python-docx has no delete method to\n reach for instead. Diagnose the match and rerun — never delete the assert to get\n a file out, since the file it would produce is a copy of the input.\n- `AssertionError: expected 7 paragraphs, got 9` means the document did not end\n up the shape a replacement makes. Two causes: the old sections were only\n partly replaced — the loop rewrote the paragraph after each heading and left\n the rest of the section standing — or new content was appended with\n `add_paragraph`/`add_heading`, which only ever append. `expected` is derived\n from the section bounds before anything changes, so it is right and the\n document is wrong: replace each section through to the next heading.\n- `AssertionError: an old paragraph is still in the document: '…'` means the\n loop was adapted and no longer drops everything past the paragraph it reuses.\n Every paragraph from `starts[k] + 2` to `ends[k]` has to go; the removal idiom\n below is the only thing that removes one. This compares elements, so it never\n fires because the document happens to repeat a line elsewhere.\n- `AssertionError: section '…' has no body paragraph to replace` means that\n heading is followed straight by a table, or is the last paragraph in the\n document. There is nothing under it to rewrite: target a different section, or\n insert the body with `insert_paragraph_before` before adding to it.\n- `IndexError: list index out of range` while walking sections means an index\n ran past the end of `starts` or of `paras`: a `TARGET` reaching past the last\n section, or `paras[i + 1]` on a document whose final paragraph is a heading.\n Guard the range with `len(starts) > max(TARGET)` — not against `len(NEW)`,\n which says nothing when the range does not start at zero — and take section\n ends from the next heading of the same or higher rank, with `len(paras)`\n closing the last one.\n- `AssertionError: section '…' holds a table` means the section being replaced\n contains a table. `doc.paragraphs` never sees tables, so the loop cannot\n remove one and it would survive under the new heading. Rebuild the table\n explicitly, or tell the user that section has to be replaced by hand.\n- `AssertionError: TARGET names 2 sections but NEW has 3 items` means the range\n and the content list drifted apart. Fix whichever is wrong; do not let `zip`\n quietly use the shorter.\n- `AssertionError: TARGET reaches section 2, but the document has 1` on a\n document that plainly has several usually means its sections are `Heading 2`\n under a `Heading 1` title. The title is dropped before sections are picked,\n so check `rank()` is reading the style names this document actually uses —\n print `[p.style.name for p in doc.paragraphs]` — rather than lowering\n `TARGET` until the assert passes. Section 1 of a title-only document is the\n whole document.\n- `AssertionError: section '…' spans the whole document` means the heading\n selected covers every paragraph, so replacing it would empty the document.\n It is a title being treated as a section, or a request to rewrite rather than\n edit — build a new document with `references/create.md` if that is what the\n user wants.\n- `AttributeError: 'Document' object has no attribute 'insert_paragraph'` means\n the code guessed an insert API on the document. There is none. The only insert\n is `paragraph.insert_paragraph_before(text, style)`, on the paragraph the new\n one goes above.\n- `TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments\n but 4 were given` means a position was passed to `add_paragraph`. It has no\n position parameter and always appends; use `insert_paragraph_before`.\n- Identical old and new fingerprints on a run that saved anyway means the\n assert was softened into `print` lines and both hashes were taken before the\n change. It is not evidence the edit failed, and it is not grounds for another\n `exec`: restore the assert and take the second hash after the mutation.\n- A delivered document identical to the one you opened means an edit ran\n without the fingerprint assert — an edit that matched nothing, or an\n inspection that saved. Add the assert before `save` and rerun the actual\n change.\n- `AssertionError: nothing changed — the edit matched nothing or never ran`\n means exactly that: the paragraph match found no text, or no mutation\n happened before `save`. Print the real `.text` values, fix the match, rerun\n — never remove the assert.\n- `TypeError: 'Table' object is not subscriptable` — a table was indexed\n directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or\n `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.\n- `AttributeError: 'Document' object has no attribute 'remove_paragraph'` (or\n `'Paragraph' object has no attribute 'delete'`) means the code guessed a delete\n API. There is none; drop the XML element instead.\n- A resize that \"worked\" but left the document blank means a 0pt font: something\n scaled `run.font.size` while it was `None`. Set absolute sizes on the styles\n instead — see Resizing Text.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to\n `python -c` or change the package pin.\n- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`\n about positional arguments the call passes the wrong number of them — usually\n several strings where one is allowed. Fix either against this file's examples,\n reading the line number in the traceback. Do not retry the same call, and do\n not switch to a shell.\n- If the result has `missingOutputs`, the file was never written. Read stderr\n first: an `AssertionError` there means a guard stopped the save on purpose\n and its message names what to fix — rerunning the same code fails the same\n way. Only when stderr is clean is this a naming problem: check the `save()`\n name matches the declared output and rerun once.\n- Never print the document's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints exactly one line (e.g. `9\nparagraphs, 1 table(s)`).\n- Never pass an absolute path to `save()`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.docx`, the edit is done —\nthe `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already\nattached to the chat for the user to open or save. Stop tool use and reply\nwith a single line: file name + the count line from stdout. Exactly one\nsuccessful `exec` per request; never reopen a delivered document to \"verify\"\nit.\n",
77
+ "word/SKILL.md": "---\nname: word\ndescription: Create, edit, or read Word (.docx) documents with python-docx — deliver documents as chat attachments, or read an attached one to summarize it or answer questions in the chat. Can embed images generated in the chat. Opens in Pages and Google Docs too.\naliases: [docx, word-document, memo]\npreload_on_name: false\ntools: [exec(python)]\nplatform: [darwin, linux, win32]\nmetadata:\n {\n \"openclaw\":\n {\n \"setup\":\n {\n \"summary\": \"Runs python-docx in the in-process Python runtime, from packages that ship with the app. The first use waits for the runtime to start.\"\n }\n }\n }\n---\n\n# Word\n\nBuild, change, or read `.docx` documents. This file holds no Python and no\nrecipe: it only says which reference file to load. Load exactly one with the\n`skill` tool, then do what that file says.\n\n## Which File to Load\n\nPick the row by **what the user wants done**, then make that exact `skill`\ncall. \"Edit\", \"update\", \"modify\", \"change\", \"replace\", \"rewrite\" and \"fix\" all\nmean the same thing here the verb never picks the row, the change does.\n\n| The user wants | The `skill` call |\n| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |\n| A new document \"write a report\", \"make a doc with 30 fun facts about cats\", \"draft a letter\" | `{\"name\": \"word\", \"file\": \"references/create.md\"}` |\n| Some paragraphs of an existing document changed — \"replace the first 10 facts with dog facts\", \"change fact 3\", \"reword paragraph 7\", \"swap these bullets for those\" | `{\"name\": \"word\", \"file\": \"references/paragraphs.md\"}` |\n| Anything else done to an existing document add a section, remove or rewrite a whole section, make the text bigger, put an image in it | `{\"name\": \"word\", \"file\": \"references/rework.md\"}` |\n| An answer in the chat from an attached document — \"summarize this\", \"what does it say about X\" | `{\"name\": \"word\", \"file\": \"references/read.md\"}` |\n\n- A document that already exists in this chat is never rebuilt with\n `create.md`that throws away everything the user has. Its `attachmentId`\n is in the `exec` result that produced it or on the `[Attached file …]` line.\n- A summary delivered as a file is `read.md` first, then `create.md`.\n- `paragraphs.md` runs two scripts bundled with this skill and contains no\n Python. `rework.md` and `create.md` carry the python-docx recipes to copy.\n\nEach load is a real `skill` tool call — printing the call as JSON or text in\nyour reply loads nothing. Never write the Python from memory: the recipes carry\nrules (exact version pins, attachment staging, the only working removal idiom)\nthat fail in non-obvious ways when improvised, and loading the file is one\ncheap read-only call.\n\n## When to Use\n\n- The user asks for a document, report, letter, memo, `.docx`, or Word file.\n- The user attaches a `.docx` and wants its content changed, replaced in part,\n extended, trimmed, or reworked.\n- The user attaches a `.docx` and asks what it says — a summary, a question\n answered, or content pulled out into the chat.\n- The user wants a document that embeds images generated in this chat.\n\n## When NOT to Use\n\n- The user wants text in the chat and no document is involved — just write it.\n Summarizing or answering from an attached `.docx` **is** this skill: load\n `references/read.md`.\n- The user wants slides or a deck — that is the presentations skill.\n- The user wants a spreadsheet or a PDF — python-docx writes only `.docx`.\n\n## What This Skill Cannot Do\n\nSay so instead of faking these; a fake is worse than a clear \"not supported\":\n\n- **No table of contents.** A real TOC is a Word field that Word itself computes;\n python-docx cannot insert one. Do not fake a TOC by typing headings and page\n numbers — the page numbers would be wrong. Offer headings (`Heading 1..9`)\n instead; Word can generate a TOC from them later.\n- **No tracked changes or comments.** There is no revisions API. Edits land as\n plain content; say that when the user asks for a redline.\n- **No legacy `.doc`.** Only `.docx`. A `.doc` output name is rejected — name it\n `.docx`.\n- **No PDF export and no rendering.** The runtime cannot convert or preview the\n document; it can only write the file.\n\n## Rules for Every Job\n\n**You build it, not the user.** Deliver the document, never the recipe. Do NOT\nprint the python source in chat, do NOT tell the user to install python-docx,\nrun a script, or open a terminal — they have no terminal in this chat and the\ncode would not run there. The document exists only if an `exec` call with\n`outputs` succeeds and returns the attachment; falling back to \"here is the\nscript, run it yourself\" is a failed turn.\n\n**Success = stop.** When `exitCode` is `0` and the result's `attachments`\nlists the `.docx`, the document is done. Do not call `exec` again for the same\nrequest — not to \"confirm\", not to \"improve\", not to \"add the image\" after the\nfact. Exactly one successful _build_ `exec` per document request — a\nno-`outputs` read that precedes a build delivers nothing and is not one of\nthem, but it belongs before the build, never after it. Reply with a single\nline: file name + the count line from stdout. If the result has\n`missingOutputs` instead, the file was never written: read stderr first — an\n`AssertionError` there means a guard stopped the save on purpose (see the\nrework recipe); only when stderr is clean check the `save()` name matches the\ndeclared output and rerun once.\n\n**Failures are fixed in the code, not around it.** An error in your code is\nnever a fault in python-docx or in the runtime; fix the Python against the\nloaded reference file's recipes and Errors and call `exec` again. A bundled\nscript that stops with a message is fixed by correcting its arguments and\nrerunning the same script — never by writing Python in its place. If two\nconsecutive calls fail with the same error, re-read the traceback\nline-by-line before a third — retrying the identical `command`, or a version\nwith only cosmetic changes, is a loop, not a fix. Do not switch package pins\n(keep `python-docx==1.2.0`), do not wrap source in `python -c` / `pip` /\nshell, do not \"debug\" with `os.listdir` or no-op scripts while `outputs`\nstill lists the document, and do not write the document as markdown/chat text\ninstead of a `.docx`. Never search the web about an error; the answer is\nalways in the `exec` result you already have.\n\n**The runtime is sealed.** There is no shell — `ls`, `cat`, and `file` raise\n`SyntaxError` because `command` is Python source — and no network:\n`requests`, `urllib`, and `socket` all fail. The working directory starts\nempty on every call: a file from an earlier call is gone unless staged again,\nand a file you write but do not declare in `outputs` is discarded. The `exec`\nresult is the only account of what happened — there is no filesystem to check\nand no shell to check it with.\n\n**Never overwrite a staged input.** Changes always save a new output name,\nderived from the document changed — `report.docx` becomes `report_revised.docx`,\nnever a fresh name taken from the new content.\n\n**A change happens inside the document.** `add_paragraph` and `add_heading`\nappend at the end and nowhere else, so replacing content that is already there\nmeans rewriting those paragraphs, not adding new ones. Delivering the original\nwith the new version appended, or a fresh document holding only the new\ncontent, is a failed turn.\n",
78
+ "word/references/create.md": "# Creating a Word Document (python-docx)\n\nCreate a new `.docx` from scratch by running Python through the `exec` tool.\nA new document needs **no** `inputs` — do not invent attachment ids — unless\nit embeds an image (see Embedding Images). **Exactly one** `exec` call per\nuser request when that call succeeds.\n\n**A document that already exists in this chat is never rebuilt here.** \"Replace\nthe first 10 facts\", \"reword this\", \"add a section\" — any request that starts\nfrom an existing `.docx` is a change to that document: some of its paragraphs\nis `references/paragraphs.md`, anything else is `references/rework.md`, and\neither one stages the document by its `attachmentId`. Building a fresh\ndocument for such a request throws away everything the user already has.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"outputs\": [\"report.docx\"],\n \"command\": \"...\"\n}\n```\n\n- `language` — always `\"python\"`.\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. Pin the version; an unpinned\n install resolves a potentially different library version. This exact version\n ships with the app and installs with no network; any other version has to be\n downloaded, which fails on a device that is offline.\n- `outputs` — `[\"report.docx\"]`. `save(\"report.docx\")` must match the declared\n output name. A file you write but do not declare here is discarded. A `.doc`\n output name is rejected — name it `.docx`.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. Its first line is the first line of Python\n that runs: there is no shell and no interpreter to invoke, and no\n installer — packages are declared in `packages`.\n\n## Embedding Images\n\nTwo kinds of image input, told apart by where the file came from:\n\n**Tool-produced images** (`generate_image` output): stage them with the exact\n`attachmentId` from the tool result — never placeholders like `att_image` or\nany id you made up.\n\n**Images the user uploaded** (\"use this photo\"): there is no id to copy — an\nuploaded image never shows one. Stage it with `path` only and **no\n`attachmentId` key**; the first id-less entry is the first image of the user's\nlatest message, the second is its second image, and so on. Id-less entries\nresolve _images only_.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"path\": \"photo.png\" }],\n \"outputs\": [\"report.docx\"],\n \"command\": \"...\"\n}\n```\n\nStaged files land in the working directory under the bare `path` names —\nreference `doc.add_picture(\"photo.png\", …)` by that name only. Paths must be\nunique bare filenames. `attachment … not found in this chat` means you\ninvented an id or the file is not attached: re-copy the exact id from the tool\nresult, or for a document with no image drop `inputs` entirely.\n\nIf the image was staged in `inputs`, embed it in **that** single build with\n`doc.add_picture` — never deliver a document and then rebuild to add the\nimage. Soft-failing (`try`/`except` around the picture) and saving without it\nis a failed turn, not a success.\n\n**Image URLs do not work — never download.** Your Python code has **no\nnetwork access**: `requests`, `urllib`, and `socket` all fail with a network\nerror, and `http_request` returns truncated text, never image bytes. When the\nuser gives an image URL, do not try to fetch it from Python and do not retry\nthrough other tools — that is a dead end. Say the link cannot be downloaded\nand ask the user to attach the image itself, or offer `generate_image` for a\nsimilar visual. Then build the document with the staged attachment as above.\n\n## Which Shape\n\n| The user asks for | Shape |\n| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| \"30 fun facts about cats\", \"10 tips for …\", \"a list of …\", any number of items or points | **List** — a title, then exactly N `List Bullet` paragraphs and nothing else: no intro sentence, no section headings, no numbers typed into the text |\n| a report, memo, letter, plan — anything with sections | **Report** — the recipe under The Recipe below |\n\n### The list shape\n\n```python\nfrom docx import Document\n\ndoc = Document()\ndoc.add_heading(\"30 Fun Facts About Cats\", level=0)\nfacts = [\n \"Cats sleep for about 70 percent of their lives.\",\n \"A group of cats is called a clowder.\",\n \"A cat's nose print is unique, like a fingerprint.\",\n] # one plain string per item — write all N here\nfor fact in facts:\n doc.add_paragraph(fact, style=\"List Bullet\")\ndoc.save(\"cat_facts.docx\") # must match the declared output exactly\nprint(f\"{len(facts)} items, {len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\nOne string per item, as many as the user asked for. No headings between\ngroups of items and no introductory sentence: each of those is a paragraph the\nuser did not ask for, and a later \"change the first 10 items\" then lands on the\nwrong lines. The count line it prints is the reply — a delivered list is done,\nwhatever the count says; never rebuild it to fix the number.\n\n## The Recipe\n\nStart from this for a report. It is a complete, working document — a title, headings,\nparagraphs with bold and italic runs, a bulleted list, and a table — saved\nunder the declared output name. Copy it and change the content; do not\nassemble a document from memory.\n\n**Keep the source multi-line.** A `for`/`if`/`with` after a semicolon is a\n`SyntaxError` — paste the block with real newlines, not `stmt; for x in y: …`.\n\n**Hold content in plain lists of strings, and walk them.** Every list of bullets\nis a flat `[\"…\", \"…\"]`, and every table is a list of row lists. Do not reach for\na dict, a tuple of mixed widths, or a nested comprehension to hold document\ncontent — those are where a `SyntaxError` or a\n`ValueError: too many values to unpack` comes from, and they buy nothing here.\n\n**Keep every underscore in API names.** `add_heading`, `add_paragraph`,\n`add_run`, `add_table`, `add_row`, `add_picture`, `add_page_break` — stripping\nthem to `addheading` / `addparagraph` fails. Copy identifiers exactly as written\nbelow:\n\n```python\nfrom docx import Document\nfrom docx.shared import Inches, Pt, RGBColor # one import line covers sizes, widths, colors\n\ndoc = Document()\n\ndoc.add_heading(\"Quarterly Report\", level=0)\ndoc.add_paragraph(\"Prepared by the finance team.\")\n\ndoc.add_heading(\"Summary\", level=1)\np = doc.add_paragraph(\"Revenue grew \")\nstrong = p.add_run(\"18 percent\")\nstrong.bold = True\np.add_run(\" against a \")\nemphasis = p.add_run(\"flat\")\nemphasis.italic = True\np.add_run(\" cost base.\")\n\ndoc.add_heading(\"Highlights\", level=1)\nfor point in [\n \"New retail partners in two regions\",\n \"Churn down for the third quarter\",\n \"Support backlog cleared\",\n]:\n doc.add_paragraph(point, style=\"List Bullet\")\n\ndoc.add_heading(\"Key Figures\", level=1)\nfigures = [\n [\"Metric\", \"Q3\", \"Q4\"], # first list is the header row\n [\"Revenue\", \"$1.2M\", \"$1.4M\"],\n [\"Costs\", \"$0.9M\", \"$0.9M\"],\n]\ntable = doc.add_table(rows=1, cols=len(figures[0]))\ntable.style = \"Table Grid\"\nfor index, cells in enumerate(figures):\n row = table.rows[0].cells if index == 0 else table.add_row().cells\n for column, value in enumerate(cells):\n row[column].text = value\n\ndoc.save(\"report.docx\") # must match the declared output exactly\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\n## Write a document, not markdown\n\nA `.docx` carries real styles, so the structure is the style — never the\npunctuation. Markdown written into text stays there verbatim and reads as a\ntypo in the finished document:\n\n- **No markdown characters in any string.** `#`, `##`, `-`, `*`, `1.`, `**bold**`\n and backticks all render literally. `add_heading(\"Security\", level=2)` — never\n `add_heading(\"- Security\", level=2)` or `\"## Security\"`. A numbered list is\n `style=\"List Number\"`, which numbers itself; a typed `\"1. \"` prefix double-numbers.\n- **No typed rules or line breaks.** A row of dashes or underscores as a section\n divider is just those characters on the page, and a leading `\"\\n\"` is a blank\n line inside the paragraph. Headings already separate sections.\n- **Every section title is a heading.** A first section called \"Introduction\" or\n \"Overview\" goes through `add_heading(..., level=1)` like every other one; as a\n plain `add_paragraph` it renders as body text and the document looks unstructured.\n- **No blank paragraphs for spacing.** `add_paragraph(\"\")` leaves a visible gap —\n the heading and body styles already carry their own space before and after.\n- **Bold is for a few words, not a sentence.** A fully bold paragraph reads as a\n formatting mistake; bold the term, then continue in a normal run.\n\n## One paragraph, one string\n\n`add_paragraph` takes a single text string, optionally with `style=` — nothing\nelse. Several sentences passed positionally raise\n`TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments but 4\nwere given`. Join them into one string, or open the paragraph with the first\npiece and add the rest as runs:\n\n```python\np = doc.add_paragraph(\"As of 2026, Bitcoin is widely held. \")\np.add_run(\"Adoption keeps growing.\")\n```\n\n**The text you pass to `add_paragraph` is already the paragraph's first run.** A\nrun added afterwards _appends_ — repeating any of those words writes them twice\ninto the document (`\"…finite supplyfinite supply\"`). Each run carries the next\nwords and only those, so give a mixed-format paragraph an empty start and add\nevery piece as its own run:\n\n```python\np = doc.add_paragraph()\np.add_run(\"Digital scarcity \")\ntail = p.add_run(\"and a finite supply\")\ntail.italic = True\n```\n\n## Bold and italic live on runs, never on paragraphs\n\n`paragraph.bold = True` raises no error and changes **nothing** in the file — a\nparagraph has no bold; the assignment lands on the Python object and is silently\ndiscarded on save. Formatting belongs to runs:\n\n```python\np = doc.add_paragraph(\"normal, then \")\nstrong = p.add_run(\"bold\")\nstrong.bold = True\np.add_run(\" and \")\nemphasis = p.add_run(\"italic\")\nemphasis.italic = True\n```\n\nTwo rules make that shape the only one to write:\n\n- **`add_run` takes the text and nothing else.** `p.add_run(\"x\", bold=True)`\n raises `TypeError: Paragraph.add_run() got an unexpected keyword argument\n'bold'` — create the run, then set the attribute.\n- **Never chain an attribute onto the `add_run(...)` call.** Name the run on one\n line and format it on the next, as above. A run that needs no formatting is a\n bare `p.add_run(\"plain text\")` and the line ends there — a trailing `.` left\n over from a half-written chain is `SyntaxError: invalid syntax`.\n- **Runs join with no gap between them.** The next run starts exactly where the\n last one ended, so the separating space belongs inside one of the strings —\n `\"…without intermediaries. \"` then `\"It was invented\"`, never\n `\"…intermediaries.\"` followed by `\"It was invented\"`.\n\n**`add_run` belongs to the paragraph, not to a run.** Keep the paragraph in a\nvariable and call `p.add_run(...)` for every run in it — chaining a second run off\nthe first raises `AttributeError: 'Run' object has no attribute 'add_run'`. A run\nowns `.text`, `.bold`, `.italic` and `.font`, and nothing else: it has no\n`add_run`, no `add_paragraph`, and no `.style`.\n\nA run is also not a string: `p.add_run(\" \") * 2` raises\n`TypeError: unsupported operand type(s) for *: 'Run' and 'int'`. Put any repeated\ntext inside the string itself — and reach for neither, since spacing is the\nstyle's job, not padding you type.\n\nCharacter detail goes through `run.font` — size, color:\n\n```python\nfrom docx.shared import Pt, RGBColor\n\np = doc.add_paragraph()\nrun = p.add_run(\"Key finding\")\nrun.font.size = Pt(14)\nrun.font.color.rgb = RGBColor(0x1A, 0x73, 0xE8) # RGB in all caps\n```\n\n`Pt`, `Inches`, and `RGBColor` all import from `docx.shared` — there is no\n`docx.util` and no `docx.dml.color`; those are python-pptx paths and fail here.\n\n## Styles must exist in the document\n\n`style=\"List Bullet\"` names a style **inside the document**. A missing name\nraises `KeyError: \"no style with name 'List Bullet'\"` at `add_paragraph` time.\n\nA **new** `Document()` ships these styles — safe to use without checking:\n`Title`, `Heading 1` … `Heading 9`, `Normal`, `List Bullet` (+ ` 2`, ` 3`),\n`List Number` (+ ` 2`, ` 3`), `Intense Quote`, and the table style `Table Grid`.\nDo not invent other names for a new document. (An uploaded document carries\nonly its own styles — when editing one, load `references/rework.md` for the\nguard.)\n\n## Headings and lists\n\n- `doc.add_heading(text, level=N)` — level `0` is the document title style,\n `1`–`9` map to `Heading 1`–`Heading 9`. Any other level raises\n `ValueError: level must be in range 0-9`.\n- Bullets: one `add_paragraph(point, style=\"List Bullet\")` per point, over a flat\n list of plain strings. Never pack several points into one paragraph with `\\n` —\n a `\\n` is a soft line break inside the same list item, not a new bullet. A\n bullet that needs a label and a detail is one string (`\"Limited supply — 21\nmillion coins\"`), never a dict entry or a tuple.\n- Numbered lists: `style=\"List Number\"`. Indent a level with `List Bullet 2` /\n `List Number 2`.\n\n## Tables\n\nWrite the whole table as a list of row lists — header first — then let the code\nabove derive everything from it. **Always `rows=1` and `cols=len(rows[0])`**:\n\n```python\nrows = [\n [\"Item\", \"Status\"], # header\n [\"Search\", \"Shipped\"],\n [\"Export\", \"In review\"],\n]\ntable = doc.add_table(rows=1, cols=len(rows[0]))\ntable.style = \"Table Grid\" # borders; omit for invisible grid\nfor index, cells in enumerate(rows):\n row = table.rows[0].cells if index == 0 else table.add_row().cells\n for column, value in enumerate(cells):\n row[column].text = value\n```\n\nThat shape exists because the two hand-written alternatives both fail:\n\n- **`rows=` is a count of blank rows created immediately, not a maximum.**\n `add_table(rows=4, …)` followed by `add_row()` per entry leaves three empty\n rows sitting between the header and the data, plainly visible in the finished\n document. `rows=1` is the header; every other row comes from `add_row()`.\n- **Unpacking a row into fixed names breaks the moment a row is a different\n width.** `for name, q3, q4 in data:` raises\n `ValueError: too many values to unpack (expected 3, got 4)`, and hand-counting\n `cols=` against the data is the same mistake one step earlier. Index the cells\n instead, and take the column count from the header.\n\nAddress cells as `table.cell(row, col)` or `table.rows[r].cells[c]` — they are\nthe same cell. Rows only grow at the bottom: there is no insert-at.\n`table.rows[9]` on a 4-row table raises `IndexError`. Write text with\n`cell.text = \"…\"`; for formatting inside a cell go through `cell.paragraphs[0]`\nand its runs like any other paragraph.\n\n## Images and page breaks\n\n`doc.add_picture(name, width=…)` appends the image in its own paragraph. Pass\nonly one of `width`/`height`; passing both distorts the picture.\n\n```python\nfrom docx.shared import Inches\n\ndoc.add_picture(\"figure1.png\", width=Inches(5.5))\ndoc.add_page_break()\n```\n\n**Do not soft-fail images or imports.** Never wrap `add_picture` or an import in\n`try`/`except` that prints a warning and continues. A missing file must raise so\nyou fix it and rerun — a document saved without the requested image is a failed\nturn, not a success.\n\n## Errors\n\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `TypeError: 'Table' object is not subscriptable` — a table was indexed\n directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or\n `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.\n- `KeyError: \"no style with name '…'\"` — the style is not in this document. For\n a new document use only the names listed under Styles.\n- `NameError: name 'RGBColor' is not defined` (or `Pt`, `Inches`) — the import\n line is missing that name. Keep the sample's single\n `from docx.shared import Inches, Pt, RGBColor` rather than importing one at a time.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to\n `python -c` or change the package pin.\n- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`\n about positional arguments the call passes the wrong number of them — usually\n several strings where one is allowed. Fix either against this file's examples,\n reading the line number in the traceback. Do not retry the same call, and do\n not switch to a shell.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). For a new document,\n omit `inputs` entirely and rerun. Only stage real ids from prior tool results.\n- Never print the document's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints exactly one line (e.g. `9\nparagraphs, 1 table(s)`).\n- Never pass an absolute path to `save()`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.docx`, the document is\ndone — the `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already\nattached to the chat for the user to open or save, exactly like a\n`generate_image` result. Stop tool use and reply with a single line: file name\n\n- the count line from stdout. Exactly one successful `exec` per request. If\n the result has `missingOutputs` instead, the file was never written: check the\n `save()` name matches the declared output and rerun once.\n",
79
+ "word/references/paragraphs.md": "# Changing Some Paragraphs of a Document (bundled scripts)\n\n\"Replace the first 10 facts\", \"change fact 3\", \"swap these bullets for those\",\n\"reword paragraph 7\": two `exec` calls, both running a script bundled with this\nskill. **Write no Python.** There is no `command` in this job — a call with\n`command` is the wrong call. Pass `skill`, `script`, and `scriptArgs` exactly as\nshown, with `inputs` staging the document by its `attachmentId` (from the\nearlier `exec` result or the `[Attached file …]` line — copy it verbatim, never\ninvent one).\n\n| Step | The `exec` call |\n| --------------------------------------- | ---------------------------------------------------------------- |\n| 1. see the paragraphs and their indexes | `scripts/list_paragraphs.py`, `inputs` staged, no `outputs` |\n| 2. replace exactly the chosen indexes | `scripts/replace_paragraphs.py`, `inputs` staged, one `outputs` |\n\n## Step 0 — find the document's `attachmentId`\n\nThe id is in the chat already, never invented: a document built earlier in\nthis chat has it in the `attachments` of the `exec` result that produced it —\n`{\"attachmentId\":\"922bd4e17517b90593be1c5ae4f12fbd\",\"fileName\":\"cat_facts.docx\"}`\n— and a document the user uploaded has it on the `[Attached file …]` line of\ntheir message. Copy that exact id into `inputs`. An `inputs` entry with a\n`path` and no `attachmentId` is an _image_ upload and is refused for a\ndocument:\n\n```json\n{ \"inputs\": [{ \"path\": \"existing.docx\" }] }\n```\n\n## Step 1 — list the paragraphs (no `outputs`)\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"skill\": \"word\",\n \"script\": \"scripts/list_paragraphs.py\",\n \"scriptArgs\": [\"existing.docx\"]\n}\n```\n\nEvery key above is required — `inputs` with the document's real\n`attachmentId`, `skill`, `script`, `scriptArgs`. A call missing `skill` or\n`inputs` is refused.\n\nIt prints one line per paragraph — `index`, style, text — then a count line.\nPick the indexes to replace from that list:\n\n- Only body paragraphs (`Normal`, `List Bullet`, `List Number`) are facts,\n points, or bullets. `Title`, `Heading N`, and an intro sentence are never\n counted as one.\n- \"The first 10 facts\" = the first 10 body-paragraph indexes after the heading\n or sentence that introduces them — not indexes 0–9.\n- Fewer facts in the document than asked for: replace the ones that exist and\n say so in the reply.\n\n## Step 2 — replace exactly those paragraphs (one `outputs` entry)\n\n`scriptArgs` is: input name, output name, then `index, new text` pairs — one\npair per replaced paragraph, as many pairs as facts requested. The output name\nkeeps the input's stem plus `_revised`.\n\nCount the pairs before sending: \"the first 10 facts\" is 10 pairs — 20 strings\nafter the two file names, 10 different sentences, the last index being\nstart + 9.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"outputs\": [\"existing_revised.docx\"],\n \"skill\": \"word\",\n \"script\": \"scripts/replace_paragraphs.py\",\n \"scriptArgs\": [\n \"existing.docx\", \"existing_revised.docx\",\n \"3\", \"Dogs have about 1,700 taste buds.\",\n \"4\", \"A dog's nose print is unique, like a fingerprint.\"\n ]\n}\n```\n\nWrong, for this job — a `command` instead of a `script`:\n\n```json\n{ \"command\": \"from docx import Document\\ndoc = Document(\\\"existing.docx\\\")\\nfor i in range(1, 11): ...\" }\n```\n\nEach new text is one complete plain sentence, no markdown, each different. The\nscript keeps each paragraph's paragraph style, refuses a heading index,\nrefuses text that already reads the same, and prints\n`K of N paragraphs replaced`.\n\n## Finish\n\n`exitCode 0` plus an attachment = done. Reply with one line: the file name and\nthe printed count line. Do not call `exec` again for this request.\n\n## Errors\n\nThe script stops with a message that names the fix; correct the arguments and\nrerun the **same script** — never switch to writing Python.\n\n- `usage: replace_paragraphs.py …` — the pairs are incomplete: after the two\n file names, arguments alternate `index`, `text`.\n- `scriptArgs name \"existing_revised.docx\" but the working directory starts\n empty` — the call has no `outputs`; add `\"outputs\": [\"existing_revised.docx\"]`\n (the same name as in `scriptArgs`) and rerun the same script.\n- `script runs need the owning skill name in skill` — add `\"skill\": \"word\"`.\n- `index N is the heading '…'` — that paragraph is a heading, not a fact. Pick\n body indexes from the Step 1 list.\n- `index N is outside the document's M paragraphs` — re-read the Step 1 list;\n indexes run from 0 to M-1.\n- `index N already reads exactly that` — the new text equals the old one; write\n a different sentence.\n- `output … must be a new name` — the output name equals the input's; use\n `existing_revised.docx`.\n- `an id-less input stages an uploaded image` — the `inputs` entry has no\n `attachmentId`; add the document's id from Step 0 and rerun the same script.\n- `usage: list_paragraphs.py <input.docx>` — `scriptArgs` was left out; pass\n the staged path, `[\"existing.docx\"]`.\n- `PackageNotFoundError` / `attachment … not found` / `does not exist in the\n working directory` — `inputs` is missing or carries an invented id; stage the\n document by its real `attachmentId`.\n",
80
80
  "word/references/read.md": "# Reading a Word Document to Answer in Chat\n\nWhen the user asks what an attached `.docx` _says_ — a summary, a question\nanswered, specific content pulled out — the deliverable is your reply in the\nchat, not a file. This is a **read request**: exactly one `exec` call, staging\nthe document in `inputs` and declaring **no `outputs`**, whose whole job is to\nprint the document's text so you can read it in the result.\n\n## Staging the Document\n\nStage the document **by its `attachmentId`**. The id comes from wherever the\ndocument entered the chat:\n\n- **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.\n- **Uploaded by the user** — the `[Attached file …]` line on their message names\n it, when the message carries one:\n\n ```\n [Attached file \"report.docx\" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]\n ```\n\nCopy the id verbatim — never invent one, never stage a document id-less: an\nid-less input resolves to an uploaded _image_, so it can never reach a\ndocument. If no `attachmentId` for the document appears anywhere in the chat,\nsay you cannot open that file and ask the user to attach it again — do not\nretry. An attachment from an earlier turn can be used when its attachment id\nis available in the conversation.\n\n## The exec call\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<id from the [Attached file …] line>\", \"path\": \"existing.docx\" }],\n \"maxOutputChars\": 24000,\n \"command\": \"...\"\n}\n```\n\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. This exact version ships with\n the app and installs with no network.\n- `inputs` — the staged document lands in the working directory under the bare\n `path` name; open `Document(\"existing.docx\")` by that name only. The working\n directory starts empty on every call.\n- No `outputs` — a read builds nothing.\n- `maxOutputChars` — stdout cap in characters (default 8192, max 65536). Set\n it only on a read call, where the document text must fit in one result —\n keep the sample's 24000. A build call prints one line and never needs it.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`.\n\n## The Read Program\n\nThe read program prints every paragraph under its style name — the style names\nare the document's structure — then every table:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nfor p in doc.paragraphs:\n text = p.text.replace(\"\\n\", \" \").strip()\n if text:\n print(f\"[{p.style.name}] {text}\")\nfor i, t in enumerate(doc.tables):\n print(f\"[Table {i + 1}]\")\n for row in t.rows:\n print(\" | \".join(c.text.replace(\"\\n\", \" \") for c in row.cells))\n```\n\nThe `replace` calls are load-bearing: a multi-paragraph cell and a soft line\nbreak both embed `\"\\n\"` in `.text`, and an embedded newline would split one\ntable row — or one paragraph — across two printed lines. Flattened, every line\nstarts with its `[...]` marker and every table row is exactly one line.\n\nTables print after the body text — python-docx does not expose their position\nbetween paragraphs. When that order matters to the answer, say the tables are\nlisted separately. Note `p.style.name` in the sample: `paragraph.style` is a\nstyle object, not a string — go through `.name` to print or compare it.\n\n**You cannot summarize in the call that reads.** The words in `command` are\nfixed before the program runs, so one call cannot inform itself: any summary\nwritten into it was written blind — recalled or invented, not read. Python only\n_transports_ the text; the summarizing happens in your reply, after the result\ncomes back.\n\n## Answering\n\n**A successful read ends tool use.** When the result prints the document, reply\nwith the summary or the answer as chat text. **Scale the reply to the\ndocument**: a summary is much shorter than what it summarizes — a page or two\nof source earns three to five sentences, and only a long document earns\nsections. Restating the document near its full length is not a summary. Do\n**not**:\n\n- call `exec` again to \"re-check\", \"read more\", or read the same document a\n second time;\n- build a summary `.docx` the user never asked for — an unrequested file is a\n failed turn, not a bonus.\n\nIf stdout ends with `… [truncated]`, the document is longer than the cap:\nanswer from what came back and say the answer covers the document up to that\npoint. Do not rerun the read — it prints the same beginning again.\n\nIf the user asks for the summary **as a file**, that is a read followed by a\nbuild: the read call above first, then one build call that writes the new\ndocument from the text you actually read (load `references/create.md` for the\nbuild). The read still declares no `outputs`, delivers nothing, and does not\ncount against the one successful build `exec` per document request — but it\nbelongs before the build, never after it.\n\n## Errors\n\n- `PackageNotFoundError: Package not found at '…'` — the document was never\n staged, or an id-less entry staged an image under a `.docx` path. Add\n `inputs: [{ \"attachmentId\": \"<id from the exec result or the [Attached file …]\nline>\", \"path\": \"existing.docx\" }]` and open that exact path. If no id is\n available, ask the user to attach the file again rather than guessing a name.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). Only stage real ids\n from prior tool results or `[Attached file …]` lines; if none exists, ask the\n user to re-attach.\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Do not\n switch to `python -c` or change the package pin.\n- A read result ending in `… [truncated]` means the document outgrew the cap:\n answer from what came back and say the answer covers the document up to that\n point. Do not rerun the read — it prints the same beginning again.\n\n## Finish\n\nWhen the read result prints the document, stop tool use and answer the user in\nthe chat. Exactly one read `exec` per request; only a read call prints\ndocument text.\n",
81
- "word/references/replace.md": "# Replacing Paragraphs by Position (bundled scripts)\n\n\"Replace the first 10 facts\", \"reword point 3\", \"swap these bullets for those\",\n\"change paragraph 7\": two `exec` calls, both running a script bundled with this\nskill. **Write no Python.** Never put source in `command` for this job — pass\n`skill`, `script`, and `scriptArgs` exactly as shown, with `inputs` staging the\ndocument by its `attachmentId` (from the earlier `exec` result or the\n`[Attached file …]` line — copy it verbatim, never invent one).\n\n## Step 1 — list the paragraphs (no `outputs`)\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"skill\": \"word\",\n \"script\": \"scripts/list_paragraphs.py\",\n \"scriptArgs\": [\"existing.docx\"]\n}\n```\n\nIt prints one line per paragraph — `index`, style, text — then a count line.\nPick the indexes to replace from that list:\n\n- Only body paragraphs (`Normal`, `List Bullet`, `List Number`) are facts,\n points, or bullets. `Title`, `Heading N`, and an intro sentence are never\n counted as one.\n- \"The first 10 facts\" = the first 10 body-paragraph indexes after the heading\n that introduces them — not indexes 0–9.\n- Fewer facts in the document than asked for: replace the ones that exist and\n say so in the reply.\n\n## Step 2 — replace exactly those paragraphs (one `outputs` entry)\n\n`scriptArgs` is: input name, output name, then `index, new text` pairs — one\npair per replaced paragraph, as many pairs as facts requested. The output name\nkeeps the input's stem plus `_revised`.\n\nCount the pairs before sending: \"the first 10 facts\" is 10 pairs — 20 strings\nafter the two file names, 10 different sentences, the last index being\nstart + 9.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"outputs\": [\"existing_revised.docx\"],\n \"skill\": \"word\",\n \"script\": \"scripts/replace_paragraphs.py\",\n \"scriptArgs\": [\n \"existing.docx\", \"existing_revised.docx\",\n \"3\", \"Dogs have about 1,700 taste buds.\",\n \"4\", \"A dog's nose print is unique, like a fingerprint.\"\n ]\n}\n```\n\nEach new text is one complete plain sentence, no markdown, each different. The\nscript keeps each paragraph's paragraph style, refuses a heading index,\nrefuses text that already reads the same, and prints\n`K of N paragraphs replaced`.\n\n## Finish\n\n`exitCode 0` plus an attachment = done. Reply with one line: the file name and\nthe printed count line. Do not call `exec` again for this request.\n\n## Errors\n\nThe script stops with a message that names the fix; correct the arguments and\nrerun the **same script** — never switch to writing Python.\n\n- `usage: replace_paragraphs.py …` — the pairs are incomplete: after the two\n file names, arguments alternate `index`, `text`.\n- `index N is the heading '…'` — that paragraph is a heading, not a fact. Pick\n body indexes from the Step 1 list.\n- `index N is outside the document's M paragraphs` — re-read the Step 1 list;\n indexes run from 0 to M-1.\n- `index N already reads exactly that` — the new text equals the old one; write\n a different sentence.\n- `output … must be a new name` — the output name equals the input's; use\n `existing_revised.docx`.\n- `PackageNotFoundError` / `attachment … not found` — `inputs` is missing or\n carries an invented id; stage the document by its real `attachmentId`.\n",
82
- "word/scripts/list_paragraphs.py": "\"\"\"List every paragraph in a Word document, headings included.\n\nUsage: list_paragraphs.py <input.docx>\n\"\"\"\n\nimport sys\n\nfrom docx import Document\n\nif len(sys.argv) != 2:\n sys.exit(f\"usage: list_paragraphs.py <input.docx> — got {sys.argv[1:]}\")\n\nsource = sys.argv[1]\ndoc = Document(source)\n\nfor index, paragraph in enumerate(doc.paragraphs):\n text = paragraph.text.replace(\"\\n\", \" \")\n print(f\"{index}\\t{paragraph.style.name}\\t{text}\")\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n",
81
+ "word/references/rework.md": "# Reworking an Existing Word Document (python-docx)\n\nChange, replace, extend, trim, or rework a `.docx` that is already in this\nchat by running Python through the `exec` tool: stage it as an input, modify\nparagraphs and tables, and save a **new** output such as\n`existing_revised.docx`. Never overwrite the staged input.\n\n## Replacing Some Facts, Points, Bullets or Paragraphs: Two Script Calls\n\n\"Replace the first 10 facts\", \"change fact 3\", \"swap these bullets for those\",\n\"reword paragraph 7\" — any request that changes some of the paragraphs and\nkeeps the rest — is two `exec` calls that run scripts bundled with this skill.\n**Write no Python for it.** Nothing else in this file applies to that job: no\n`command`, no `Document(...)`, no fingerprint, no loop. `references/paragraphs.md`\nis this same recipe with its error table.\n\nThe document's `attachmentId` is already in the chat — in the `attachments` of\nthe `exec` result that produced it, or on the user's `[Attached file …]` line.\nCopy it into `inputs`; an entry with only a `path` stages an image, not a\ndocument.\n\nStep 1 — list the paragraphs (no `outputs`):\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"skill\": \"word\",\n \"script\": \"scripts/list_paragraphs.py\",\n \"scriptArgs\": [\"existing.docx\"]\n}\n```\n\nIt prints one line per paragraph — `index`, style, text — then a count line.\nOnly body paragraphs (`Normal`, `List Bullet`, `List Number`) are facts, points,\nor bullets; `Title`, `Heading N`, and an intro sentence are never counted as\none. \"The first 10 facts\" = the first 10 body-paragraph indexes after the\nheading or sentence that introduces them — not indexes 0–9.\n\nStep 2 — replace exactly those paragraphs (one `outputs` entry). `scriptArgs`\nis the input name, the output name, then one `index, new text` pair per\nreplaced paragraph — \"the first 10 facts\" is 10 pairs, each a different\ncomplete sentence:\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [{ \"attachmentId\": \"<real id>\", \"path\": \"existing.docx\" }],\n \"outputs\": [\"existing_revised.docx\"],\n \"skill\": \"word\",\n \"script\": \"scripts/replace_paragraphs.py\",\n \"scriptArgs\": [\n \"existing.docx\", \"existing_revised.docx\",\n \"3\", \"Dogs have about 1,700 taste buds.\",\n \"4\", \"A dog's nose print is unique, like a fingerprint.\"\n ]\n}\n```\n\nWrong, for this job — a `command` instead of a `script`:\n\n```json\n{ \"command\": \"from docx import Document\\ndoc = Document(\\\"existing.docx\\\")\\nfor i in range(1, 11): ...\" }\n```\n\n`exitCode 0` plus an attachment = done: reply with the file name and the\nprinted `K of N paragraphs replaced`, and do not call `exec` again. A script\nerror names the fix (a heading index, an index out of range, unchanged text,\nmissing pairs, a missing `outputs` for the revised name); correct the\narguments and rerun the **same script**.\n\nEverything below is for the other edits: extending a document, trimming it,\nrewriting a whole section, resizing its text, embedding an image.\n\n## Staging the Document\n\nStage the document as an input **by its `attachmentId`** and open it with\n`Document(\"existing.docx\")`. The id comes from wherever the document entered\nthe chat:\n\n- **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.\n- **Uploaded by the user** — the `[Attached file …]` line on their message names\n it, when the message carries one:\n\n ```\n [Attached file \"report.docx\" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]\n ```\n\nCopy the id verbatim — never placeholders like `att_doc`, `att_image`, or any\nid you made up. A `.docx` is **never** staged id-less: an id-less input\nresolves to an uploaded _image_, so it can never reach a document. If no\n`attachmentId` for the document appears anywhere in the chat, say you cannot\nopen that file for editing and ask the user to attach it again — do not invent\nan id, do not stage it id-less, and do not retry. An attachment from an\nearlier turn can be used when its attachment id is available in the\nconversation.\n\n**The file only exists if this same `exec` call stages it.** The working\ndirectory starts empty on every call, so an edit needs an `inputs` entry\nnaming the attachment, and `Document(\"existing.docx\")` must use that entry's\nexact `path`. Opening a name that was never staged raises\n`PackageNotFoundError: Package not found at '…'` — the fix is the missing\n`inputs`, never a different file name.\n\n## The exec call\n\nImages can be staged alongside the document. Tool-produced images\n(`generate_image` output) take the exact `attachmentId` from the tool result;\nan image the user uploaded is staged with `path` only and **no\n`attachmentId` key** — the first id-less entry is the first image of the\nuser's latest message, and so on. Id-less entries resolve _images only_.\n\n```json\n{\n \"language\": \"python\",\n \"packages\": [\"python-docx==1.2.0\"],\n \"inputs\": [\n {\n \"attachmentId\": \"<id from the exec result or the [Attached file …] line>\",\n \"path\": \"existing.docx\"\n },\n { \"path\": \"photo.png\" }\n ],\n \"outputs\": [\"existing_revised.docx\"],\n \"command\": \"...\"\n}\n```\n\n- `packages` — `[\"python-docx==1.2.0\"]` on every call. The PyPI package is\n `python-docx` but the import is `docx`; never list `docx` as the package —\n that resolves a different, abandoned library. Pin the version; this exact\n version ships with the app and installs with no network; any other version\n has to be downloaded, which fails on a device that is offline.\n- `inputs` — staged files land in the working directory under the bare `path`\n names — reference `Document(\"existing.docx\")` /\n `doc.add_picture(\"photo.png\", …)` by that name only. Paths must be unique\n bare filenames.\n- `outputs` — the new file to deliver; a file you write but do not declare\n here is discarded. Never the staged input's name. **Name it after the\n document you edited**, not after the change: keep the input's stem and add a\n marker — `report.docx` edited is `report_revised.docx`. A fresh name picked\n from the new content (`cats.docx` for an edit of `parrot_facts.docx`) reads\n as a second, unrelated document and hides the fact that an edit happened at\n all.\n- `command` — the multi-line Python source, with real newline characters.\n Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a\n semicolon is a `SyntaxError`. There is no shell and no installer — packages\n are declared in `packages`.\n\nIf the image was staged in `inputs`, embed it in **that** single build with\n`doc.add_picture(\"photo.png\", width=Inches(5.5))` — never deliver a document\nand then rebuild to add the image. **Do not soft-fail images or imports**:\nnever wrap `add_picture` or an import in `try`/`except` that prints a warning\nand continues — a document saved without the requested image is a failed\nturn, not a success. Pass only one of `width`/`height`; passing both distorts\nthe picture.\n\n**Image URLs do not work — never download.** Your Python code has **no\nnetwork access**: `requests`, `urllib`, and `socket` all fail with a network\nerror, and `http_request` returns truncated text, never image bytes. Say the\nlink cannot be downloaded and ask the user to attach the image itself, or\noffer `generate_image` for a similar visual.\n\n## Editing: Work the Objects, Save a New Name\n\nOne call does the whole edit: open, change, verify the document actually\nchanged, save. Keep the fingerprint lines exactly as written — they are what\nstops an edit that silently matched nothing (or a read that only inspected)\nfrom delivering an unchanged copy of the user's document at `exitCode 0`. The\nfingerprint covers the body **and** the styles part, so a style-only change —\nthe resize recipe below — counts as a change too:\n\n```python\nimport hashlib\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nfingerprint = hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest()\n\nfor paragraph in doc.paragraphs:\n if paragraph.text == \"Prepared by the finance team.\":\n paragraph.text = \"Prepared by the finance team. Revised after board review.\"\n\ntable = doc.tables[0]\nrow = table.add_row().cells\nrow[0].text = \"Margin\"\nrow[1].text = \"25%\"\nrow[2].text = \"36%\"\n\ndoc.add_heading(\"Appendix\", level=1)\ndoc.add_paragraph(\"Margins recovered as one-off costs rolled out of the base.\")\n\nassert (\n hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest() != fingerprint\n), \"nothing changed — the edit matched nothing or never ran; fix it, never deliver an unchanged copy\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n```\n\n**An assert that fires is a failed turn to diagnose, not a document to\ndeliver**: the usual cause is a paragraph match on text that is not exactly\nthere — print the real `.text` values in the rerun, fix the match, and never\ndelete the assert to get a file out.\n\n**Keep it an `assert`, never a `print` or an `if`.** Two `print` lines showing\nthe old and new hashes let a no-op save and deliver anyway, which is the one\nthing the assert exists to stop. They also invite a second miscoding: taking\nboth hashes together, before the change. Then they match whatever the edit did,\nand the run reports \"nothing changed\" over a document that changed correctly.\nTake the second hash after the last mutation and before `save`, and let the\nassert raise.\n\n**A printed line is never a reason to call `exec` again.** The guard is the\nassert: if it did not fire and the result carries an attachment, the document is\ndelivered and the turn is over, whatever stdout says about it. Re-running to\ncheck saves the same edit under a second name, and the user gets two documents\nfor one request.\n\n`doc.paragraphs` walks only the document body — text inside tables, headers, and\nfooters is **not** in it. Table text is reached through `doc.tables`; match\nparagraphs by their exact `.text` before rewriting them, and remember the\nformatting-loss rule below.\n\nThe `add_heading`/`add_paragraph` pair above appends an **Appendix** because\nthat is what the sample edit asks for. Copy that shape only when the user\ngenuinely wants new content at the end. Substituting content that is already\nin the document is a different job with its own guards: some of the points —\n\"change the first five points\" — is the two script calls at the top of this\nfile; a whole section — \"rewrite section 2\" — is Rewriting Whole Sections.\n\nWhen an edit adds substantial new content — new sections, formatted runs,\nbulleted lists, whole tables — the writing rules apply unchanged: load\n`references/create.md` too and copy its shapes (no markdown characters in\nstrings, bold/italic on runs never paragraphs, one string per `add_paragraph`,\ntables built from a header row with `rows=1`).\n\n### Setting `paragraph.text` erases formatting\n\nAssigning `paragraph.text = \"…\"` replaces **all** runs with one plain run: every\nbold, italic, size, and color in that paragraph is gone. Fine for plain\nparagraphs; on a formatted paragraph edit the runs instead, or accept the loss\ndeliberately. This is the top footgun when editing an uploaded document.\n\n### Styles must exist in the document\n\n`style=\"List Bullet\"` names a style **inside the document**. A missing name\nraises `KeyError: \"no style with name 'List Bullet'\"` at `add_paragraph` time.\nAn **uploaded** document carries only its own styles — one written by another\ntool may lack even `List Bullet`. When editing, guard once and fall back:\n\n```python\nnames = [s.name for s in doc.styles]\nbullet = \"List Bullet\" if \"List Bullet\" in names else None\ndoc.add_paragraph(\"point one\", style=bullet) # style=None → Normal\n```\n\n## Rewriting Whole Sections\n\nThis recipe is for whole _sections_ (a heading plus its body). Changing some\nof the facts, points, bullets, or paragraphs is the two script calls at the\ntop of this file — never hand-write a loop for that.\n\n`add_paragraph`, `add_heading`, and `add_picture` **always append at the end of\nthe document.** None of them takes a position. \"Change the first five points\",\n\"rewrite section 2\", \"swap these facts for those\" are all *replacements*, and\nreaching for `add_*` silently turns them into an append: the original content\nstays where it is, the new content lands after the closing line, and the\ndocument comes back longer than it started with both versions in it. That is a\nfailed turn, not a partial success — it is the most common way this skill goes\nwrong.\n\n**A section is a heading plus everything under it, up to the next heading of\nthe same or higher rank** — which may be one paragraph, or six bullets, or a\nwhole subsection, or nothing at all. Never assume it is exactly one paragraph:\nrewriting the heading and the single paragraph after it leaves the rest of the\nold section sitting under its new title, which is the same contradiction an\nappend produces and is just as invisible in the result. Work out where each\nsection ends before changing anything.\n\nRank matters as much as position. `Heading 2` under a `Heading 1` is a\nsubsection, not the next section, so \"replace the first two sections\" on a\ndocument with subheadings must not consume the parent's own subheading as\nsection two — the same rule the removal recipe below follows. `rank()` reads\nthe level off the style name, and only the shallowest rank counts as a section\nstart.\n\n**A heading shallower than every other heading is the document's title, not its\nfirst section.** A document headed `Heading 1` and sectioned `Heading 2` — the\nshape most attached documents have — would otherwise have exactly one\n\"section\": the title, spanning everything under it. Replacing that section\nreplaces the entire document, and nothing about the result says so. The `if`\ndrops such a heading before sections are picked, and the whole-document assert\nrefuses the span even if one is somehow selected.\n\n**One heading is dropped, never a chain of them.** It is an `if`, not a\n`while`: a document is titled once. Stripping repeatedly walks down the\noutline — on a `Heading 1` title over a `Heading 2` phase holding `Heading 3`\nweeks it drops the title, then the phase, and the weeks become the \"sections\",\nso replacing the first two rewrites the weeks and leaves the phase untouched.\nThat is the silent wrong target this section exists to prevent. Stopping after\none leaves the phase as the only section, and asking for a second raises an\nerror that says so.\n\nTake one snapshot of `doc.paragraphs` and index into it. **Every string in\n`NEW` is a placeholder** — the sample fills it with report sections so the\nshape is readable, and you replace all of it with the content this request asks\nfor. Shipping a sample string in the user's document is a failed turn:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\nparas = doc.paragraphs # one snapshot — index into THIS list\nblocks = list(doc.element.body) # paragraphs AND tables, in document order\n\nNEW = [ # placeholders — you write every string here\n (\"Regional Performance\", \"Revenue grew in every region except EMEA, where the quarter closed flat.\"),\n (\"Cost Base\", \"Headcount costs fell as the contractor pool wound down, and the saving held.\"),\n]\nTARGET = range(len(NEW)) # which sections to replace — here the first len(NEW)\n\ndef rank(paragraph): # \"Heading 2\" -> 2; a bare \"Heading\" is rank 1\n tail = paragraph.style.name.split()[-1]\n return int(tail) if tail.isdigit() else 1\n\nheads = [i for i, p in enumerate(paras) if p.style.name.startswith(\"Heading\")]\nif len(heads) > 1 and all(rank(paras[heads[0]]) < rank(paras[i]) for i in heads[1:]):\n heads = heads[1:] # a lone heading above all the rest is the title\ntop = min((rank(paras[i]) for i in heads), default=1)\nstarts = [i for i in heads if rank(paras[i]) == top] # sections, never their subsections\nends = [next((j for j in heads if j > i and rank(paras[j]) <= top), len(paras)) for i in starts]\n\nassert NEW, \"NEW is empty — write the replacement content before running the edit\"\nassert len(TARGET) == len(NEW), f\"TARGET names {len(TARGET)} sections but NEW has {len(NEW)} items\"\nassert len(starts) > max(TARGET), f\"TARGET reaches section {max(TARGET) + 1}, but the document has {len(starts)}\"\nat = [blocks.index(p._element) for p in paras] # where each paragraph sits among the blocks\nfor k in TARGET:\n assert ends[k] > starts[k] + 1, f\"section {paras[starts[k]].text!r} has no body paragraph to replace\"\n assert (starts[k], ends[k]) != (heads[0], len(paras)), f\"section {paras[starts[k]].text!r} spans the whole document — that is a rewrite, not a section replacement\"\n span = blocks[at[starts[k]] : at[ends[k]] if ends[k] < len(paras) else len(blocks)]\n assert not any(el.tag.endswith(\"}tbl\") for el in span), f\"section {paras[starts[k]].text!r} holds a table — this recipe replaces paragraphs only\"\n\n# measured from the document, before anything changes — never from what the loop below does\nbefore = len(paras)\nold_body = [p.text for k in TARGET for p in paras[starts[k] + 1 : ends[k]]]\ndoomed = [(p.text, p._element) for k in TARGET for p in paras[starts[k] + 2 : ends[k]]]\nexpected = before - len(old_body) + len(NEW) # each replaced section keeps exactly one body paragraph\n\nfor k, (title, body) in zip(TARGET, NEW):\n paras[starts[k]].text = title # the heading keeps its own style\n paras[starts[k] + 1].text = body\n paras[starts[k] + 1].style = doc.styles[\"Normal\"] # the reused paragraph may have been a bullet\n for p in paras[starts[k] + 2 : ends[k]]: # whatever else the section held\n p._element.getparent().remove(p._element)\n\nassert len(doc.paragraphs) == expected, f\"expected {expected} paragraphs, got {len(doc.paragraphs)} — an old section was not fully replaced, or content was appended\"\nfor text, el in doomed:\n assert el.getparent() is None, f\"an old paragraph is still in the document: {text[:40]!r}\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{len(NEW)} of {len(starts)} sections replaced, {before} -> {len(doc.paragraphs)} paragraphs\")\n```\n\n`TARGET` names the sections to replace, once, and `zip` pairs each new item with\nthe section it overwrites. Replacing a different range is a change to that one\nline — `TARGET = range(2, 5)` for \"sections 3 through 5\", with three items in\n`NEW` to match. Keep it bound in a single place: a range written twice drifts\napart the moment one copy is edited, and every guard below reads `TARGET`\nrather than assuming the range starts at zero.\n\nAssigning `paras[head].text` keeps that paragraph's style, because the style\nlives on the paragraph and not on its runs: a `Heading 2` stays a `Heading 2`.\nOnly the run-level formatting inside it is lost, per the rule above. The body\nparagraph is the opposite case — it is reused, so it arrives carrying whatever\nstyle the old body had, which is why the sample sets it back to `Normal`. Set it\nto something else when the new body should be a bullet or a quote, and guard the\nname as shown under Styles.\n\n**Keep the document's own numbering.** The sample titles carry no `1.`, `2.`\nprefix because the document it edits does not number itself, and a typed prefix\non a `List Number` paragraph double-numbers. When the headings you are\noverwriting *do* carry manual numbers, take each number from the position being\noverwritten so the sequence continues — replacing sections 3 through 5 writes\n`3.`, `4.`, `5.`, never restarting at `1.`\n\n**Every assert, exactly as written — and measured before the loop runs.**\n`old_body` and `expected` come from the document's own structure, never from\nwhat the loop reports about itself. That is the whole point: a loop that\nrewrites only the paragraph after each heading, the mistake this recipe exists\nto prevent, would tally its own work as complete. Derived up front, the numbers\ncontradict it. None of these failures is distinguishable from success by\n`exitCode 0` plus an attachment:\n\n- **`assert NEW`** catches an empty content list. Without it `max(TARGET)`\n raises a bare `ValueError`, and were it not for that the run would save an\n untouched copy of the user's document at `exitCode 0`.\n- **`len(TARGET) == len(NEW)`** catches a target range and a content list that\n drifted apart. `zip` would silently pair only the shorter of the two.\n- **`len(starts) > max(TARGET)`** catches a range reaching past the last\n section. It reads `TARGET`, not `len(NEW)`, because the range need not start\n at zero — a `len(NEW)` check passes on `range(2, 5)` over four sections and\n the run then dies on an `IndexError` that names nothing.\n- **the whole-document assert** refuses a section running from the first\n heading to the last paragraph. That is not a replacement, it is a rewrite:\n every other check passes while the document is emptied down to one heading\n and one paragraph. It anchors on `heads[0]`, not paragraph 0 — a document\n whose only heading sits under a draft notice, a date, or a byline still has\n exactly one section, and anchoring on index 0 would wave it through.\n- **`ends[k] > starts[k] + 1`** catches a section with no body paragraph — a\n heading followed straight by a table, or the last heading in the document.\n There is nothing under it to rewrite. It runs before any mutation, so a bad\n target changes nothing.\n- **the `}tbl` assert** catches a table inside a section being replaced.\n `doc.paragraphs` does not see tables, so the loop below cannot remove one:\n without this the old table survives under the new heading with every other\n check passing. Say the table has to be rebuilt, or target a different section.\n- **the `expected` assert** catches an old section left partly in place *and*\n new content appended, because `expected` is what the paragraph count must be\n once each replaced section holds exactly one body paragraph.\n- **the `getparent() is None` assert** catches an old paragraph the loop was\n supposed to drop but left attached. Compare **elements, not text**: text\n comparison cannot tell a paragraph that survived from an identical one\n standing legitimately elsewhere, and a document that repeats a line — three\n status sections each reading `Nothing to report.` — would fail a correct edit\n with no way to satisfy the assert. Identity has no such collision, and it\n needs no special case for blank paragraphs.\n\nAn assert that fires is a failed turn to diagnose, never a document to deliver.\n\n### When the replacement needs more than one paragraph\n\nThe loop above reuses one paragraph per section and drops the rest. When a\nreplacement needs an **extra** paragraph, insert it before the paragraph that\nshould follow it. `insert_paragraph_before` is the only insert there is, and it\nis a method on the paragraph you want to push down:\n\n```python\nanchor = paras[ends[k]] # the next section's heading\nextra = anchor.insert_paragraph_before(\"A second body paragraph.\", style=\"Normal\")\n```\n\nIt takes the same style names as `add_paragraph` (`\"Heading 2\"`, `\"List\nBullet\"`, `None` for Normal) and returns the new paragraph, so runs can be\nformatted on it. Inserting a whole new section is this call once per paragraph,\neach against the heading it goes above. A section at the very end of the\ndocument has no next heading to anchor to — `ends[k]` is `len(paras)` — so\nappend there with `doc.add_paragraph`, the one case where appending is right.\n\nInserting does not disturb the `paras` snapshot: it is a plain Python list\nholding the paragraphs that already existed, so every index taken before the\ninsert still points at the same paragraph afterwards. Only a fresh\n`doc.paragraphs` shifts.\n\nCount what you insert and fold it into `expected` rather than dropping the\nguard — `expected = before - len(old_body) + len(NEW) + added` — so an\naccidental append is still caught.\n\n## Removing Content\n\npython-docx has **no delete API.** There is no `doc.remove_paragraph` and no\n`paragraph.delete`, and `doc.paragraphs` is rebuilt on every access, so\n`doc.paragraphs.remove(p)` edits a throwaway list and changes nothing in the file.\nRemoving anything means dropping its XML element from the parent — this one line\nis the whole technique, and there is no alternative to it:\n\n```python\np._element.getparent().remove(p._element)\n```\n\nCode that finds the paragraphs and never runs that line — a `for`/`if` that\nmatches the text and falls through, or a comment like\n`# Find and remove paragraphs containing \"Conclusion\"` standing in for the\nremoval — saves a document byte-identical to the input at `exitCode 0`, with an\nattachment that looks like a success. Nothing in the result says the edit was a\nno-op, which is why the sample below asserts the count changed before it saves.\n\nBecause `doc.paragraphs` is a fresh list each time, `for p in doc.paragraphs:`\nwalks a snapshot and removing inside the loop is safe.\n\n**A whole section** — a heading plus everything under it, up to the next heading\nof the same or higher rank — is that line plus a flag. Track the heading's level,\nor a sub-heading inside the section ends the removal early and orphans the\nparagraphs below it:\n\n```python\nfrom docx import Document\n\ndoc = Document(\"existing.docx\")\n\nTARGET = \"Conclusion\" # the heading text that opens the section\n\nbefore = len(doc.paragraphs)\ndepth = None # the target heading's level while removing\nfor p in doc.paragraphs:\n style = p.style.name # a style object — compare through .name\n if style.startswith(\"Heading\"):\n tail = style.split()[-1]\n level = int(tail) if tail.isdigit() else 1 # \"Heading 2\" -> 2\n if depth is not None and level <= depth:\n depth = None # a sibling heading closes the section\n if p.text.strip() == TARGET:\n depth = level\n if depth is not None:\n p._element.getparent().remove(p._element)\n\nafter = len(doc.paragraphs)\nassert after < before, f\"removed nothing ({before} -> {after}) — the match never fired\"\ndoc.save(\"existing_revised.docx\") # a NEW name — never the staged input\nprint(f\"{before} -> {after} paragraphs\")\n```\n\nFind the heading through `p.style.name`, never the text alone — a body paragraph\nthat mentions \"Conclusion\" is not the section heading. Removing individual\nparagraphs is the same loop without the flag: match them, and call the removal\nline on each one.\n\n**Assert the count changed, before you save.** Keep the\n`assert after < before` line exactly where the sample puts it — between the loop\nand `doc.save(...)` — and do not soften it to a `print` or an `if`. It is what\nmakes a no-op impossible to deliver: the assert raises, `save` never runs, so no\nfile is written and the result comes back with `missingOutputs` instead of an\nattachment. Without it a removal that never fired still saves the unchanged\ndocument, and the run is indistinguishable from a real edit — `exitCode 0`, an\nattachment, and nothing anywhere saying the document is a copy of the input.\n\nAn assert that fires is a **failed turn to diagnose**, never a result to report.\nIt means the match did not fire: wrong heading text, a heading style the document\ndoes not use, or text living in a table, header, or footer, which\n`doc.paragraphs` never walks. Fix the match and rerun — do not delete the assert\nto get a file out.\n\nThe printed `before -> after` line is then just the reply line (`16 -> 12\nparagraphs`), not the check. Both live inside the build, so this takes no extra\ncall: the assert and the `print` are in the same `exec` that does the removal.\n\nThis assert is also why \"Success = stop\" needs no second call on a destructive\nedit: `exitCode: 0` plus an attachment cannot on its own tell a real edit from\na copy of the input, because a removal that never fired produces both. The\nbuild itself closes that gap — it asserts the count changed before `save`, so\na no-op returns `missingOutputs` rather than a convincing attachment. A\ndelivered document is still never reopened to \"verify\" it; the fix for a\nfailed assert is a corrected build, never an `exec` opened to inspect what was\nalready delivered.\n\n**Table rows** have no delete API either, and take the same idiom on the row's own\nelement. `table.rows` iterates a snapshot just as `doc.paragraphs` does, so\nremoving inside the loop is safe — and the count gets the same assert, because a\nrow matched by its cell text can miss exactly the way a paragraph can:\n\n```python\ntable = doc.tables[0]\n\nbefore = len(table.rows)\nfor row in table.rows:\n if row.cells[0].text == \"Discontinued\":\n row._element.getparent().remove(row._element)\nassert len(table.rows) < before, f\"no row matched ({before} rows unchanged)\"\n```\n\nRemoving a row by position needs no assert — `table.rows[9]` on a 4-row table\nraises `IndexError` rather than quietly doing nothing:\n\n```python\nrow = table.rows[2]\nrow._element.getparent().remove(row._element)\n```\n\nRows only grow at the bottom: there is no insert-at, and no delete either\noutside this idiom. Address cells as `table.cell(row, col)` or\n`table.rows[r].cells[c]` — they are the same cell; for formatting inside a\ncell go through `cell.paragraphs[0]` and its runs like any other paragraph.\n\n**Table columns cannot be removed.** A column is not one element — it is an entry\nin the table grid plus one cell in every row — and a horizontally merged cell is a\nsingle `<w:tc>` shared across two grid positions, so removing \"the second cell\" of\nevery row deletes that merged cell whole and leaves its row a column short. The\ndocument opens visibly ragged and nothing raises. Rebuild the table with the\ncolumns you want instead, or say the column has to be dropped in Word.\n\n## Reading What Is Already in the Document\n\nA document exposes exactly two collections — `doc.paragraphs` and `doc.tables`.\nEverything else is derived by filtering them; there is no `doc.headings`, no\n`doc.sections_by_title`, no `doc.text`. A `Paragraph` has `.text`, `.style` and\n`.runs`, and no `.paragraphs` of its own.\n\n**`paragraph.style` is a style object, not a string** — compare through\n`.name`, or you get\n`AttributeError: 'ParagraphStyle' object has no attribute 'startswith'`:\n\n```python\nheadings = [p for p in doc.paragraphs if p.style.name.startswith(\"Heading\")]\nbody = [p for p in doc.paragraphs if p.style.name == \"Normal\"]\n```\n\nMost edits need no inspection at all — go straight to the change. When a look\nis genuinely needed first (an exact `.text` to match, a style name), that call\nonly prints: **an inspection never saves and declares no `outputs`** — a save\nwithout the change delivers a stale copy of the user's document. The new file\ncomes only from the one call that changes it. Reading for the _user_ — a\nsummary or an answer delivered as chat text — is its own flow with its own\ncall shape: load `references/read.md`.\n\n## Resizing Text: Set the Styles, Never Scale `run.font.size`\n\n`run.font.size` is `None` whenever the size comes from the paragraph's style,\nwhich is the normal case for a document you did not hand-size. **`None` does not\nmean zero.** Reading it as a number and scaling it writes a 0pt font, and 0pt\ntext is invisible in Word and Pages — the document opens looking blank, with no\nerror anywhere to tell you why:\n\n```python\nsize = run.font.size.pt if run.font.size else 0\nrun.font.size = Pt(size * 1.5) # WRONG: 0 * 1.5 = 0pt, invisible text\n```\n\n\"Make the font bigger\" is a change to the **styles**, because every run without\nits own size inherits from them. Set absolute point sizes on the styles the\ndocument actually uses, and the whole document — body, tables, headers — follows\nin four lines:\n\n```python\nfrom docx import Document\nfrom docx.shared import Pt\n\ndoc = Document(\"existing.docx\")\n\ndoc.styles[\"Normal\"].font.size = Pt(14) # body text; 11pt is the default\ndoc.styles[\"List Bullet\"].font.size = Pt(14)\ndoc.styles[\"Heading 1\"].font.size = Pt(20)\ndoc.styles[\"Title\"].font.size = Pt(32)\n\ndoc.save(\"larger.docx\")\nprint(f\"{len(doc.paragraphs)} paragraphs resized\")\n```\n\n**Keep the hierarchy.** Raise every style you touch, not one size for all of\nthem — a title and a heading set to the body size read as unstyled text. Body\naround 14pt pairs with roughly 20pt headings and a 32pt title, and the same\nratios hold at any size the user asks for.\n\nOnly touch a style the document has — guard with the `doc.styles` check above\nwhen unsure. If a specific run really must be sized on its own, assign an\nabsolute `Pt(...)` value; never one derived from the size you read back.\n\n## Errors\n\n- `PackageNotFoundError: Package not found at '…'` — the document was never\n staged, or an id-less entry staged an image under a `.docx` path. Add\n `inputs: [{ \"attachmentId\": \"<id from the exec result or the [Attached file …]\nline>\", \"path\": \"existing.docx\" }]` and open that exact path. If no id is\n available, ask the user to attach the file again rather than guessing a name.\n- `attachment … not found in this chat` means `inputs` listed an id that is not\n in this chat (often a copied placeholder like `att_doc`). Re-copy the exact id\n from the `exec` result or the `[Attached file …]` line that names the file;\n if no id appears anywhere in the chat, ask the user to re-attach.\n- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or\n wrong — add `[\"python-docx==1.2.0\"]` and rerun. Never try to install it, and\n never \"fix\" it by importing `python_docx`; the import stays `docx`.\n- `KeyError: \"no style with name '…'\"` — the style is not in this document.\n Check `doc.styles` and fall back as shown under Styles.\n- `NameError: name 'Pt' is not defined` (or `Inches`, `RGBColor`) — the import\n line is missing that name; they all import from `docx.shared`.\n- `AssertionError: removed nothing (16 -> 16)` means the removal matched nothing:\n either the loop never fired or it never called\n `p._element.getparent().remove(p._element)`; python-docx has no delete method to\n reach for instead. Diagnose the match and rerun — never delete the assert to get\n a file out, since the file it would produce is a copy of the input.\n- `AssertionError: expected 7 paragraphs, got 9` means the document did not end\n up the shape a replacement makes. Two causes: the old sections were only\n partly replaced — the loop rewrote the paragraph after each heading and left\n the rest of the section standing — or new content was appended with\n `add_paragraph`/`add_heading`, which only ever append. `expected` is derived\n from the section bounds before anything changes, so it is right and the\n document is wrong: replace each section through to the next heading.\n- `AssertionError: an old paragraph is still in the document: '…'` means the\n loop was adapted and no longer drops everything past the paragraph it reuses.\n Every paragraph from `starts[k] + 2` to `ends[k]` has to go; the removal idiom\n below is the only thing that removes one. This compares elements, so it never\n fires because the document happens to repeat a line elsewhere.\n- `AssertionError: section '…' has no body paragraph to replace` means that\n heading is followed straight by a table, or is the last paragraph in the\n document. There is nothing under it to rewrite: target a different section, or\n insert the body with `insert_paragraph_before` before adding to it.\n- `IndexError: list index out of range` while walking sections means an index\n ran past the end of `starts` or of `paras`: a `TARGET` reaching past the last\n section, or `paras[i + 1]` on a document whose final paragraph is a heading.\n Guard the range with `len(starts) > max(TARGET)` — not against `len(NEW)`,\n which says nothing when the range does not start at zero — and take section\n ends from the next heading of the same or higher rank, with `len(paras)`\n closing the last one.\n- `AssertionError: section '…' holds a table` means the section being replaced\n contains a table. `doc.paragraphs` never sees tables, so the loop cannot\n remove one and it would survive under the new heading. Rebuild the table\n explicitly, or tell the user that section has to be replaced by hand.\n- `AssertionError: TARGET names 2 sections but NEW has 3 items` means the range\n and the content list drifted apart. Fix whichever is wrong; do not let `zip`\n quietly use the shorter.\n- `AssertionError: TARGET reaches section 2, but the document has 1` on a\n document that plainly has several usually means its sections are `Heading 2`\n under a `Heading 1` title. The title is dropped before sections are picked,\n so check `rank()` is reading the style names this document actually uses —\n print `[p.style.name for p in doc.paragraphs]` — rather than lowering\n `TARGET` until the assert passes. Section 1 of a title-only document is the\n whole document.\n- `AssertionError: section '…' spans the whole document` means the heading\n selected covers every paragraph, so replacing it would empty the document.\n It is a title being treated as a section, or a request to rewrite rather than\n edit — build a new document with `references/create.md` if that is what the\n user wants.\n- `AttributeError: 'Document' object has no attribute 'insert_paragraph'` means\n the code guessed an insert API on the document. There is none. The only insert\n is `paragraph.insert_paragraph_before(text, style)`, on the paragraph the new\n one goes above.\n- `TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments\n but 4 were given` means a position was passed to `add_paragraph`. It has no\n position parameter and always appends; use `insert_paragraph_before`.\n- Identical old and new fingerprints on a run that saved anyway means the\n assert was softened into `print` lines and both hashes were taken before the\n change. It is not evidence the edit failed, and it is not grounds for another\n `exec`: restore the assert and take the second hash after the mutation.\n- A delivered document identical to the one you opened means an edit ran\n without the fingerprint assert — an edit that matched nothing, or an\n inspection that saved. Add the assert before `save` and rerun the actual\n change.\n- `AssertionError: nothing changed — the edit matched nothing or never ran`\n means exactly that: the paragraph match found no text, or no mutation\n happened before `save`. Print the real `.text` values, fix the match, rerun\n — never remove the assert.\n- `TypeError: 'Table' object is not subscriptable` — a table was indexed\n directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or\n `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.\n- `AttributeError: 'Document' object has no attribute 'remove_paragraph'` (or\n `'Paragraph' object has no attribute 'delete'`) means the code guessed a delete\n API. There is none; drop the XML element instead.\n- A resize that \"worked\" but left the document blank means a 0pt font: something\n scaled `run.font.size` while it was `None`. Set absolute sizes on the styles\n instead — see Resizing Text.\n- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was\n collapsed — restore multi-line newlines from the sample and rerun. Underscores\n in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to\n `python -c` or change the package pin.\n- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`\n about positional arguments the call passes the wrong number of them — usually\n several strings where one is allowed. Fix either against this file's examples,\n reading the line number in the traceback. Do not retry the same call, and do\n not switch to a shell.\n- If the result has `missingOutputs`, the file was never written. Read stderr\n first: an `AssertionError` there means a guard stopped the save on purpose\n and its message names what to fix — rerunning the same code fails the same\n way. Only when stderr is clean is this a naming problem: check the `save()`\n name matches the declared output and rerun once.\n- Never print the document's bytes or base64 — stdout is capped and the file\n travels through `outputs`. A build call prints exactly one line (e.g. `9\nparagraphs, 1 table(s)`).\n- Never pass an absolute path to `save()`.\n\n## Finish\n\nWhen `exitCode` is `0` and `attachments` lists the `.docx`, the edit is done —\nthe `exec` result carries\n`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already\nattached to the chat for the user to open or save. Stop tool use and reply\nwith a single line: file name + the count line from stdout. Exactly one\nsuccessful `exec` per request; never reopen a delivered document to \"verify\"\nit.\n",
82
+ "word/scripts/list_paragraphs.py": "\"\"\"List every paragraph in a Word document, headings included.\n\nUsage: list_paragraphs.py <input.docx>\n\"\"\"\n\nimport glob\nimport sys\n\nfrom docx import Document\n\n\n# A call that stages the document but forgets scriptArgs still means that document.\nstaged = glob.glob(\"*.docx\")\nif len(sys.argv) == 1 and len(staged) == 1:\n sys.argv.append(staged[0])\nif len(sys.argv) != 2:\n sys.exit(f\"usage: list_paragraphs.py <input.docx> — got {sys.argv[1:]}\")\n\nsource = sys.argv[1]\ndoc = Document(source)\n\nfor index, paragraph in enumerate(doc.paragraphs):\n text = paragraph.text.replace(\"\\n\", \" \")\n print(f\"{index}\\t{paragraph.style.name}\\t{text}\")\nprint(f\"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)\")\n",
83
83
  "word/scripts/replace_paragraphs.py": "\"\"\"Replace selected body paragraphs in a Word document.\n\nUsage: replace_paragraphs.py <input.docx> <output.docx> <index> <new text> [<index> <new text> ...]\n\"\"\"\n\nimport sys\n\nfrom docx import Document\n\nusage = \"usage: replace_paragraphs.py <input.docx> <output.docx> <index> <new text> [<index> <new text> ...]\"\nif len(sys.argv) < 5 or (len(sys.argv) - 3) % 2 != 0:\n sys.exit(f\"{usage} — got {sys.argv[1:]}\")\n\nsource, target = sys.argv[1], sys.argv[2]\nif source == target:\n sys.exit(f\"output {target} must be a new name, not the staged input\")\n\ndoc = Document(source)\nparagraphs = doc.paragraphs\nreplacements = []\nseen = set()\n\nfor position in range(3, len(sys.argv), 2):\n index_raw, new_text = sys.argv[position], sys.argv[position + 1]\n try:\n index = int(index_raw)\n except ValueError:\n sys.exit(f\"index must be an integer, got: {index_raw}\")\n if index < 0 or index >= len(paragraphs):\n sys.exit(f\"index {index} is outside the document's {len(paragraphs)} paragraphs\")\n if index in seen:\n sys.exit(f\"index {index} was provided more than once\")\n if not new_text:\n sys.exit(f\"new text for index {index} must not be empty\")\n\n paragraph = paragraphs[index]\n style_name = paragraph.style.name\n if style_name.startswith(\"Heading\") or style_name == \"Title\":\n sys.exit(\n f\"index {index} is the heading {paragraph.text!r} — headings are not facts/points; \"\n \"pick body paragraph indexes from list_paragraphs.py\"\n )\n if new_text == paragraph.text:\n sys.exit(f\"index {index} already reads exactly that\")\n\n seen.add(index)\n replacements.append((index, new_text))\n\nbefore = len(paragraphs)\nfor index, new_text in replacements:\n paragraphs[index].text = new_text\n\nassert len(doc.paragraphs) == before\nfor index, new_text in replacements:\n assert doc.paragraphs[index].text == new_text\n\ndoc.save(target)\nprint(f\"{len(replacements)} of {before} paragraphs replaced\")\n"
84
84
  }
package/hash.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Autogenerated by scripts/build.mjs from skills/. Do not edit.
2
- export const SKILLS_HASH = 'c6c3741623f9af9c'
2
+ export const SKILLS_HASH = '0a22513646401785'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/skills",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Skills for the QV.AC app — the SKILL.md tree plus a content-addressed bundle of it.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,15 +1,18 @@
1
1
  -- Create a Notes note. argv: name, htmlBody, [folder]
2
- -- Notes writes the `name` property as the body's first line, so creating with
3
- -- both name and body renders the title twice. Create from body, then rename.
2
+ -- A note's title is its first line. iCloud notes carry no separate `name` and
3
+ -- refuse `set name` (-10006) after the note already exists, which reported a
4
+ -- created note as failed. So the title goes into the body as its heading.
4
5
  on run argv
5
6
  set noteName to item 1 of argv
6
7
  set noteBody to item 2 of argv
8
+ set heading to "<h1>" & noteName & "</h1>"
9
+ if noteBody does not start with heading then set noteBody to heading & noteBody
7
10
  tell application "Notes"
8
11
  if (count of argv) > 2 then
9
12
  set newNote to make new note at folder (item 3 of argv) with properties {body:noteBody}
10
13
  else
11
14
  set newNote to make new note with properties {body:noteBody}
12
15
  end if
13
- set name of newNote to noteName
16
+ return id of newNote
14
17
  end tell
15
18
  end run
@@ -29,6 +29,15 @@ level so its double quotes need no escaping. Never embed a body inside
29
29
  `osascript -e '...'`: three nested quoting layers drop the closing `"`/`}` and
30
30
  produce `syntax error: Expected "}"` and no note.
31
31
 
32
+ On success the command prints the new note's id (`x-coredata://…`) and nothing
33
+ else. That id IS the confirmation: the note exists, so answer the user — never
34
+ run the command again to check or "retry". Only a non-zero exit with an
35
+ `execution error` means no note was created.
36
+
37
+ The note's title is its first line, so the script makes sure the body starts
38
+ with `<h1>name</h1>`, adding it when the body does not already begin with it.
39
+ Write the title once as the leading `<h1>` and the script changes nothing.
40
+
32
41
  Plain note in the default folder:
33
42
 
34
43
  ```bash
@@ -19,43 +19,35 @@ metadata:
19
19
 
20
20
  # Word
21
21
 
22
- Build, edit, or read `.docx` documents by running python-docx through the
23
- `exec` tool with `language: "python"`. Declare a produced document in
24
- `outputs` and it comes back as a chat attachment the user can save. To answer
25
- _from_ a document instead of building one, run a read call — no `outputs` —
26
- and reply in the chat.
27
-
28
- ## Load the Recipe File First
29
-
30
- This file contains no Python. The working recipes live in four reference
31
- files — load the one for the job with the `skill` tool BEFORE writing any
32
- Python, then copy its recipe and change the content:
22
+ Build, change, or read `.docx` documents. This file holds no Python and no
23
+ recipe: it only says which reference file to load. Load exactly one with the
24
+ `skill` tool, then do what that file says.
25
+
26
+ ## Which File to Load
27
+
28
+ Pick the row by **what the user wants done**, then make that exact `skill`
29
+ call. "Edit", "update", "modify", "change", "replace", "rewrite" and "fix" all
30
+ mean the same thing here the verb never picks the row, the change does.
31
+
32
+ | The user wants | The `skill` call |
33
+ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
34
+ | A new document — "write a report", "make a doc with 30 fun facts about cats", "draft a letter" | `{"name": "word", "file": "references/create.md"}` |
35
+ | Some paragraphs of an existing document changed — "replace the first 10 facts with dog facts", "change fact 3", "reword paragraph 7", "swap these bullets for those" | `{"name": "word", "file": "references/paragraphs.md"}` |
36
+ | Anything else done to an existing document — add a section, remove or rewrite a whole section, make the text bigger, put an image in it | `{"name": "word", "file": "references/rework.md"}` |
37
+ | An answer in the chat from an attached document — "summarize this", "what does it say about X" | `{"name": "word", "file": "references/read.md"}` |
38
+
39
+ - A document that already exists in this chat is never rebuilt with
40
+ `create.md` — that throws away everything the user has. Its `attachmentId`
41
+ is in the `exec` result that produced it or on the `[Attached file …]` line.
42
+ - A summary delivered as a file is `read.md` first, then `create.md`.
43
+ - `paragraphs.md` runs two scripts bundled with this skill and contains no
44
+ Python. `rework.md` and `create.md` carry the python-docx recipes to copy.
33
45
 
34
46
  Each load is a real `skill` tool call — printing the call as JSON or text in
35
- your reply loads nothing.
36
-
37
- - **Creating a new document** (no existing `.docx` involved; may embed
38
- images): call the `skill` tool with `name: "word"` and
39
- `file: "references/create.md"`.
40
- - **Changing some of the facts, points, bullets, items, or paragraphs** of an
41
- existing `.docx` — "replace the first 10 facts", "change fact 3", "swap the
42
- bullets for these", "reword paragraph 7": call the `skill` tool with
43
- `name: "word"` and `file: "references/replace.md"`. This is the file even
44
- when the user says edit, replace, change, update, or rewrite; it runs two
45
- bundled scripts and no Python is written.
46
- - **Any other edit of an existing document** (extend it, trim it, rework a
47
- whole section, resize the text, embed an image into it): call the `skill`
48
- tool with `name: "word"` and `file: "references/edit.md"`.
49
- - **Reading a document to answer in chat** (a summary, a question answered,
50
- content pulled out — no file delivered): call the `skill` tool with
51
- `name: "word"` and `file: "references/read.md"`.
52
- - **A summary delivered as a file** is a read followed by a build: load both
53
- `references/read.md` and `references/create.md`.
54
-
55
- Never write the Python from memory. The recipes carry rules (exact version
56
- pins, attachment staging, run-level formatting, in-place replacement, the only
57
- working removal idiom) that fail in non-obvious ways when improvised; loading
58
- the file is one cheap read-only call.
47
+ your reply loads nothing. Never write the Python from memory: the recipes carry
48
+ rules (exact version pins, attachment staging, the only working removal idiom)
49
+ that fail in non-obvious ways when improvised, and loading the file is one
50
+ cheap read-only call.
59
51
 
60
52
  ## When to Use
61
53
 
@@ -106,13 +98,15 @@ no-`outputs` read that precedes a build delivers nothing and is not one of
106
98
  them, but it belongs before the build, never after it. Reply with a single
107
99
  line: file name + the count line from stdout. If the result has
108
100
  `missingOutputs` instead, the file was never written: read stderr first — an
109
- `AssertionError` there means a guard stopped the save on purpose (see the edit
110
- recipe); only when stderr is clean check the `save()` name matches the
101
+ `AssertionError` there means a guard stopped the save on purpose (see the
102
+ rework recipe); only when stderr is clean check the `save()` name matches the
111
103
  declared output and rerun once.
112
104
 
113
105
  **Failures are fixed in the code, not around it.** An error in your code is
114
106
  never a fault in python-docx or in the runtime; fix the Python against the
115
- loaded reference file's recipes and Errors and call `exec` again. If two
107
+ loaded reference file's recipes and Errors and call `exec` again. A bundled
108
+ script that stops with a message is fixed by correcting its arguments and
109
+ rerunning the same script — never by writing Python in its place. If two
116
110
  consecutive calls fail with the same error, re-read the traceback
117
111
  line-by-line before a third — retrying the identical `command`, or a version
118
112
  with only cosmetic changes, is a loop, not a fix. Do not switch package pins
@@ -130,12 +124,12 @@ and a file you write but do not declare in `outputs` is discarded. The `exec`
130
124
  result is the only account of what happened — there is no filesystem to check
131
125
  and no shell to check it with.
132
126
 
133
- **Never overwrite a staged input.** Edits always save a new output name,
134
- derived from the document edited — `report.docx` becomes `report_revised.docx`,
127
+ **Never overwrite a staged input.** Changes always save a new output name,
128
+ derived from the document changed — `report.docx` becomes `report_revised.docx`,
135
129
  never a fresh name taken from the new content.
136
130
 
137
- **An edit changes the document in place.** `add_paragraph` and `add_heading`
131
+ **A change happens inside the document.** `add_paragraph` and `add_heading`
138
132
  append at the end and nowhere else, so replacing content that is already there
139
133
  means rewriting those paragraphs, not adding new ones. Delivering the original
140
- with the new version appended is a failed turn the edit recipe carries the
141
- guards that catch it.
134
+ with the new version appended, or a fresh document holding only the new
135
+ content, is a failed turn.
@@ -5,11 +5,12 @@ A new document needs **no** `inputs` — do not invent attachment ids — unless
5
5
  it embeds an image (see Embedding Images). **Exactly one** `exec` call per
6
6
  user request when that call succeeds.
7
7
 
8
- **A document that already exists in this chat is never rebuilt here.** "Add a
9
- section", "reword this", "extend the doc" — any request that starts from an
10
- existing `.docx` is an EDIT: load `references/edit.md` and stage the document
11
- by its `attachmentId`. Building a fresh document for an edit request throws
12
- away everything the user already has.
8
+ **A document that already exists in this chat is never rebuilt here.** "Replace
9
+ the first 10 facts", "reword this", "add a section" — any request that starts
10
+ from an existing `.docx` is a change to that document: some of its paragraphs
11
+ is `references/paragraphs.md`, anything else is `references/rework.md`, and
12
+ either one stages the document by its `attachmentId`. Building a fresh
13
+ document for such a request throws away everything the user already has.
13
14
 
14
15
  ## The exec call
15
16
 
@@ -81,9 +82,40 @@ through other tools — that is a dead end. Say the link cannot be downloaded
81
82
  and ask the user to attach the image itself, or offer `generate_image` for a
82
83
  similar visual. Then build the document with the staged attachment as above.
83
84
 
85
+ ## Which Shape
86
+
87
+ | The user asks for | Shape |
88
+ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | "30 fun facts about cats", "10 tips for …", "a list of …", any number of items or points | **List** — a title, then exactly N `List Bullet` paragraphs and nothing else: no intro sentence, no section headings, no numbers typed into the text |
90
+ | a report, memo, letter, plan — anything with sections | **Report** — the recipe under The Recipe below |
91
+
92
+ ### The list shape
93
+
94
+ ```python
95
+ from docx import Document
96
+
97
+ doc = Document()
98
+ doc.add_heading("30 Fun Facts About Cats", level=0)
99
+ facts = [
100
+ "Cats sleep for about 70 percent of their lives.",
101
+ "A group of cats is called a clowder.",
102
+ "A cat's nose print is unique, like a fingerprint.",
103
+ ] # one plain string per item — write all N here
104
+ for fact in facts:
105
+ doc.add_paragraph(fact, style="List Bullet")
106
+ doc.save("cat_facts.docx") # must match the declared output exactly
107
+ print(f"{len(facts)} items, {len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)")
108
+ ```
109
+
110
+ One string per item, as many as the user asked for. No headings between
111
+ groups of items and no introductory sentence: each of those is a paragraph the
112
+ user did not ask for, and a later "change the first 10 items" then lands on the
113
+ wrong lines. The count line it prints is the reply — a delivered list is done,
114
+ whatever the count says; never rebuild it to fix the number.
115
+
84
116
  ## The Recipe
85
117
 
86
- Start from this. It is a complete, working document — a title, headings,
118
+ Start from this for a report. It is a complete, working document — a title, headings,
87
119
  paragraphs with bold and italic runs, a bulleted list, and a table — saved
88
120
  under the declared output name. Copy it and change the content; do not
89
121
  assemble a document from memory.
@@ -255,7 +287,7 @@ A **new** `Document()` ships these styles — safe to use without checking:
255
287
  `Title`, `Heading 1` … `Heading 9`, `Normal`, `List Bullet` (+ ` 2`, ` 3`),
256
288
  `List Number` (+ ` 2`, ` 3`), `Intense Quote`, and the table style `Table Grid`.
257
289
  Do not invent other names for a new document. (An uploaded document carries
258
- only its own styles — when editing one, load `references/edit.md` for the
290
+ only its own styles — when editing one, load `references/rework.md` for the
259
291
  guard.)
260
292
 
261
293
  ## Headings and lists
@@ -1,11 +1,31 @@
1
- # Replacing Paragraphs by Position (bundled scripts)
1
+ # Changing Some Paragraphs of a Document (bundled scripts)
2
2
 
3
- "Replace the first 10 facts", "reword point 3", "swap these bullets for those",
4
- "change paragraph 7": two `exec` calls, both running a script bundled with this
5
- skill. **Write no Python.** Never put source in `command` for this job — pass
6
- `skill`, `script`, and `scriptArgs` exactly as shown, with `inputs` staging the
7
- document by its `attachmentId` (from the earlier `exec` result or the
8
- `[Attached file …]` line — copy it verbatim, never invent one).
3
+ "Replace the first 10 facts", "change fact 3", "swap these bullets for those",
4
+ "reword paragraph 7": two `exec` calls, both running a script bundled with this
5
+ skill. **Write no Python.** There is no `command` in this job — a call with
6
+ `command` is the wrong call. Pass `skill`, `script`, and `scriptArgs` exactly as
7
+ shown, with `inputs` staging the document by its `attachmentId` (from the
8
+ earlier `exec` result or the `[Attached file …]` line — copy it verbatim, never
9
+ invent one).
10
+
11
+ | Step | The `exec` call |
12
+ | --------------------------------------- | ---------------------------------------------------------------- |
13
+ | 1. see the paragraphs and their indexes | `scripts/list_paragraphs.py`, `inputs` staged, no `outputs` |
14
+ | 2. replace exactly the chosen indexes | `scripts/replace_paragraphs.py`, `inputs` staged, one `outputs` |
15
+
16
+ ## Step 0 — find the document's `attachmentId`
17
+
18
+ The id is in the chat already, never invented: a document built earlier in
19
+ this chat has it in the `attachments` of the `exec` result that produced it —
20
+ `{"attachmentId":"922bd4e17517b90593be1c5ae4f12fbd","fileName":"cat_facts.docx"}`
21
+ — and a document the user uploaded has it on the `[Attached file …]` line of
22
+ their message. Copy that exact id into `inputs`. An `inputs` entry with a
23
+ `path` and no `attachmentId` is an _image_ upload and is refused for a
24
+ document:
25
+
26
+ ```json
27
+ { "inputs": [{ "path": "existing.docx" }] }
28
+ ```
9
29
 
10
30
  ## Step 1 — list the paragraphs (no `outputs`)
11
31
 
@@ -20,6 +40,10 @@ document by its `attachmentId` (from the earlier `exec` result or the
20
40
  }
21
41
  ```
22
42
 
43
+ Every key above is required — `inputs` with the document's real
44
+ `attachmentId`, `skill`, `script`, `scriptArgs`. A call missing `skill` or
45
+ `inputs` is refused.
46
+
23
47
  It prints one line per paragraph — `index`, style, text — then a count line.
24
48
  Pick the indexes to replace from that list:
25
49
 
@@ -27,7 +51,7 @@ Pick the indexes to replace from that list:
27
51
  points, or bullets. `Title`, `Heading N`, and an intro sentence are never
28
52
  counted as one.
29
53
  - "The first 10 facts" = the first 10 body-paragraph indexes after the heading
30
- that introduces them — not indexes 0–9.
54
+ or sentence that introduces them — not indexes 0–9.
31
55
  - Fewer facts in the document than asked for: replace the ones that exist and
32
56
  say so in the reply.
33
57
 
@@ -57,6 +81,12 @@ start + 9.
57
81
  }
58
82
  ```
59
83
 
84
+ Wrong, for this job — a `command` instead of a `script`:
85
+
86
+ ```json
87
+ { "command": "from docx import Document\ndoc = Document(\"existing.docx\")\nfor i in range(1, 11): ..." }
88
+ ```
89
+
60
90
  Each new text is one complete plain sentence, no markdown, each different. The
61
91
  script keeps each paragraph's paragraph style, refuses a heading index,
62
92
  refuses text that already reads the same, and prints
@@ -74,6 +104,10 @@ rerun the **same script** — never switch to writing Python.
74
104
 
75
105
  - `usage: replace_paragraphs.py …` — the pairs are incomplete: after the two
76
106
  file names, arguments alternate `index`, `text`.
107
+ - `scriptArgs name "existing_revised.docx" but the working directory starts
108
+ empty` — the call has no `outputs`; add `"outputs": ["existing_revised.docx"]`
109
+ (the same name as in `scriptArgs`) and rerun the same script.
110
+ - `script runs need the owning skill name in skill` — add `"skill": "word"`.
77
111
  - `index N is the heading '…'` — that paragraph is a heading, not a fact. Pick
78
112
  body indexes from the Step 1 list.
79
113
  - `index N is outside the document's M paragraphs` — re-read the Step 1 list;
@@ -82,5 +116,10 @@ rerun the **same script** — never switch to writing Python.
82
116
  a different sentence.
83
117
  - `output … must be a new name` — the output name equals the input's; use
84
118
  `existing_revised.docx`.
85
- - `PackageNotFoundError` / `attachment not found` — `inputs` is missing or
86
- carries an invented id; stage the document by its real `attachmentId`.
119
+ - `an id-less input stages an uploaded image` — the `inputs` entry has no
120
+ `attachmentId`; add the document's id from Step 0 and rerun the same script.
121
+ - `usage: list_paragraphs.py <input.docx>` — `scriptArgs` was left out; pass
122
+ the staged path, `["existing.docx"]`.
123
+ - `PackageNotFoundError` / `attachment … not found` / `does not exist in the
124
+ working directory` — `inputs` is missing or carries an invented id; stage the
125
+ document by its real `attachmentId`.
@@ -1,16 +1,79 @@
1
- # Editing an Existing Word Document (python-docx)
2
-
3
- **Stop here if the request changes some of the facts, points, bullets, items,
4
- or paragraphs** — "replace the first 10 facts", "change fact 3", "swap the
5
- bullets", "reword paragraph 7". That job is `references/replace.md`: call the
6
- `skill` tool with `name: "word"` and `file: "references/replace.md"` now, and
7
- do not use anything in this file for it. No Python is written for that job.
1
+ # Reworking an Existing Word Document (python-docx)
8
2
 
9
3
  Change, replace, extend, trim, or rework a `.docx` that is already in this
10
4
  chat by running Python through the `exec` tool: stage it as an input, modify
11
5
  paragraphs and tables, and save a **new** output such as
12
6
  `existing_revised.docx`. Never overwrite the staged input.
13
7
 
8
+ ## Replacing Some Facts, Points, Bullets or Paragraphs: Two Script Calls
9
+
10
+ "Replace the first 10 facts", "change fact 3", "swap these bullets for those",
11
+ "reword paragraph 7" — any request that changes some of the paragraphs and
12
+ keeps the rest — is two `exec` calls that run scripts bundled with this skill.
13
+ **Write no Python for it.** Nothing else in this file applies to that job: no
14
+ `command`, no `Document(...)`, no fingerprint, no loop. `references/paragraphs.md`
15
+ is this same recipe with its error table.
16
+
17
+ The document's `attachmentId` is already in the chat — in the `attachments` of
18
+ the `exec` result that produced it, or on the user's `[Attached file …]` line.
19
+ Copy it into `inputs`; an entry with only a `path` stages an image, not a
20
+ document.
21
+
22
+ Step 1 — list the paragraphs (no `outputs`):
23
+
24
+ ```json
25
+ {
26
+ "language": "python",
27
+ "packages": ["python-docx==1.2.0"],
28
+ "inputs": [{ "attachmentId": "<real id>", "path": "existing.docx" }],
29
+ "skill": "word",
30
+ "script": "scripts/list_paragraphs.py",
31
+ "scriptArgs": ["existing.docx"]
32
+ }
33
+ ```
34
+
35
+ It prints one line per paragraph — `index`, style, text — then a count line.
36
+ Only body paragraphs (`Normal`, `List Bullet`, `List Number`) are facts, points,
37
+ or bullets; `Title`, `Heading N`, and an intro sentence are never counted as
38
+ one. "The first 10 facts" = the first 10 body-paragraph indexes after the
39
+ heading or sentence that introduces them — not indexes 0–9.
40
+
41
+ Step 2 — replace exactly those paragraphs (one `outputs` entry). `scriptArgs`
42
+ is the input name, the output name, then one `index, new text` pair per
43
+ replaced paragraph — "the first 10 facts" is 10 pairs, each a different
44
+ complete sentence:
45
+
46
+ ```json
47
+ {
48
+ "language": "python",
49
+ "packages": ["python-docx==1.2.0"],
50
+ "inputs": [{ "attachmentId": "<real id>", "path": "existing.docx" }],
51
+ "outputs": ["existing_revised.docx"],
52
+ "skill": "word",
53
+ "script": "scripts/replace_paragraphs.py",
54
+ "scriptArgs": [
55
+ "existing.docx", "existing_revised.docx",
56
+ "3", "Dogs have about 1,700 taste buds.",
57
+ "4", "A dog's nose print is unique, like a fingerprint."
58
+ ]
59
+ }
60
+ ```
61
+
62
+ Wrong, for this job — a `command` instead of a `script`:
63
+
64
+ ```json
65
+ { "command": "from docx import Document\ndoc = Document(\"existing.docx\")\nfor i in range(1, 11): ..." }
66
+ ```
67
+
68
+ `exitCode 0` plus an attachment = done: reply with the file name and the
69
+ printed `K of N paragraphs replaced`, and do not call `exec` again. A script
70
+ error names the fix (a heading index, an index out of range, unchanged text,
71
+ missing pairs, a missing `outputs` for the revised name); correct the
72
+ arguments and rerun the **same script**.
73
+
74
+ Everything below is for the other edits: extending a document, trimming it,
75
+ rewriting a whole section, resizing its text, embedding an image.
76
+
14
77
  ## Staging the Document
15
78
 
16
79
  Stage the document as an input **by its `attachmentId`** and open it with
@@ -163,9 +226,9 @@ formatting-loss rule below.
163
226
  The `add_heading`/`add_paragraph` pair above appends an **Appendix** because
164
227
  that is what the sample edit asks for. Copy that shape only when the user
165
228
  genuinely wants new content at the end. Substituting content that is already
166
- in the document "change the first five points", "rewrite section 2" is a
167
- different job with its own recipe and its own guards: see Replacing Content In
168
- Place.
229
+ in the document is a different job with its own guards: some of the points —
230
+ "change the first five points" is the two script calls at the top of this
231
+ file; a whole section — "rewrite section 2" — is Rewriting Whole Sections.
169
232
 
170
233
  When an edit adds substantial new content — new sections, formatted runs,
171
234
  bulleted lists, whole tables — the writing rules apply unchanged: load
@@ -193,11 +256,11 @@ bullet = "List Bullet" if "List Bullet" in names else None
193
256
  doc.add_paragraph("point one", style=bullet) # style=None → Normal
194
257
  ```
195
258
 
196
- ## Replacing Content In Place
259
+ ## Rewriting Whole Sections
197
260
 
198
- This recipe is for whole *sections* (a heading plus its body). Changing some
199
- of the facts, points, bullets, or paragraphs is `references/replace.md`
200
- never hand-write a loop for that.
261
+ This recipe is for whole _sections_ (a heading plus its body). Changing some
262
+ of the facts, points, bullets, or paragraphs is the two script calls at the
263
+ top of this file — never hand-write a loop for that.
201
264
 
202
265
  `add_paragraph`, `add_heading`, and `add_picture` **always append at the end of
203
266
  the document.** None of them takes a position. "Change the first five points",
@@ -3,10 +3,16 @@
3
3
  Usage: list_paragraphs.py <input.docx>
4
4
  """
5
5
 
6
+ import glob
6
7
  import sys
7
8
 
8
9
  from docx import Document
9
10
 
11
+
12
+ # A call that stages the document but forgets scriptArgs still means that document.
13
+ staged = glob.glob("*.docx")
14
+ if len(sys.argv) == 1 and len(staged) == 1:
15
+ sys.argv.append(staged[0])
10
16
  if len(sys.argv) != 2:
11
17
  sys.exit(f"usage: list_paragraphs.py <input.docx> — got {sys.argv[1:]}")
12
18