@qvac/skills 0.0.0 → 0.1.0

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.
Files changed (84) hide show
  1. package/LICENSE.md +198 -0
  2. package/README.md +49 -0
  3. package/index.d.ts +4 -0
  4. package/index.js +9 -0
  5. package/package.json +87 -1
  6. package/skills/apple-notes/SKILL.md +92 -0
  7. package/skills/apple-notes/append-note.applescript +9 -0
  8. package/skills/apple-notes/cli.schema.json +32 -0
  9. package/skills/apple-notes/create-note.applescript +15 -0
  10. package/skills/apple-notes/delete-note.applescript +10 -0
  11. package/skills/apple-notes/edit-note.applescript +10 -0
  12. package/skills/apple-notes/read-note.applescript +28 -0
  13. package/skills/apple-notes/references/read.md +65 -0
  14. package/skills/apple-notes/references/write.md +105 -0
  15. package/skills/apple-notes/search-notes.applescript +21 -0
  16. package/skills/apple-reminders/SKILL.md +129 -0
  17. package/skills/apple-reminders/cli.schema.json +201 -0
  18. package/skills/apple-reminders/references/edit.md +69 -0
  19. package/skills/apple-reminders/references/view.md +58 -0
  20. package/skills/asana/SKILL.md +59 -0
  21. package/skills/diagrams/SKILL.md +107 -0
  22. package/skills/diagrams/references/class.md +29 -0
  23. package/skills/diagrams/references/er.md +27 -0
  24. package/skills/diagrams/references/flowchart.md +33 -0
  25. package/skills/diagrams/references/gantt.md +38 -0
  26. package/skills/diagrams/references/mindmap.md +35 -0
  27. package/skills/diagrams/references/pie.md +27 -0
  28. package/skills/diagrams/references/sequence.md +32 -0
  29. package/skills/diagrams/references/state.md +30 -0
  30. package/skills/diagrams/references/timeline.md +28 -0
  31. package/skills/excel/SKILL.md +120 -0
  32. package/skills/excel/references/create.md +374 -0
  33. package/skills/excel/references/edit.md +353 -0
  34. package/skills/excel/references/read.md +99 -0
  35. package/skills/github/SKILL.md +42 -0
  36. package/skills/gmail/SKILL.md +142 -0
  37. package/skills/gmail/operations.json +71 -0
  38. package/skills/google-calendar/SKILL.md +139 -0
  39. package/skills/google-calendar/operations.json +62 -0
  40. package/skills/google-docs/SKILL.md +74 -0
  41. package/skills/google-docs/operations.json +61 -0
  42. package/skills/google-docs/references/create.md +97 -0
  43. package/skills/google-docs/references/edit.md +146 -0
  44. package/skills/google-docs/references/read.md +49 -0
  45. package/skills/google-drive/SKILL.md +118 -0
  46. package/skills/google-drive/operations.json +40 -0
  47. package/skills/google-sheets/SKILL.md +71 -0
  48. package/skills/google-sheets/operations.json +85 -0
  49. package/skills/google-sheets/references/create.md +54 -0
  50. package/skills/google-sheets/references/edit.md +124 -0
  51. package/skills/google-sheets/references/read.md +74 -0
  52. package/skills/image-generation/SKILL.md +48 -0
  53. package/skills/music-generation/SKILL.md +76 -0
  54. package/skills/notion/SKILL.md +61 -0
  55. package/skills/notion/operations.json +53 -0
  56. package/skills/notion/references/comments.md +65 -0
  57. package/skills/notion/references/databases.md +68 -0
  58. package/skills/notion/references/pages.md +119 -0
  59. package/skills/notion/references/tasks.md +28 -0
  60. package/skills/obsidian/SKILL.md +122 -0
  61. package/skills/obsidian/cli.schema.json +392 -0
  62. package/skills/obsidian/references/read.md +79 -0
  63. package/skills/obsidian/references/write.md +67 -0
  64. package/skills/pdf/SKILL.md +110 -0
  65. package/skills/pdf/references/create.md +169 -0
  66. package/skills/pdf/references/transform.md +270 -0
  67. package/skills/pdf/scripts/decrypt.py +26 -0
  68. package/skills/pdf/scripts/encrypt.py +25 -0
  69. package/skills/pdf/scripts/extract_text.py +25 -0
  70. package/skills/pdf/scripts/merge.py +21 -0
  71. package/skills/pdf/scripts/rotate.py +27 -0
  72. package/skills/presentations/SKILL.md +118 -0
  73. package/skills/presentations/references/create.md +399 -0
  74. package/skills/presentations/references/edit.md +314 -0
  75. package/skills/presentations/references/read.md +127 -0
  76. package/skills/spotify/SKILL.md +86 -0
  77. package/skills/weather/SKILL.md +33 -0
  78. package/skills/word/SKILL.md +141 -0
  79. package/skills/word/references/create.md +368 -0
  80. package/skills/word/references/edit.md +704 -0
  81. package/skills/word/references/read.md +141 -0
  82. package/skills/word/references/replace.md +86 -0
  83. package/skills/word/scripts/list_paragraphs.py +19 -0
  84. package/skills/word/scripts/replace_paragraphs.py +58 -0
@@ -0,0 +1,704 @@
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.
8
+
9
+ Change, replace, extend, trim, or rework a `.docx` that is already in this
10
+ chat by running Python through the `exec` tool: stage it as an input, modify
11
+ paragraphs and tables, and save a **new** output such as
12
+ `existing_revised.docx`. Never overwrite the staged input.
13
+
14
+ ## Staging the Document
15
+
16
+ Stage the document as an input **by its `attachmentId`** and open it with
17
+ `Document("existing.docx")`. The id comes from wherever the document entered
18
+ the chat:
19
+
20
+ - **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.
21
+ - **Uploaded by the user** — the `[Attached file …]` line on their message names
22
+ it, when the message carries one:
23
+
24
+ ```
25
+ [Attached file "report.docx" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]
26
+ ```
27
+
28
+ Copy the id verbatim — never placeholders like `att_doc`, `att_image`, or any
29
+ id you made up. A `.docx` is **never** staged id-less: an id-less input
30
+ resolves to an uploaded _image_, so it can never reach a document. If no
31
+ `attachmentId` for the document appears anywhere in the chat, say you cannot
32
+ open that file for editing and ask the user to attach it again — do not invent
33
+ an id, do not stage it id-less, and do not retry. An attachment from an
34
+ earlier turn can be used when its attachment id is available in the
35
+ conversation.
36
+
37
+ **The file only exists if this same `exec` call stages it.** The working
38
+ directory starts empty on every call, so an edit needs an `inputs` entry
39
+ naming the attachment, and `Document("existing.docx")` must use that entry's
40
+ exact `path`. Opening a name that was never staged raises
41
+ `PackageNotFoundError: Package not found at '…'` — the fix is the missing
42
+ `inputs`, never a different file name.
43
+
44
+ ## The exec call
45
+
46
+ Images can be staged alongside the document. Tool-produced images
47
+ (`generate_image` output) take the exact `attachmentId` from the tool result;
48
+ an image the user uploaded is staged with `path` only and **no
49
+ `attachmentId` key** — the first id-less entry is the first image of the
50
+ user's latest message, and so on. Id-less entries resolve _images only_.
51
+
52
+ ```json
53
+ {
54
+ "language": "python",
55
+ "packages": ["python-docx==1.2.0"],
56
+ "inputs": [
57
+ {
58
+ "attachmentId": "<id from the exec result or the [Attached file …] line>",
59
+ "path": "existing.docx"
60
+ },
61
+ { "path": "photo.png" }
62
+ ],
63
+ "outputs": ["existing_revised.docx"],
64
+ "command": "..."
65
+ }
66
+ ```
67
+
68
+ - `packages` — `["python-docx==1.2.0"]` on every call. The PyPI package is
69
+ `python-docx` but the import is `docx`; never list `docx` as the package —
70
+ that resolves a different, abandoned library. Pin the version; this exact
71
+ version ships with the app and installs with no network; any other version
72
+ has to be downloaded, which fails on a device that is offline.
73
+ - `inputs` — staged files land in the working directory under the bare `path`
74
+ names — reference `Document("existing.docx")` /
75
+ `doc.add_picture("photo.png", …)` by that name only. Paths must be unique
76
+ bare filenames.
77
+ - `outputs` — the new file to deliver; a file you write but do not declare
78
+ here is discarded. Never the staged input's name. **Name it after the
79
+ document you edited**, not after the change: keep the input's stem and add a
80
+ marker — `report.docx` edited is `report_revised.docx`. A fresh name picked
81
+ from the new content (`cats.docx` for an edit of `parrot_facts.docx`) reads
82
+ as a second, unrelated document and hides the fact that an edit happened at
83
+ all.
84
+ - `command` — the multi-line Python source, with real newline characters.
85
+ Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a
86
+ semicolon is a `SyntaxError`. There is no shell and no installer — packages
87
+ are declared in `packages`.
88
+
89
+ If the image was staged in `inputs`, embed it in **that** single build with
90
+ `doc.add_picture("photo.png", width=Inches(5.5))` — never deliver a document
91
+ and then rebuild to add the image. **Do not soft-fail images or imports**:
92
+ never wrap `add_picture` or an import in `try`/`except` that prints a warning
93
+ and continues — a document saved without the requested image is a failed
94
+ turn, not a success. Pass only one of `width`/`height`; passing both distorts
95
+ the picture.
96
+
97
+ **Image URLs do not work — never download.** Your Python code has **no
98
+ network access**: `requests`, `urllib`, and `socket` all fail with a network
99
+ error, and `http_request` returns truncated text, never image bytes. Say the
100
+ link cannot be downloaded and ask the user to attach the image itself, or
101
+ offer `generate_image` for a similar visual.
102
+
103
+ ## Editing: Work the Objects, Save a New Name
104
+
105
+ One call does the whole edit: open, change, verify the document actually
106
+ changed, save. Keep the fingerprint lines exactly as written — they are what
107
+ stops an edit that silently matched nothing (or a read that only inspected)
108
+ from delivering an unchanged copy of the user's document at `exitCode 0`. The
109
+ fingerprint covers the body **and** the styles part, so a style-only change —
110
+ the resize recipe below — counts as a change too:
111
+
112
+ ```python
113
+ import hashlib
114
+ from docx import Document
115
+
116
+ doc = Document("existing.docx")
117
+ fingerprint = hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest()
118
+
119
+ for paragraph in doc.paragraphs:
120
+ if paragraph.text == "Prepared by the finance team.":
121
+ paragraph.text = "Prepared by the finance team. Revised after board review."
122
+
123
+ table = doc.tables[0]
124
+ row = table.add_row().cells
125
+ row[0].text = "Margin"
126
+ row[1].text = "25%"
127
+ row[2].text = "36%"
128
+
129
+ doc.add_heading("Appendix", level=1)
130
+ doc.add_paragraph("Margins recovered as one-off costs rolled out of the base.")
131
+
132
+ assert (
133
+ hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest() != fingerprint
134
+ ), "nothing changed — the edit matched nothing or never ran; fix it, never deliver an unchanged copy"
135
+ doc.save("existing_revised.docx") # a NEW name — never the staged input
136
+ print(f"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)")
137
+ ```
138
+
139
+ **An assert that fires is a failed turn to diagnose, not a document to
140
+ deliver**: the usual cause is a paragraph match on text that is not exactly
141
+ there — print the real `.text` values in the rerun, fix the match, and never
142
+ delete the assert to get a file out.
143
+
144
+ **Keep it an `assert`, never a `print` or an `if`.** Two `print` lines showing
145
+ the old and new hashes let a no-op save and deliver anyway, which is the one
146
+ thing the assert exists to stop. They also invite a second miscoding: taking
147
+ both hashes together, before the change. Then they match whatever the edit did,
148
+ and the run reports "nothing changed" over a document that changed correctly.
149
+ Take the second hash after the last mutation and before `save`, and let the
150
+ assert raise.
151
+
152
+ **A printed line is never a reason to call `exec` again.** The guard is the
153
+ assert: if it did not fire and the result carries an attachment, the document is
154
+ delivered and the turn is over, whatever stdout says about it. Re-running to
155
+ check saves the same edit under a second name, and the user gets two documents
156
+ for one request.
157
+
158
+ `doc.paragraphs` walks only the document body — text inside tables, headers, and
159
+ footers is **not** in it. Table text is reached through `doc.tables`; match
160
+ paragraphs by their exact `.text` before rewriting them, and remember the
161
+ formatting-loss rule below.
162
+
163
+ The `add_heading`/`add_paragraph` pair above appends an **Appendix** because
164
+ that is what the sample edit asks for. Copy that shape only when the user
165
+ 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.
169
+
170
+ When an edit adds substantial new content — new sections, formatted runs,
171
+ bulleted lists, whole tables — the writing rules apply unchanged: load
172
+ `references/create.md` too and copy its shapes (no markdown characters in
173
+ strings, bold/italic on runs never paragraphs, one string per `add_paragraph`,
174
+ tables built from a header row with `rows=1`).
175
+
176
+ ### Setting `paragraph.text` erases formatting
177
+
178
+ Assigning `paragraph.text = "…"` replaces **all** runs with one plain run: every
179
+ bold, italic, size, and color in that paragraph is gone. Fine for plain
180
+ paragraphs; on a formatted paragraph edit the runs instead, or accept the loss
181
+ deliberately. This is the top footgun when editing an uploaded document.
182
+
183
+ ### Styles must exist in the document
184
+
185
+ `style="List Bullet"` names a style **inside the document**. A missing name
186
+ raises `KeyError: "no style with name 'List Bullet'"` at `add_paragraph` time.
187
+ An **uploaded** document carries only its own styles — one written by another
188
+ tool may lack even `List Bullet`. When editing, guard once and fall back:
189
+
190
+ ```python
191
+ names = [s.name for s in doc.styles]
192
+ bullet = "List Bullet" if "List Bullet" in names else None
193
+ doc.add_paragraph("point one", style=bullet) # style=None → Normal
194
+ ```
195
+
196
+ ## Replacing Content In Place
197
+
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.
201
+
202
+ `add_paragraph`, `add_heading`, and `add_picture` **always append at the end of
203
+ the document.** None of them takes a position. "Change the first five points",
204
+ "rewrite section 2", "swap these facts for those" are all *replacements*, and
205
+ reaching for `add_*` silently turns them into an append: the original content
206
+ stays where it is, the new content lands after the closing line, and the
207
+ document comes back longer than it started with both versions in it. That is a
208
+ failed turn, not a partial success — it is the most common way this skill goes
209
+ wrong.
210
+
211
+ **A section is a heading plus everything under it, up to the next heading of
212
+ the same or higher rank** — which may be one paragraph, or six bullets, or a
213
+ whole subsection, or nothing at all. Never assume it is exactly one paragraph:
214
+ rewriting the heading and the single paragraph after it leaves the rest of the
215
+ old section sitting under its new title, which is the same contradiction an
216
+ append produces and is just as invisible in the result. Work out where each
217
+ section ends before changing anything.
218
+
219
+ Rank matters as much as position. `Heading 2` under a `Heading 1` is a
220
+ subsection, not the next section, so "replace the first two sections" on a
221
+ document with subheadings must not consume the parent's own subheading as
222
+ section two — the same rule the removal recipe below follows. `rank()` reads
223
+ the level off the style name, and only the shallowest rank counts as a section
224
+ start.
225
+
226
+ **A heading shallower than every other heading is the document's title, not its
227
+ first section.** A document headed `Heading 1` and sectioned `Heading 2` — the
228
+ shape most attached documents have — would otherwise have exactly one
229
+ "section": the title, spanning everything under it. Replacing that section
230
+ replaces the entire document, and nothing about the result says so. The `if`
231
+ drops such a heading before sections are picked, and the whole-document assert
232
+ refuses the span even if one is somehow selected.
233
+
234
+ **One heading is dropped, never a chain of them.** It is an `if`, not a
235
+ `while`: a document is titled once. Stripping repeatedly walks down the
236
+ outline — on a `Heading 1` title over a `Heading 2` phase holding `Heading 3`
237
+ weeks it drops the title, then the phase, and the weeks become the "sections",
238
+ so replacing the first two rewrites the weeks and leaves the phase untouched.
239
+ That is the silent wrong target this section exists to prevent. Stopping after
240
+ one leaves the phase as the only section, and asking for a second raises an
241
+ error that says so.
242
+
243
+ Take one snapshot of `doc.paragraphs` and index into it. **Every string in
244
+ `NEW` is a placeholder** — the sample fills it with report sections so the
245
+ shape is readable, and you replace all of it with the content this request asks
246
+ for. Shipping a sample string in the user's document is a failed turn:
247
+
248
+ ```python
249
+ from docx import Document
250
+
251
+ doc = Document("existing.docx")
252
+ paras = doc.paragraphs # one snapshot — index into THIS list
253
+ blocks = list(doc.element.body) # paragraphs AND tables, in document order
254
+
255
+ NEW = [ # placeholders — you write every string here
256
+ ("Regional Performance", "Revenue grew in every region except EMEA, where the quarter closed flat."),
257
+ ("Cost Base", "Headcount costs fell as the contractor pool wound down, and the saving held."),
258
+ ]
259
+ TARGET = range(len(NEW)) # which sections to replace — here the first len(NEW)
260
+
261
+ def rank(paragraph): # "Heading 2" -> 2; a bare "Heading" is rank 1
262
+ tail = paragraph.style.name.split()[-1]
263
+ return int(tail) if tail.isdigit() else 1
264
+
265
+ heads = [i for i, p in enumerate(paras) if p.style.name.startswith("Heading")]
266
+ if len(heads) > 1 and all(rank(paras[heads[0]]) < rank(paras[i]) for i in heads[1:]):
267
+ heads = heads[1:] # a lone heading above all the rest is the title
268
+ top = min((rank(paras[i]) for i in heads), default=1)
269
+ starts = [i for i in heads if rank(paras[i]) == top] # sections, never their subsections
270
+ ends = [next((j for j in heads if j > i and rank(paras[j]) <= top), len(paras)) for i in starts]
271
+
272
+ assert NEW, "NEW is empty — write the replacement content before running the edit"
273
+ assert len(TARGET) == len(NEW), f"TARGET names {len(TARGET)} sections but NEW has {len(NEW)} items"
274
+ assert len(starts) > max(TARGET), f"TARGET reaches section {max(TARGET) + 1}, but the document has {len(starts)}"
275
+ at = [blocks.index(p._element) for p in paras] # where each paragraph sits among the blocks
276
+ for k in TARGET:
277
+ assert ends[k] > starts[k] + 1, f"section {paras[starts[k]].text!r} has no body paragraph to replace"
278
+ 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"
279
+ span = blocks[at[starts[k]] : at[ends[k]] if ends[k] < len(paras) else len(blocks)]
280
+ 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"
281
+
282
+ # measured from the document, before anything changes — never from what the loop below does
283
+ before = len(paras)
284
+ old_body = [p.text for k in TARGET for p in paras[starts[k] + 1 : ends[k]]]
285
+ doomed = [(p.text, p._element) for k in TARGET for p in paras[starts[k] + 2 : ends[k]]]
286
+ expected = before - len(old_body) + len(NEW) # each replaced section keeps exactly one body paragraph
287
+
288
+ for k, (title, body) in zip(TARGET, NEW):
289
+ paras[starts[k]].text = title # the heading keeps its own style
290
+ paras[starts[k] + 1].text = body
291
+ paras[starts[k] + 1].style = doc.styles["Normal"] # the reused paragraph may have been a bullet
292
+ for p in paras[starts[k] + 2 : ends[k]]: # whatever else the section held
293
+ p._element.getparent().remove(p._element)
294
+
295
+ assert len(doc.paragraphs) == expected, f"expected {expected} paragraphs, got {len(doc.paragraphs)} — an old section was not fully replaced, or content was appended"
296
+ for text, el in doomed:
297
+ assert el.getparent() is None, f"an old paragraph is still in the document: {text[:40]!r}"
298
+ doc.save("existing_revised.docx") # a NEW name — never the staged input
299
+ print(f"{len(NEW)} of {len(starts)} sections replaced, {before} -> {len(doc.paragraphs)} paragraphs")
300
+ ```
301
+
302
+ `TARGET` names the sections to replace, once, and `zip` pairs each new item with
303
+ the section it overwrites. Replacing a different range is a change to that one
304
+ line — `TARGET = range(2, 5)` for "sections 3 through 5", with three items in
305
+ `NEW` to match. Keep it bound in a single place: a range written twice drifts
306
+ apart the moment one copy is edited, and every guard below reads `TARGET`
307
+ rather than assuming the range starts at zero.
308
+
309
+ Assigning `paras[head].text` keeps that paragraph's style, because the style
310
+ lives on the paragraph and not on its runs: a `Heading 2` stays a `Heading 2`.
311
+ Only the run-level formatting inside it is lost, per the rule above. The body
312
+ paragraph is the opposite case — it is reused, so it arrives carrying whatever
313
+ style the old body had, which is why the sample sets it back to `Normal`. Set it
314
+ to something else when the new body should be a bullet or a quote, and guard the
315
+ name as shown under Styles.
316
+
317
+ **Keep the document's own numbering.** The sample titles carry no `1.`, `2.`
318
+ prefix because the document it edits does not number itself, and a typed prefix
319
+ on a `List Number` paragraph double-numbers. When the headings you are
320
+ overwriting *do* carry manual numbers, take each number from the position being
321
+ overwritten so the sequence continues — replacing sections 3 through 5 writes
322
+ `3.`, `4.`, `5.`, never restarting at `1.`
323
+
324
+ **Every assert, exactly as written — and measured before the loop runs.**
325
+ `old_body` and `expected` come from the document's own structure, never from
326
+ what the loop reports about itself. That is the whole point: a loop that
327
+ rewrites only the paragraph after each heading, the mistake this recipe exists
328
+ to prevent, would tally its own work as complete. Derived up front, the numbers
329
+ contradict it. None of these failures is distinguishable from success by
330
+ `exitCode 0` plus an attachment:
331
+
332
+ - **`assert NEW`** catches an empty content list. Without it `max(TARGET)`
333
+ raises a bare `ValueError`, and were it not for that the run would save an
334
+ untouched copy of the user's document at `exitCode 0`.
335
+ - **`len(TARGET) == len(NEW)`** catches a target range and a content list that
336
+ drifted apart. `zip` would silently pair only the shorter of the two.
337
+ - **`len(starts) > max(TARGET)`** catches a range reaching past the last
338
+ section. It reads `TARGET`, not `len(NEW)`, because the range need not start
339
+ at zero — a `len(NEW)` check passes on `range(2, 5)` over four sections and
340
+ the run then dies on an `IndexError` that names nothing.
341
+ - **the whole-document assert** refuses a section running from the first
342
+ heading to the last paragraph. That is not a replacement, it is a rewrite:
343
+ every other check passes while the document is emptied down to one heading
344
+ and one paragraph. It anchors on `heads[0]`, not paragraph 0 — a document
345
+ whose only heading sits under a draft notice, a date, or a byline still has
346
+ exactly one section, and anchoring on index 0 would wave it through.
347
+ - **`ends[k] > starts[k] + 1`** catches a section with no body paragraph — a
348
+ heading followed straight by a table, or the last heading in the document.
349
+ There is nothing under it to rewrite. It runs before any mutation, so a bad
350
+ target changes nothing.
351
+ - **the `}tbl` assert** catches a table inside a section being replaced.
352
+ `doc.paragraphs` does not see tables, so the loop below cannot remove one:
353
+ without this the old table survives under the new heading with every other
354
+ check passing. Say the table has to be rebuilt, or target a different section.
355
+ - **the `expected` assert** catches an old section left partly in place *and*
356
+ new content appended, because `expected` is what the paragraph count must be
357
+ once each replaced section holds exactly one body paragraph.
358
+ - **the `getparent() is None` assert** catches an old paragraph the loop was
359
+ supposed to drop but left attached. Compare **elements, not text**: text
360
+ comparison cannot tell a paragraph that survived from an identical one
361
+ standing legitimately elsewhere, and a document that repeats a line — three
362
+ status sections each reading `Nothing to report.` — would fail a correct edit
363
+ with no way to satisfy the assert. Identity has no such collision, and it
364
+ needs no special case for blank paragraphs.
365
+
366
+ An assert that fires is a failed turn to diagnose, never a document to deliver.
367
+
368
+ ### When the replacement needs more than one paragraph
369
+
370
+ The loop above reuses one paragraph per section and drops the rest. When a
371
+ replacement needs an **extra** paragraph, insert it before the paragraph that
372
+ should follow it. `insert_paragraph_before` is the only insert there is, and it
373
+ is a method on the paragraph you want to push down:
374
+
375
+ ```python
376
+ anchor = paras[ends[k]] # the next section's heading
377
+ extra = anchor.insert_paragraph_before("A second body paragraph.", style="Normal")
378
+ ```
379
+
380
+ It takes the same style names as `add_paragraph` (`"Heading 2"`, `"List
381
+ Bullet"`, `None` for Normal) and returns the new paragraph, so runs can be
382
+ formatted on it. Inserting a whole new section is this call once per paragraph,
383
+ each against the heading it goes above. A section at the very end of the
384
+ document has no next heading to anchor to — `ends[k]` is `len(paras)` — so
385
+ append there with `doc.add_paragraph`, the one case where appending is right.
386
+
387
+ Inserting does not disturb the `paras` snapshot: it is a plain Python list
388
+ holding the paragraphs that already existed, so every index taken before the
389
+ insert still points at the same paragraph afterwards. Only a fresh
390
+ `doc.paragraphs` shifts.
391
+
392
+ Count what you insert and fold it into `expected` rather than dropping the
393
+ guard — `expected = before - len(old_body) + len(NEW) + added` — so an
394
+ accidental append is still caught.
395
+
396
+ ## Removing Content
397
+
398
+ python-docx has **no delete API.** There is no `doc.remove_paragraph` and no
399
+ `paragraph.delete`, and `doc.paragraphs` is rebuilt on every access, so
400
+ `doc.paragraphs.remove(p)` edits a throwaway list and changes nothing in the file.
401
+ Removing anything means dropping its XML element from the parent — this one line
402
+ is the whole technique, and there is no alternative to it:
403
+
404
+ ```python
405
+ p._element.getparent().remove(p._element)
406
+ ```
407
+
408
+ Code that finds the paragraphs and never runs that line — a `for`/`if` that
409
+ matches the text and falls through, or a comment like
410
+ `# Find and remove paragraphs containing "Conclusion"` standing in for the
411
+ removal — saves a document byte-identical to the input at `exitCode 0`, with an
412
+ attachment that looks like a success. Nothing in the result says the edit was a
413
+ no-op, which is why the sample below asserts the count changed before it saves.
414
+
415
+ Because `doc.paragraphs` is a fresh list each time, `for p in doc.paragraphs:`
416
+ walks a snapshot and removing inside the loop is safe.
417
+
418
+ **A whole section** — a heading plus everything under it, up to the next heading
419
+ of the same or higher rank — is that line plus a flag. Track the heading's level,
420
+ or a sub-heading inside the section ends the removal early and orphans the
421
+ paragraphs below it:
422
+
423
+ ```python
424
+ from docx import Document
425
+
426
+ doc = Document("existing.docx")
427
+
428
+ TARGET = "Conclusion" # the heading text that opens the section
429
+
430
+ before = len(doc.paragraphs)
431
+ depth = None # the target heading's level while removing
432
+ for p in doc.paragraphs:
433
+ style = p.style.name # a style object — compare through .name
434
+ if style.startswith("Heading"):
435
+ tail = style.split()[-1]
436
+ level = int(tail) if tail.isdigit() else 1 # "Heading 2" -> 2
437
+ if depth is not None and level <= depth:
438
+ depth = None # a sibling heading closes the section
439
+ if p.text.strip() == TARGET:
440
+ depth = level
441
+ if depth is not None:
442
+ p._element.getparent().remove(p._element)
443
+
444
+ after = len(doc.paragraphs)
445
+ assert after < before, f"removed nothing ({before} -> {after}) — the match never fired"
446
+ doc.save("existing_revised.docx") # a NEW name — never the staged input
447
+ print(f"{before} -> {after} paragraphs")
448
+ ```
449
+
450
+ Find the heading through `p.style.name`, never the text alone — a body paragraph
451
+ that mentions "Conclusion" is not the section heading. Removing individual
452
+ paragraphs is the same loop without the flag: match them, and call the removal
453
+ line on each one.
454
+
455
+ **Assert the count changed, before you save.** Keep the
456
+ `assert after < before` line exactly where the sample puts it — between the loop
457
+ and `doc.save(...)` — and do not soften it to a `print` or an `if`. It is what
458
+ makes a no-op impossible to deliver: the assert raises, `save` never runs, so no
459
+ file is written and the result comes back with `missingOutputs` instead of an
460
+ attachment. Without it a removal that never fired still saves the unchanged
461
+ document, and the run is indistinguishable from a real edit — `exitCode 0`, an
462
+ attachment, and nothing anywhere saying the document is a copy of the input.
463
+
464
+ An assert that fires is a **failed turn to diagnose**, never a result to report.
465
+ It means the match did not fire: wrong heading text, a heading style the document
466
+ does not use, or text living in a table, header, or footer, which
467
+ `doc.paragraphs` never walks. Fix the match and rerun — do not delete the assert
468
+ to get a file out.
469
+
470
+ The printed `before -> after` line is then just the reply line (`16 -> 12
471
+ paragraphs`), not the check. Both live inside the build, so this takes no extra
472
+ call: the assert and the `print` are in the same `exec` that does the removal.
473
+
474
+ This assert is also why "Success = stop" needs no second call on a destructive
475
+ edit: `exitCode: 0` plus an attachment cannot on its own tell a real edit from
476
+ a copy of the input, because a removal that never fired produces both. The
477
+ build itself closes that gap — it asserts the count changed before `save`, so
478
+ a no-op returns `missingOutputs` rather than a convincing attachment. A
479
+ delivered document is still never reopened to "verify" it; the fix for a
480
+ failed assert is a corrected build, never an `exec` opened to inspect what was
481
+ already delivered.
482
+
483
+ **Table rows** have no delete API either, and take the same idiom on the row's own
484
+ element. `table.rows` iterates a snapshot just as `doc.paragraphs` does, so
485
+ removing inside the loop is safe — and the count gets the same assert, because a
486
+ row matched by its cell text can miss exactly the way a paragraph can:
487
+
488
+ ```python
489
+ table = doc.tables[0]
490
+
491
+ before = len(table.rows)
492
+ for row in table.rows:
493
+ if row.cells[0].text == "Discontinued":
494
+ row._element.getparent().remove(row._element)
495
+ assert len(table.rows) < before, f"no row matched ({before} rows unchanged)"
496
+ ```
497
+
498
+ Removing a row by position needs no assert — `table.rows[9]` on a 4-row table
499
+ raises `IndexError` rather than quietly doing nothing:
500
+
501
+ ```python
502
+ row = table.rows[2]
503
+ row._element.getparent().remove(row._element)
504
+ ```
505
+
506
+ Rows only grow at the bottom: there is no insert-at, and no delete either
507
+ outside this idiom. Address cells as `table.cell(row, col)` or
508
+ `table.rows[r].cells[c]` — they are the same cell; for formatting inside a
509
+ cell go through `cell.paragraphs[0]` and its runs like any other paragraph.
510
+
511
+ **Table columns cannot be removed.** A column is not one element — it is an entry
512
+ in the table grid plus one cell in every row — and a horizontally merged cell is a
513
+ single `<w:tc>` shared across two grid positions, so removing "the second cell" of
514
+ every row deletes that merged cell whole and leaves its row a column short. The
515
+ document opens visibly ragged and nothing raises. Rebuild the table with the
516
+ columns you want instead, or say the column has to be dropped in Word.
517
+
518
+ ## Reading What Is Already in the Document
519
+
520
+ A document exposes exactly two collections — `doc.paragraphs` and `doc.tables`.
521
+ Everything else is derived by filtering them; there is no `doc.headings`, no
522
+ `doc.sections_by_title`, no `doc.text`. A `Paragraph` has `.text`, `.style` and
523
+ `.runs`, and no `.paragraphs` of its own.
524
+
525
+ **`paragraph.style` is a style object, not a string** — compare through
526
+ `.name`, or you get
527
+ `AttributeError: 'ParagraphStyle' object has no attribute 'startswith'`:
528
+
529
+ ```python
530
+ headings = [p for p in doc.paragraphs if p.style.name.startswith("Heading")]
531
+ body = [p for p in doc.paragraphs if p.style.name == "Normal"]
532
+ ```
533
+
534
+ Most edits need no inspection at all — go straight to the change. When a look
535
+ is genuinely needed first (an exact `.text` to match, a style name), that call
536
+ only prints: **an inspection never saves and declares no `outputs`** — a save
537
+ without the change delivers a stale copy of the user's document. The new file
538
+ comes only from the one call that changes it. Reading for the _user_ — a
539
+ summary or an answer delivered as chat text — is its own flow with its own
540
+ call shape: load `references/read.md`.
541
+
542
+ ## Resizing Text: Set the Styles, Never Scale `run.font.size`
543
+
544
+ `run.font.size` is `None` whenever the size comes from the paragraph's style,
545
+ which is the normal case for a document you did not hand-size. **`None` does not
546
+ mean zero.** Reading it as a number and scaling it writes a 0pt font, and 0pt
547
+ text is invisible in Word and Pages — the document opens looking blank, with no
548
+ error anywhere to tell you why:
549
+
550
+ ```python
551
+ size = run.font.size.pt if run.font.size else 0
552
+ run.font.size = Pt(size * 1.5) # WRONG: 0 * 1.5 = 0pt, invisible text
553
+ ```
554
+
555
+ "Make the font bigger" is a change to the **styles**, because every run without
556
+ its own size inherits from them. Set absolute point sizes on the styles the
557
+ document actually uses, and the whole document — body, tables, headers — follows
558
+ in four lines:
559
+
560
+ ```python
561
+ from docx import Document
562
+ from docx.shared import Pt
563
+
564
+ doc = Document("existing.docx")
565
+
566
+ doc.styles["Normal"].font.size = Pt(14) # body text; 11pt is the default
567
+ doc.styles["List Bullet"].font.size = Pt(14)
568
+ doc.styles["Heading 1"].font.size = Pt(20)
569
+ doc.styles["Title"].font.size = Pt(32)
570
+
571
+ doc.save("larger.docx")
572
+ print(f"{len(doc.paragraphs)} paragraphs resized")
573
+ ```
574
+
575
+ **Keep the hierarchy.** Raise every style you touch, not one size for all of
576
+ them — a title and a heading set to the body size read as unstyled text. Body
577
+ around 14pt pairs with roughly 20pt headings and a 32pt title, and the same
578
+ ratios hold at any size the user asks for.
579
+
580
+ Only touch a style the document has — guard with the `doc.styles` check above
581
+ when unsure. If a specific run really must be sized on its own, assign an
582
+ absolute `Pt(...)` value; never one derived from the size you read back.
583
+
584
+ ## Errors
585
+
586
+ - `PackageNotFoundError: Package not found at '…'` — the document was never
587
+ staged, or an id-less entry staged an image under a `.docx` path. Add
588
+ `inputs: [{ "attachmentId": "<id from the exec result or the [Attached file …]
589
+ line>", "path": "existing.docx" }]` and open that exact path. If no id is
590
+ available, ask the user to attach the file again rather than guessing a name.
591
+ - `attachment … not found in this chat` means `inputs` listed an id that is not
592
+ in this chat (often a copied placeholder like `att_doc`). Re-copy the exact id
593
+ from the `exec` result or the `[Attached file …]` line that names the file;
594
+ if no id appears anywhere in the chat, ask the user to re-attach.
595
+ - `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or
596
+ wrong — add `["python-docx==1.2.0"]` and rerun. Never try to install it, and
597
+ never "fix" it by importing `python_docx`; the import stays `docx`.
598
+ - `KeyError: "no style with name '…'"` — the style is not in this document.
599
+ Check `doc.styles` and fall back as shown under Styles.
600
+ - `NameError: name 'Pt' is not defined` (or `Inches`, `RGBColor`) — the import
601
+ line is missing that name; they all import from `docx.shared`.
602
+ - `AssertionError: removed nothing (16 -> 16)` means the removal matched nothing:
603
+ either the loop never fired or it never called
604
+ `p._element.getparent().remove(p._element)`; python-docx has no delete method to
605
+ reach for instead. Diagnose the match and rerun — never delete the assert to get
606
+ a file out, since the file it would produce is a copy of the input.
607
+ - `AssertionError: expected 7 paragraphs, got 9` means the document did not end
608
+ up the shape a replacement makes. Two causes: the old sections were only
609
+ partly replaced — the loop rewrote the paragraph after each heading and left
610
+ the rest of the section standing — or new content was appended with
611
+ `add_paragraph`/`add_heading`, which only ever append. `expected` is derived
612
+ from the section bounds before anything changes, so it is right and the
613
+ document is wrong: replace each section through to the next heading.
614
+ - `AssertionError: an old paragraph is still in the document: '…'` means the
615
+ loop was adapted and no longer drops everything past the paragraph it reuses.
616
+ Every paragraph from `starts[k] + 2` to `ends[k]` has to go; the removal idiom
617
+ below is the only thing that removes one. This compares elements, so it never
618
+ fires because the document happens to repeat a line elsewhere.
619
+ - `AssertionError: section '…' has no body paragraph to replace` means that
620
+ heading is followed straight by a table, or is the last paragraph in the
621
+ document. There is nothing under it to rewrite: target a different section, or
622
+ insert the body with `insert_paragraph_before` before adding to it.
623
+ - `IndexError: list index out of range` while walking sections means an index
624
+ ran past the end of `starts` or of `paras`: a `TARGET` reaching past the last
625
+ section, or `paras[i + 1]` on a document whose final paragraph is a heading.
626
+ Guard the range with `len(starts) > max(TARGET)` — not against `len(NEW)`,
627
+ which says nothing when the range does not start at zero — and take section
628
+ ends from the next heading of the same or higher rank, with `len(paras)`
629
+ closing the last one.
630
+ - `AssertionError: section '…' holds a table` means the section being replaced
631
+ contains a table. `doc.paragraphs` never sees tables, so the loop cannot
632
+ remove one and it would survive under the new heading. Rebuild the table
633
+ explicitly, or tell the user that section has to be replaced by hand.
634
+ - `AssertionError: TARGET names 2 sections but NEW has 3 items` means the range
635
+ and the content list drifted apart. Fix whichever is wrong; do not let `zip`
636
+ quietly use the shorter.
637
+ - `AssertionError: TARGET reaches section 2, but the document has 1` on a
638
+ document that plainly has several usually means its sections are `Heading 2`
639
+ under a `Heading 1` title. The title is dropped before sections are picked,
640
+ so check `rank()` is reading the style names this document actually uses —
641
+ print `[p.style.name for p in doc.paragraphs]` — rather than lowering
642
+ `TARGET` until the assert passes. Section 1 of a title-only document is the
643
+ whole document.
644
+ - `AssertionError: section '…' spans the whole document` means the heading
645
+ selected covers every paragraph, so replacing it would empty the document.
646
+ It is a title being treated as a section, or a request to rewrite rather than
647
+ edit — build a new document with `references/create.md` if that is what the
648
+ user wants.
649
+ - `AttributeError: 'Document' object has no attribute 'insert_paragraph'` means
650
+ the code guessed an insert API on the document. There is none. The only insert
651
+ is `paragraph.insert_paragraph_before(text, style)`, on the paragraph the new
652
+ one goes above.
653
+ - `TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments
654
+ but 4 were given` means a position was passed to `add_paragraph`. It has no
655
+ position parameter and always appends; use `insert_paragraph_before`.
656
+ - Identical old and new fingerprints on a run that saved anyway means the
657
+ assert was softened into `print` lines and both hashes were taken before the
658
+ change. It is not evidence the edit failed, and it is not grounds for another
659
+ `exec`: restore the assert and take the second hash after the mutation.
660
+ - A delivered document identical to the one you opened means an edit ran
661
+ without the fingerprint assert — an edit that matched nothing, or an
662
+ inspection that saved. Add the assert before `save` and rerun the actual
663
+ change.
664
+ - `AssertionError: nothing changed — the edit matched nothing or never ran`
665
+ means exactly that: the paragraph match found no text, or no mutation
666
+ happened before `save`. Print the real `.text` values, fix the match, rerun
667
+ — never remove the assert.
668
+ - `TypeError: 'Table' object is not subscriptable` — a table was indexed
669
+ directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or
670
+ `table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.
671
+ - `AttributeError: 'Document' object has no attribute 'remove_paragraph'` (or
672
+ `'Paragraph' object has no attribute 'delete'`) means the code guessed a delete
673
+ API. There is none; drop the XML element instead.
674
+ - A resize that "worked" but left the document blank means a 0pt font: something
675
+ scaled `run.font.size` while it was `None`. Set absolute sizes on the styles
676
+ instead — see Resizing Text.
677
+ - `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was
678
+ collapsed — restore multi-line newlines from the sample and rerun. Underscores
679
+ in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to
680
+ `python -c` or change the package pin.
681
+ - On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`
682
+ about positional arguments the call passes the wrong number of them — usually
683
+ several strings where one is allowed. Fix either against this file's examples,
684
+ reading the line number in the traceback. Do not retry the same call, and do
685
+ not switch to a shell.
686
+ - If the result has `missingOutputs`, the file was never written. Read stderr
687
+ first: an `AssertionError` there means a guard stopped the save on purpose
688
+ and its message names what to fix — rerunning the same code fails the same
689
+ way. Only when stderr is clean is this a naming problem: check the `save()`
690
+ name matches the declared output and rerun once.
691
+ - Never print the document's bytes or base64 — stdout is capped and the file
692
+ travels through `outputs`. A build call prints exactly one line (e.g. `9
693
+ paragraphs, 1 table(s)`).
694
+ - Never pass an absolute path to `save()`.
695
+
696
+ ## Finish
697
+
698
+ When `exitCode` is `0` and `attachments` lists the `.docx`, the edit is done —
699
+ the `exec` result carries
700
+ `attachments: [{ attachmentId, fileName, byteLength }]` and the file is already
701
+ attached to the chat for the user to open or save. Stop tool use and reply
702
+ with a single line: file name + the count line from stdout. Exactly one
703
+ successful `exec` per request; never reopen a delivered document to "verify"
704
+ it.