Improve Office canvas artifact editing

Add read/edit support for Office document artifacts, including direct DOCX, XLSX, and PPTX updates with version history preservation. Inject compact active canvas metadata so agents can discover opened files without loading file contents. Move detailed usage guidance into the office-artifacts skill and keep the always-on tool prompt lean to avoid context bloat.

Alessandro committed Apr 27, 2026 at 18:56 UTC 45933a47f17717b4f4c84788da5f096295e2c888
11 files changed +1150 -59
plugins/_office/extensions/python/message_loop_prompts_after/_55_include_office_canvas_context.py new
+21
@@ -0,0 +1,21 @@
1 +from __future__ import annotations
2 +
3 +from agent import LoopData
4 +from helpers.extension import Extension
5 +from plugins._office.helpers import canvas_context
6 +
7 +
8 +class IncludeOfficeCanvasContext(Extension):
9 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 + if not self.agent:
11 + return
12 +
13 + context = canvas_context.build_context()
14 + if not context:
15 + loop_data.extras_temporary.pop("office_canvas", None)
16 + return
17 +
18 + loop_data.extras_temporary["office_canvas"] = self.agent.read_prompt(
19 + "agent.extras.office_canvas.md",
20 + office_canvas=context,
21 + )
plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
+1 -1
@@ -58,7 +58,7 @@ function shouldAutoOpenDocument(args, document) {
58 if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false;
59 if (!document?.path) return false;
60 const action = String(kvps.action || "").trim().toLowerCase();
61 - if (["status", "version_history", "inspect"].includes(action)) return false;
61 + if (["status", "version_history", "inspect", "read", "extract"].includes(action)) return false;
62 return isFreshToolMessage(args?.timestamp);
63 }
64
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+7
@@ -11,6 +11,7 @@ export default async function autoOpenDocumentResults(context) {
11 const document = getDocumentPayload(payload);
12 if (!document?.path) continue;
13 if (payload.canvas_surface && payload.canvas_surface !== "office") continue;
14 + if (isReadOnlyAction(payload)) continue;
15 if (!isFresh(args?.timestamp, document.last_modified)) continue;
16
17 const key = [
@@ -46,6 +47,7 @@ function pickPayloadFields(args = {}) {
47 "tool_name",
48 "tool_result",
49 "canvas_surface",
50 + "action",
51 "file_id",
52 "path",
53 "title",
@@ -80,6 +82,11 @@ function getDocumentPayload(payload = {}) {
82 };
83 }
84
85 +function isReadOnlyAction(payload = {}) {
86 + const action = String(payload.action || "").trim().toLowerCase();
87 + return ["status", "version_history", "inspect", "read", "extract"].includes(action);
88 +}
89 +
90 function parseMaybeJson(value) {
91 if (!value) return null;
92 if (typeof value === "object") return value;
plugins/_office/helpers/artifact_editor.py new
+769
@@ -0,0 +1,769 @@
1 +from __future__ import annotations
2 +
3 +import csv
4 +import io
5 +import json
6 +import re
7 +import zipfile
8 +from pathlib import Path
9 +from typing import Any
10 +from xml.sax.saxutils import escape
11 +import xml.etree.ElementTree as ET
12 +
13 +from plugins._office.helpers import wopi_store
14 +
15 +
16 +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
17 +A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
18 +P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
19 +R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
20 +CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
21 +REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
22 +XML_NS = "http://www.w3.org/XML/1998/namespace"
23 +
24 +for prefix, namespace in {
25 + "w": W_NS,
26 + "a": A_NS,
27 + "p": P_NS,
28 + "r": R_NS,
29 +}.items():
30 + ET.register_namespace(prefix, namespace)
31 +
32 +
33 +def qn(namespace: str, tag: str) -> str:
34 + return f"{{{namespace}}}{tag}"
35 +
36 +
37 +def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]:
38 + """Extract compact editable content from an Office artifact."""
39 + path = Path(doc["path"])
40 + ext = str(doc["extension"]).lower()
41 + if ext == "docx":
42 + content = _read_docx(path)
43 + elif ext == "xlsx":
44 + content = _read_xlsx(path)
45 + elif ext == "pptx":
46 + content = _read_pptx(path)
47 + elif ext in {"odt", "ods", "odp"}:
48 + content = _read_odf(path)
49 + else:
50 + raise ValueError(f"Unsupported Office format: {ext}")
51 +
52 + return _trim_payload(content, max_chars=max_chars)
53 +
54 +
55 +def edit_artifact(
56 + doc: dict[str, Any],
57 + operation: str = "",
58 + content: str = "",
59 + find: str = "",
60 + replace: str = "",
61 + sheet: str = "",
62 + cells: Any = None,
63 + rows: Any = None,
64 + slides: Any = None,
65 + **kwargs: Any,
66 +) -> tuple[dict[str, Any], dict[str, Any]]:
67 + """Apply a direct saved edit to an Office artifact and return updated metadata."""
68 + path = Path(doc["path"])
69 + ext = str(doc["extension"]).lower()
70 + op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, slides=slides)
71 + before = path.read_bytes()
72 +
73 + if ext == "docx":
74 + updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
75 + elif ext == "xlsx":
76 + updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
77 + elif ext == "pptx":
78 + updated, details = _edit_pptx(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
79 + else:
80 + raise ValueError(f"Direct edit is not available for .{ext}. Use Collabora in the Office canvas.")
81 +
82 + changed = updated != before
83 + updated_doc = (
84 + wopi_store.replace_document_bytes(doc["file_id"], updated, actor="document_artifact:edit")
85 + if changed
86 + else doc
87 + )
88 + preview = read_artifact(updated_doc, max_chars=int(kwargs.get("preview_chars") or 4000))
89 + payload = {
90 + "ok": True,
91 + "action": "edit",
92 + "operation": op,
93 + "changed": changed,
94 + **details,
95 + "preview": preview,
96 + }
97 + return updated_doc, payload
98 +
99 +
100 +def normalize_operation(
101 + operation: str,
102 + *,
103 + content: str = "",
104 + find: str = "",
105 + cells: Any = None,
106 + rows: Any = None,
107 + slides: Any = None,
108 +) -> str:
109 + op = str(operation or "").strip().lower().replace("-", "_")
110 + aliases = {
111 + "patch": "replace_text" if find else "set_text",
112 + "update": "replace_text" if find else "set_text",
113 + "replace": "replace_text",
114 + "append": "append_text",
115 + "prepend": "prepend_text",
116 + "write": "set_text",
117 + "set": "set_text",
118 + "set_content": "set_text",
119 + "set_sheet": "set_rows",
120 + "write_sheet": "set_rows",
121 + "add_rows": "append_rows",
122 + "add_slide": "append_slide",
123 + "set_deck": "set_slides",
124 + }
125 + op = aliases.get(op, op)
126 + if op:
127 + return op
128 + if cells:
129 + return "set_cells"
130 + if rows:
131 + return "append_rows"
132 + if slides:
133 + return "set_slides"
134 + if find:
135 + return "replace_text"
136 + if content:
137 + return "set_text"
138 + raise ValueError("operation is required")
139 +
140 +
141 +def _read_docx(path: Path) -> dict[str, Any]:
142 + with zipfile.ZipFile(path) as archive:
143 + xml = archive.read("word/document.xml")
144 + root = ET.fromstring(xml)
145 + paragraphs = []
146 + for paragraph in root.iter(qn(W_NS, "p")):
147 + text = "".join(node.text or "" for node in paragraph.iter(qn(W_NS, "t")))
148 + if text.strip():
149 + paragraphs.append(text)
150 + return {
151 + "kind": "document",
152 + "paragraph_count": len(paragraphs),
153 + "text": "\n".join(paragraphs),
154 + "paragraphs": paragraphs[:80],
155 + }
156 +
157 +
158 +def _read_xlsx(path: Path) -> dict[str, Any]:
159 + openpyxl = _require_openpyxl()
160 + workbook = openpyxl.load_workbook(path, data_only=False)
161 + sheets = []
162 + for worksheet in workbook.worksheets[:8]:
163 + rows = []
164 + max_row = min(worksheet.max_row or 0, 80)
165 + max_col = min(worksheet.max_column or 0, 30)
166 + for row in worksheet.iter_rows(min_row=1, max_row=max_row, max_col=max_col, values_only=True):
167 + values = ["" if value is None else value for value in row]
168 + if any(str(value).strip() for value in values):
169 + rows.append(values)
170 + sheets.append({
171 + "name": worksheet.title,
172 + "max_row": worksheet.max_row,
173 + "max_column": worksheet.max_column,
174 + "preview_rows": rows,
175 + })
176 + return {
177 + "kind": "spreadsheet",
178 + "sheet_count": len(workbook.worksheets),
179 + "sheets": sheets,
180 + }
181 +
182 +
183 +def _read_pptx(path: Path) -> dict[str, Any]:
184 + slides = []
185 + with zipfile.ZipFile(path) as archive:
186 + for name in _slide_names(archive):
187 + root = ET.fromstring(archive.read(name))
188 + lines = []
189 + for paragraph in root.iter(qn(A_NS, "p")):
190 + text = "".join(node.text or "" for node in paragraph.iter(qn(A_NS, "t"))).strip()
191 + if text:
192 + lines.append(text)
193 + slides.append({
194 + "index": len(slides) + 1,
195 + "title": lines[0] if lines else "",
196 + "lines": lines,
197 + })
198 + return {
199 + "kind": "presentation",
200 + "slide_count": len(slides),
201 + "slides": slides[:40],
202 + }
203 +
204 +
205 +def _read_odf(path: Path) -> dict[str, Any]:
206 + with zipfile.ZipFile(path) as archive:
207 + xml = archive.read("content.xml")
208 + root = ET.fromstring(xml)
209 + text = "\n".join((node.text or "").strip() for node in root.iter() if (node.text or "").strip())
210 + return {
211 + "kind": "office_document",
212 + "text": text,
213 + }
214 +
215 +
216 +def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
217 + if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
218 + raise ValueError(f"Unsupported DOCX operation: {op}")
219 +
220 + with zipfile.ZipFile(io.BytesIO(before)) as archive:
221 + files = {info.filename: archive.read(info.filename) for info in archive.infolist()}
222 + root = ET.fromstring(files["word/document.xml"])
223 +
224 + if op == "replace_text" or op == "delete_text":
225 + if not find:
226 + raise ValueError("find is required for replace_text")
227 + replacement = "" if op == "delete_text" else replace
228 + count = _replace_text_in_paragraphs(
229 + root,
230 + paragraph_tag=qn(W_NS, "p"),
231 + text_tag=qn(W_NS, "t"),
232 + set_text=_set_word_paragraph_text,
233 + find=find,
234 + replacement=replacement,
235 + limit=_int_or_none(kwargs.get("count")),
236 + )
237 + details = {"replacements": count}
238 + if count == 0:
239 + return before, details
240 + else:
241 + lines = _text_lines(content)
242 + body = root.find(f".//{qn(W_NS, 'body')}")
243 + if body is None:
244 + raise ValueError("DOCX document body not found")
245 + paragraphs = [_word_paragraph(line) for line in lines]
246 + if op == "set_text":
247 + sect_pr = [child for child in list(body) if child.tag == qn(W_NS, "sectPr")]
248 + for child in list(body):
249 + body.remove(child)
250 + for paragraph in paragraphs:
251 + body.append(paragraph)
252 + for child in sect_pr:
253 + body.append(child)
254 + elif op == "append_text":
255 + insert_at = len(body)
256 + for idx, child in enumerate(list(body)):
257 + if child.tag == qn(W_NS, "sectPr"):
258 + insert_at = idx
259 + break
260 + for paragraph in reversed(paragraphs):
261 + body.insert(insert_at, paragraph)
262 + elif op == "prepend_text":
263 + for paragraph in reversed(paragraphs):
264 + body.insert(0, paragraph)
265 + details = {"paragraphs_written": len(paragraphs)}
266 +
267 + files["word/document.xml"] = _xml_bytes(root)
268 + return _zip_from_existing(files), details
269 +
270 +
271 +def _edit_xlsx(
272 + path: Path,
273 + op: str,
274 + *,
275 + content: str = "",
276 + find: str = "",
277 + replace: str = "",
278 + sheet: str = "",
279 + cells: Any = None,
280 + rows: Any = None,
281 + **kwargs: Any,
282 +) -> tuple[bytes, dict[str, Any]]:
283 + if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
284 + raise ValueError(f"Unsupported XLSX operation: {op}")
285 + openpyxl = _require_openpyxl()
286 + workbook = openpyxl.load_workbook(path)
287 + worksheet = _worksheet(workbook, sheet)
288 +
289 + details: dict[str, Any] = {"sheet": worksheet.title}
290 + if op in {"set_text", "set_rows"}:
291 + parsed_rows = _normalize_rows(rows if rows is not None else content)
292 + _clear_worksheet(worksheet)
293 + _write_rows(worksheet, parsed_rows, start_row=1)
294 + details["rows_written"] = len(parsed_rows)
295 + elif op in {"append_text", "append_rows"}:
296 + parsed_rows = _normalize_rows(rows if rows is not None else content)
297 + start_row = max((worksheet.max_row or 0) + 1, 1)
298 + _write_rows(worksheet, parsed_rows, start_row=start_row)
299 + details["rows_appended"] = len(parsed_rows)
300 + details["start_row"] = start_row
301 + elif op == "set_cells":
302 + assignments = _normalize_cells(cells, default_sheet=worksheet.title)
303 + for sheet_name, cell, value in assignments:
304 + target = _worksheet(workbook, sheet_name)
305 + target[cell] = value
306 + details["cells_written"] = len(assignments)
307 + elif op in {"replace_text", "delete_text"}:
308 + if not find:
309 + raise ValueError("find is required for replace_text")
310 + replacement = "" if op == "delete_text" else replace
311 + count = 0
312 + limit = _int_or_none(kwargs.get("count"))
313 + for target in workbook.worksheets:
314 + for row in target.iter_rows():
315 + for cell in row:
316 + if not isinstance(cell.value, str) or find not in cell.value:
317 + continue
318 + remaining = None if limit is None else max(limit - count, 0)
319 + if remaining == 0:
320 + break
321 + cell.value, replaced = _replace_limited(cell.value, find, replacement, remaining)
322 + count += replaced
323 + if limit is not None and count >= limit:
324 + break
325 + if limit is not None and count >= limit:
326 + break
327 + details["replacements"] = count
328 + if count == 0:
329 + return path.read_bytes(), details
330 +
331 + buffer = io.BytesIO()
332 + workbook.save(buffer)
333 + return buffer.getvalue(), details
334 +
335 +
336 +def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
337 + if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
338 + raise ValueError(f"Unsupported PPTX operation: {op}")
339 +
340 + if op in {"set_text", "set_slides"}:
341 + parsed_slides = _normalize_slides(slides if slides is not None else content)
342 + return _pptx_from_slides(parsed_slides), {"slides_written": len(parsed_slides)}
343 +
344 + if op in {"append_text", "append_slide"}:
345 + existing = _pptx_text_slides(before)
346 + existing.extend(_normalize_slides(slides if slides is not None else content))
347 + return _pptx_from_slides(existing), {"slides_written": len(existing)}
348 +
349 + with zipfile.ZipFile(io.BytesIO(before)) as archive:
350 + files = {info.filename: archive.read(info.filename) for info in archive.infolist()}
351 + if not find:
352 + raise ValueError("find is required for replace_text")
353 + replacement = "" if op == "delete_text" else replace
354 + count = 0
355 + limit = _int_or_none(kwargs.get("count"))
356 + for name in sorted([name for name in files if name.startswith("ppt/slides/slide") and name.endswith(".xml")], key=_natural_key):
357 + root = ET.fromstring(files[name])
358 + count += _replace_text_in_paragraphs(
359 + root,
360 + paragraph_tag=qn(A_NS, "p"),
361 + text_tag=qn(A_NS, "t"),
362 + set_text=_set_drawing_paragraph_text,
363 + find=find,
364 + replacement=replacement,
365 + limit=None if limit is None else max(limit - count, 0),
366 + )
367 + files[name] = _xml_bytes(root)
368 + if limit is not None and count >= limit:
369 + break
370 + if count == 0:
371 + return before, {"replacements": count}
372 + return _zip_from_existing(files), {"replacements": count}
373 +
374 +
375 +def _replace_text_in_paragraphs(
376 + root: ET.Element,
377 + *,
378 + paragraph_tag: str,
379 + text_tag: str,
380 + set_text: Any,
381 + find: str,
382 + replacement: str,
383 + limit: int | None,
384 +) -> int:
385 + count = 0
386 + for paragraph in root.iter(paragraph_tag):
387 + texts = list(paragraph.iter(text_tag))
388 + if not texts:
389 + continue
390 + current = "".join(node.text or "" for node in texts)
391 + if find not in current:
392 + continue
393 + remaining = None if limit is None else max(limit - count, 0)
394 + if remaining == 0:
395 + break
396 + updated, replaced = _replace_limited(current, find, replacement, remaining)
397 + if replaced:
398 + set_text(paragraph, updated)
399 + count += replaced
400 + return count
401 +
402 +
403 +def _replace_limited(value: str, find: str, replacement: str, limit: int | None) -> tuple[str, int]:
404 + if limit is None:
405 + return value.replace(find, replacement), value.count(find)
406 + return value.replace(find, replacement, limit), min(value.count(find), limit)
407 +
408 +
409 +def _set_word_paragraph_text(paragraph: ET.Element, text: str) -> None:
410 + keep = [child for child in list(paragraph) if child.tag == qn(W_NS, "pPr")]
411 + for child in list(paragraph):
412 + paragraph.remove(child)
413 + for child in keep:
414 + paragraph.append(child)
415 + paragraph.append(_word_run(text))
416 +
417 +
418 +def _word_paragraph(text: str) -> ET.Element:
419 + paragraph = ET.Element(qn(W_NS, "p"))
420 + paragraph.append(_word_run(text))
421 + return paragraph
422 +
423 +
424 +def _word_run(text: str) -> ET.Element:
425 + run = ET.Element(qn(W_NS, "r"))
426 + text_node = ET.SubElement(run, qn(W_NS, "t"))
427 + if text.startswith(" ") or text.endswith(" "):
428 + text_node.set(qn(XML_NS, "space"), "preserve")
429 + text_node.text = text
430 + return run
431 +
432 +
433 +def _set_drawing_paragraph_text(paragraph: ET.Element, text: str) -> None:
434 + keep = [child for child in list(paragraph) if child.tag == qn(A_NS, "pPr")]
435 + for child in list(paragraph):
436 + paragraph.remove(child)
437 + for child in keep:
438 + paragraph.append(child)
439 + run = ET.SubElement(paragraph, qn(A_NS, "r"))
440 + text_node = ET.SubElement(run, qn(A_NS, "t"))
441 + text_node.text = text
442 +
443 +
444 +def _require_openpyxl() -> Any:
445 + try:
446 + import openpyxl
447 + except ImportError as exc:
448 + raise RuntimeError("openpyxl is required for spreadsheet edits") from exc
449 + return openpyxl
450 +
451 +
452 +def _worksheet(workbook: Any, sheet: str = "") -> Any:
453 + if sheet:
454 + if sheet not in workbook.sheetnames:
455 + return workbook.create_sheet(sheet)
456 + return workbook[sheet]
457 + return workbook.active
458 +
459 +
460 +def _clear_worksheet(worksheet: Any) -> None:
461 + if worksheet.max_row:
462 + worksheet.delete_rows(1, worksheet.max_row)
463 +
464 +
465 +def _write_rows(worksheet: Any, rows: list[list[Any]], start_row: int) -> None:
466 + for row_offset, row in enumerate(rows):
467 + for col_offset, value in enumerate(row):
468 + worksheet.cell(row=start_row + row_offset, column=1 + col_offset, value=_cell_value(value))
469 +
470 +
471 +def _normalize_rows(value: Any) -> list[list[Any]]:
472 + if value is None:
473 + return []
474 + if isinstance(value, list):
475 + rows = value
476 + elif isinstance(value, str):
477 + rows = _rows_from_text(value)
478 + else:
479 + rows = [[value]]
480 + normalized = []
481 + for row in rows:
482 + if isinstance(row, (list, tuple)):
483 + normalized.append([_cell_value(value) for value in row])
484 + else:
485 + normalized.append([_cell_value(row)])
486 + return normalized
487 +
488 +
489 +def _normalize_cells(cells: Any, default_sheet: str) -> list[tuple[str, str, Any]]:
490 + if isinstance(cells, str):
491 + parsed = json.loads(cells)
492 + else:
493 + parsed = cells
494 + if not parsed:
495 + raise ValueError("cells is required for set_cells")
496 +
497 + result: list[tuple[str, str, Any]] = []
498 + if isinstance(parsed, dict):
499 + for ref, value in parsed.items():
500 + sheet, cell = _split_cell_ref(str(ref), default_sheet)
501 + result.append((sheet, cell, _cell_value(value)))
502 + elif isinstance(parsed, list):
503 + for item in parsed:
504 + if not isinstance(item, dict):
505 + raise ValueError("cells list entries must be objects")
506 + ref = str(item.get("cell") or item.get("ref") or "")
507 + sheet = str(item.get("sheet") or default_sheet)
508 + if "!" in ref:
509 + sheet, ref = _split_cell_ref(ref, default_sheet)
510 + if not ref:
511 + raise ValueError("cell is required for each cells entry")
512 + result.append((sheet, ref, _cell_value(item.get("value"))))
513 + else:
514 + raise ValueError("cells must be an object or list")
515 + return result
516 +
517 +
518 +def _split_cell_ref(ref: str, default_sheet: str) -> tuple[str, str]:
519 + if "!" not in ref:
520 + return default_sheet, ref
521 + sheet, cell = ref.split("!", 1)
522 + return sheet.strip("'") or default_sheet, cell
523 +
524 +
525 +def _cell_value(value: Any) -> Any:
526 + if isinstance(value, (dict, list)):
527 + return json.dumps(value, ensure_ascii=False)
528 + return value
529 +
530 +
531 +def _rows_from_text(content: str) -> list[list[str]]:
532 + text = str(content or "").strip("\n")
533 + if not text.strip():
534 + return []
535 + lines = [line for line in text.splitlines() if line.strip()]
536 + markdown_rows = _markdown_table_rows(lines)
537 + if markdown_rows:
538 + return markdown_rows
539 +
540 + delimiter = "\t" if any("\t" in line for line in lines) else ("," if any("," in line for line in lines) else None)
541 + if delimiter:
542 + return [row for row in csv.reader(io.StringIO("\n".join(lines)), delimiter=delimiter)]
543 + return [[line] for line in lines]
544 +
545 +
546 +def _markdown_table_rows(lines: list[str]) -> list[list[str]]:
547 + table_lines = [line.strip() for line in lines if line.strip().startswith("|") and line.strip().endswith("|")]
548 + if len(table_lines) < 2:
549 + return []
550 + rows = []
551 + for line in table_lines:
552 + cells = [cell.strip() for cell in line.strip("|").split("|")]
553 + if all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells):
554 + continue
555 + rows.append(cells)
556 + return rows
557 +
558 +
559 +def _pptx_text_slides(data: bytes) -> list[dict[str, Any]]:
560 + slides = []
561 + with zipfile.ZipFile(io.BytesIO(data)) as archive:
562 + for name in _slide_names(archive):
563 + root = ET.fromstring(archive.read(name))
564 + lines = []
565 + for paragraph in root.iter(qn(A_NS, "p")):
566 + text = "".join(node.text or "" for node in paragraph.iter(qn(A_NS, "t"))).strip()
567 + if text:
568 + lines.append(text)
569 + if lines:
570 + slides.append({"title": lines[0], "bullets": lines[1:]})
571 + return slides
572 +
573 +
574 +def _normalize_slides(value: Any) -> list[dict[str, Any]]:
575 + if value is None:
576 + return []
577 + if isinstance(value, str):
578 + stripped = value.strip()
579 + if not stripped:
580 + return []
581 + if stripped.startswith("[") or stripped.startswith("{"):
582 + return _normalize_slides(json.loads(stripped))
583 + chunks = re.split(r"(?m)^\s*---+\s*$", stripped)
584 + result = []
585 + for chunk in chunks:
586 + lines = [line.strip(" -\t") for line in chunk.splitlines() if line.strip()]
587 + if not lines:
588 + continue
589 + result.append({"title": lines[0], "bullets": lines[1:]})
590 + return result
591 + if isinstance(value, dict):
592 + return [_slide_from_mapping(value)]
593 + if isinstance(value, list):
594 + result = []
595 + for item in value:
596 + if isinstance(item, dict):
597 + result.append(_slide_from_mapping(item))
598 + elif isinstance(item, str):
599 + result.extend(_normalize_slides(item))
600 + elif isinstance(item, (list, tuple)):
601 + lines = [str(part) for part in item if str(part).strip()]
602 + if lines:
603 + result.append({"title": lines[0], "bullets": lines[1:]})
604 + else:
605 + result.append({"title": str(item), "bullets": []})
606 + return result
607 + return [{"title": str(value), "bullets": []}]
608 +
609 +
610 +def _slide_from_mapping(value: dict[str, Any]) -> dict[str, Any]:
611 + title = str(value.get("title") or value.get("heading") or "Slide")
612 + bullets = value.get("bullets")
613 + if bullets is None:
614 + body = value.get("body") or value.get("content") or ""
615 + bullets = [line.strip(" -\t") for line in str(body).splitlines() if line.strip()]
616 + elif isinstance(bullets, str):
617 + bullets = [line.strip(" -\t") for line in bullets.splitlines() if line.strip()]
618 + else:
619 + bullets = [str(item) for item in bullets]
620 + return {"title": title, "bullets": bullets}
621 +
622 +
623 +def _pptx_from_slides(slides: list[dict[str, Any]]) -> bytes:
624 + if not slides:
625 + slides = [{"title": "Presentation", "bullets": []}]
626 +
627 + files: dict[str, str | bytes] = {
628 + "[Content_Types].xml": _pptx_content_types(len(slides)),
629 + "_rels/.rels": (
630 + '<?xml version="1.0" encoding="UTF-8"?>'
631 + f'<Relationships xmlns="{REL_NS}">'
632 + '<Relationship Id="rId1" '
633 + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
634 + 'Target="ppt/presentation.xml"/>'
635 + "</Relationships>"
636 + ),
637 + "ppt/_rels/presentation.xml.rels": _pptx_presentation_rels(len(slides)),
638 + "ppt/presentation.xml": _pptx_presentation_xml(len(slides)),
639 + }
640 + for index, slide in enumerate(slides, start=1):
641 + files[f"ppt/slides/slide{index}.xml"] = _pptx_slide_xml(slide)
642 + return _zip_map(files)
643 +
644 +
645 +def _pptx_content_types(count: int) -> str:
646 + overrides = [
647 + '<Override PartName="/ppt/presentation.xml" '
648 + 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
649 + ]
650 + for index in range(1, count + 1):
651 + overrides.append(
652 + f'<Override PartName="/ppt/slides/slide{index}.xml" '
653 + 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
654 + )
655 + return (
656 + '<?xml version="1.0" encoding="UTF-8"?>'
657 + f'<Types xmlns="{CT_NS}">'
658 + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
659 + '<Default Extension="xml" ContentType="application/xml"/>'
660 + + "".join(overrides)
661 + + "</Types>"
662 + )
663 +
664 +
665 +def _pptx_presentation_rels(count: int) -> str:
666 + rels = []
667 + for index in range(1, count + 1):
668 + rels.append(
669 + f'<Relationship Id="rId{index}" '
670 + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
671 + f'Target="slides/slide{index}.xml"/>'
672 + )
673 + return '<?xml version="1.0" encoding="UTF-8"?>' + f'<Relationships xmlns="{REL_NS}">' + "".join(rels) + "</Relationships>"
674 +
675 +
676 +def _pptx_presentation_xml(count: int) -> str:
677 + slide_ids = "".join(f'<p:sldId id="{255 + index}" r:id="rId{index}"/>' for index in range(1, count + 1))
678 + return (
679 + '<?xml version="1.0" encoding="UTF-8"?>'
680 + f'<p:presentation xmlns:p="{P_NS}" xmlns:r="{R_NS}">'
681 + f"<p:sldIdLst>{slide_ids}</p:sldIdLst>"
682 + '<p:sldSz cx="9144000" cy="5143500"/>'
683 + "</p:presentation>"
684 + )
685 +
686 +
687 +def _pptx_slide_xml(slide: dict[str, Any]) -> str:
688 + title = str(slide.get("title") or "Slide")
689 + bullets = [str(item) for item in slide.get("bullets") or []]
690 + paragraphs = [title, *bullets]
691 + text = "".join(f"<a:p><a:r><a:t>{escape(item)}</a:t></a:r></a:p>" for item in paragraphs)
692 + return (
693 + '<?xml version="1.0" encoding="UTF-8"?>'
694 + f'<p:sld xmlns:a="{A_NS}" xmlns:p="{P_NS}">'
695 + "<p:cSld><p:spTree>"
696 + '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
697 + "<p:grpSpPr/>"
698 + '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Content"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
699 + f"<p:txBody><a:bodyPr/><a:lstStyle/>{text}</p:txBody>"
700 + "</p:sp>"
701 + "</p:spTree></p:cSld>"
702 + "</p:sld>"
703 + )
704 +
705 +
706 +def _slide_names(archive: zipfile.ZipFile) -> list[str]:
707 + return sorted(
708 + [name for name in archive.namelist() if name.startswith("ppt/slides/slide") and name.endswith(".xml")],
709 + key=_natural_key,
710 + )
711 +
712 +
713 +def _natural_key(value: str) -> list[Any]:
714 + return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
715 +
716 +
717 +def _text_lines(content: str) -> list[str]:
718 + lines = [line.rstrip() for line in str(content or "").splitlines()]
719 + return lines or [""]
720 +
721 +
722 +def _int_or_none(value: Any) -> int | None:
723 + if value in (None, ""):
724 + return None
725 + try:
726 + number = int(value)
727 + except (TypeError, ValueError):
728 + return None
729 + return number if number > 0 else None
730 +
731 +
732 +def _xml_bytes(root: ET.Element) -> bytes:
733 + return ET.tostring(root, encoding="utf-8", xml_declaration=True)
734 +
735 +
736 +def _zip_from_existing(files: dict[str, bytes]) -> bytes:
737 + buffer = io.BytesIO()
738 + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
739 + for name, data in files.items():
740 + archive.writestr(name, data)
741 + return buffer.getvalue()
742 +
743 +
744 +def _zip_map(files: dict[str, str | bytes]) -> bytes:
745 + buffer = io.BytesIO()
746 + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
747 + for name, value in files.items():
748 + archive.writestr(name, value.encode("utf-8") if isinstance(value, str) else value)
749 + return buffer.getvalue()
750 +
751 +
752 +def _trim_payload(payload: dict[str, Any], max_chars: int) -> dict[str, Any]:
753 + text = json.dumps(payload, ensure_ascii=False, default=str)
754 + if len(text) <= max_chars:
755 + return payload
756 + trimmed = dict(payload)
757 + if "paragraphs" in trimmed:
758 + trimmed["paragraphs"] = trimmed["paragraphs"][:20]
759 + if "sheets" in trimmed:
760 + trimmed["sheets"] = [
761 + {**sheet, "preview_rows": sheet.get("preview_rows", [])[:20]}
762 + for sheet in trimmed["sheets"][:4]
763 + ]
764 + if "slides" in trimmed:
765 + trimmed["slides"] = trimmed["slides"][:12]
766 + if "text" in trimmed and isinstance(trimmed["text"], str):
767 + trimmed["text"] = trimmed["text"][:max_chars] + "\n... [truncated]"
768 + trimmed["truncated"] = True
769 + return trimmed
plugins/_office/helpers/canvas_context.py new
+31
@@ -0,0 +1,31 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from plugins._office.helpers import wopi_store
6 +
7 +
8 +def build_context(max_items: int = 6) -> str:
9 + documents = wopi_store.get_open_documents(limit=max_items)
10 + if not documents:
11 + return ""
12 +
13 + lines = [
14 + "These Office files have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
15 + ]
16 + for doc in documents:
17 + lines.append(format_document_line(doc))
18 + lines.append(
19 + "Use document_artifact:edit with file_id or path for saved edits; tool results refresh the Office canvas."
20 + )
21 + return "\n".join(lines)
22 +
23 +
24 +def format_document_line(doc: dict[str, Any]) -> str:
25 + return (
26 + f"- {doc.get('basename', 'Untitled')} "
27 + f"(.{doc.get('extension', '')}, file_id={doc.get('file_id', '')}, "
28 + f"path={doc.get('path', '')}, version={wopi_store.item_version(doc)}, "
29 + f"size={doc.get('size', 0)} bytes, last_modified={doc.get('last_modified', '')}, "
30 + f"open_sessions={doc.get('open_sessions', 1)})"
31 + )
plugins/_office/helpers/wopi_store.py
+80 -6
@@ -216,6 +216,27 @@ def get_recent_documents(limit: int = 12) -> list[dict[str, Any]]:
216 return [dict(row) for row in rows]
217
218
219 +def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
220 + with connect() as conn:
221 + rows = conn.execute(
222 + """
223 + SELECT
224 + d.*,
225 + COUNT(s.session_id) AS open_sessions,
226 + MAX(s.created_at) AS last_opened_at,
227 + MAX(s.expires_at) AS session_expires_at
228 + FROM documents d
229 + JOIN sessions s ON s.file_id = d.file_id
230 + WHERE s.expires_at > ?
231 + GROUP BY d.file_id
232 + ORDER BY last_opened_at DESC
233 + LIMIT ?
234 + """,
235 + (now(), limit),
236 + ).fetchall()
237 + return [dict(row) for row in rows]
238 +
239 +
240 def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
241 permission = "write" if permission == "write" else "read"
242 token = secrets.token_urlsafe(32)
@@ -247,6 +268,50 @@ def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl
268 }
269
270
271 +def replace_document_bytes(
272 + file_id: str,
273 + data: bytes,
274 + actor: str = "agent",
275 + invalidate_sessions: bool = True,
276 +) -> dict[str, Any]:
277 + if len(data) > MAX_SAVE_BYTES:
278 + raise OverflowError("Office save exceeds maximum size")
279 + with connect() as conn:
280 + doc = get_document(file_id, conn=conn)
281 + path = Path(doc["path"])
282 + previous = path.read_bytes() if path.exists() else b""
283 + if previous == data:
284 + return doc
285 +
286 + _record_version(conn, file_id, path, item_version(doc), previous)
287 + _write_atomic(path, data)
288 + digest = sha256_bytes(data)
289 + next_version = int(doc["version"]) + 1
290 + changed_at = now()
291 + conn.execute(
292 + """
293 + UPDATE documents
294 + SET size=?, version=?, sha256=?, last_modified=?, updated_at=?
295 + WHERE file_id=?
296 + """,
297 + (len(data), next_version, digest, now_iso(), changed_at, file_id),
298 + )
299 + if invalidate_sessions:
300 + conn.execute("DELETE FROM locks WHERE file_id = ?", (file_id,))
301 + conn.execute("DELETE FROM tokens WHERE file_id = ?", (file_id,))
302 + conn.execute("DELETE FROM sessions WHERE file_id = ?", (file_id,))
303 + conn.execute(
304 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
305 + (
306 + file_id,
307 + "direct_edit",
308 + json.dumps({"actor": actor, "version": f"{next_version}-{digest[:12]}"}),
309 + changed_at,
310 + ),
311 + )
312 + return get_document(file_id, conn=conn)
313 +
314 +
315 def validate_token(raw_token: str, file_id: str, require_write: bool = False) -> dict[str, Any]:
316 if not raw_token:
317 raise PermissionError("Missing WOPI access token")
@@ -379,12 +444,7 @@ def put_file(file_id: str, data: bytes, lock_value: str) -> str:
444
445 previous = path.read_bytes() if path.exists() else b""
446 _record_version(conn, file_id, path, item_version(doc), previous)
382 - tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
383 - with tmp_path.open("wb") as handle:
384 - handle.write(data)
385 - handle.flush()
386 - os.fsync(handle.fileno())
387 - os.replace(tmp_path, path)
447 + _write_atomic(path, data)
448 digest = sha256_bytes(data)
449 next_version = int(doc["version"]) + 1
450 conn.execute(
@@ -398,6 +458,20 @@ def put_file(file_id: str, data: bytes, lock_value: str) -> str:
458 return f"{next_version}-{digest[:12]}"
459
460
461 +def _write_atomic(path: Path, data: bytes) -> None:
462 + path.parent.mkdir(parents=True, exist_ok=True)
463 + tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
464 + try:
465 + with tmp_path.open("wb") as handle:
466 + handle.write(data)
467 + handle.flush()
468 + os.fsync(handle.fileno())
469 + os.replace(tmp_path, path)
470 + finally:
471 + if tmp_path.exists():
472 + tmp_path.unlink(missing_ok=True)
473 +
474 +
475 class LockMismatch(Exception):
476 def __init__(self, current_lock: str) -> None:
477 super().__init__("WOPI lock mismatch")
plugins/_office/prompts/agent.extras.office_canvas.md new
+2
@@ -0,0 +1,2 @@
1 +[OFFICE CANVAS]
2 +{{office_canvas}}
plugins/_office/prompts/agent.system.tool.document_artifact.md
+4 -35
@@ -1,37 +1,6 @@
1 ### document_artifact
2 -create/open/inspect reusable Office artifacts in the Agent Zero canvas
3 -use when producing substantial documents, spreadsheets, or presentations that should stay editable
4 -do not dump long office-style artifacts only into chat when this tool is available
5 -
2 +create/open/read/edit reusable Office artifacts in the Agent Zero canvas
3 formats: docx xlsx pptx odt ods odp
7 -actions: create open inspect export version_history restore_version status
8 -common args: action kind title format content path file_id version_id
9 -
10 -storage:
11 -- generated files default to `/a0/usr/workdir/documents/`
12 -- existing files must be under `/a0/usr/workdir`
13 -- tool results include `canvas_surface: office`; open the Office canvas when collaborating on the artifact
14 -
15 -examples:
16 -~~~json
17 -{
18 - "tool_name": "document_artifact",
19 - "tool_args": {
20 - "action": "create",
21 - "kind": "document",
22 - "title": "Project Brief",
23 - "format": "docx",
24 - "content": "Draft the brief here."
25 - }
26 -}
27 -~~~
28 -
29 -~~~json
30 -{
31 - "tool_name": "document_artifact",
32 - "tool_args": {
33 - "action": "open",
34 - "path": "/a0/usr/workdir/documents/Project Brief.docx"
35 - }
36 -}
37 -~~~
4 +methods: create open read edit inspect export version_history restore_version status
5 +common args: kind title format content path file_id
6 +for nontrivial Office artifact work, load skill `office-artifacts` first
plugins/_office/skills/office-artifacts/SKILL.md new
+104
@@ -0,0 +1,104 @@
1 +---
2 +name: office-artifacts
3 +description: Use when creating, opening, reading, or editing editable Office canvas artifacts such as DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool.
4 +version: "1.0.0"
5 +author: "Agent Zero Core Team"
6 +tags: ["office", "docx", "xlsx", "pptx", "canvas", "documents", "spreadsheets", "presentations"]
7 +triggers:
8 + - "office canvas"
9 + - "editable document"
10 + - "docx"
11 + - "xlsx"
12 + - "pptx"
13 + - "spreadsheet"
14 + - "presentation"
15 +allowed_tools:
16 + - document_artifact
17 +---
18 +
19 +# Office Artifacts
20 +
21 +Use `document_artifact` for substantial Office deliverables that should remain editable in the canvas. Do not paste long document, spreadsheet, or deck bodies only into chat when the user asked for an editable file.
22 +
23 +## Workflow
24 +
25 +1. Create or open the artifact with `document_artifact:create` or `document_artifact:open`.
26 +2. Before content-sensitive edits, call `document_artifact:read` with `file_id` or `path`.
27 +3. Apply saved changes with `document_artifact:edit`.
28 +4. Use `version_history` or `restore_version` when the user asks to audit or roll back.
29 +
30 +Canvas context may list opened Office files with `file_id`, path, version, size, and timestamp. It intentionally omits full file contents; use `read` when the content matters.
31 +
32 +## Minimal Calls
33 +
34 +Create:
35 +```json
36 +{
37 + "tool_name": "document_artifact:create",
38 + "tool_args": {
39 + "kind": "document",
40 + "title": "Project Brief",
41 + "format": "docx",
42 + "content": "Draft text here."
43 + }
44 +}
45 +```
46 +
47 +Read:
48 +```json
49 +{
50 + "tool_name": "document_artifact:read",
51 + "tool_args": {
52 + "file_id": "abc123"
53 + }
54 +}
55 +```
56 +
57 +Edit text in a DOCX or PPTX:
58 +```json
59 +{
60 + "tool_name": "document_artifact:edit",
61 + "tool_args": {
62 + "file_id": "abc123",
63 + "operation": "replace_text",
64 + "find": "old phrase",
65 + "replace": "new phrase"
66 + }
67 +}
68 +```
69 +
70 +Set spreadsheet cells:
71 +```json
72 +{
73 + "tool_name": "document_artifact:edit",
74 + "tool_args": {
75 + "path": "/a0/usr/workdir/documents/Budget.xlsx",
76 + "operation": "set_cells",
77 + "cells": {
78 + "Sheet1!B2": 12500,
79 + "Sheet1!B3": 9800
80 + }
81 + }
82 +}
83 +```
84 +
85 +## Edit Operations
86 +
87 +- DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
88 +- XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
89 +- PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
90 +
91 +Arguments:
92 +
93 +- `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
94 +- `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
95 +- `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
96 +- `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`.
97 +- `count` limits text replacements.
98 +
99 +## Practical Rules
100 +
101 +- Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
102 +- Use `read` before editing unless the current saved content is already known.
103 +- Use `edit` for precise saved changes; use the visual Office canvas for human/manual layout polish.
104 +- Direct edits update version history and refresh the canvas on edit/open results.
plugins/_office/tools/document_artifact.py
+50 -16
@@ -5,7 +5,7 @@ from pathlib import Path
5 from typing import Any
6
7 from helpers.tool import Response, Tool
8 -from plugins._office.helpers import collabora_status, wopi_store
8 +from plugins._office.helpers import artifact_editor, collabora_status, wopi_store
9
10
11 class DocumentArtifact(Tool):
@@ -19,29 +19,62 @@ class DocumentArtifact(Tool):
19 path: str = "",
20 file_id: str = "",
21 version_id: int | str | None = None,
22 + operation: str = "",
23 + find: str = "",
24 + replace: str = "",
25 + sheet: str = "",
26 + cells: Any = None,
27 + rows: Any = None,
28 + slides: Any = None,
29 + max_chars: int | str = 12000,
30 **kwargs: Any,
31 ) -> Response:
32 action = str(action or self.method or "status").strip().lower().replace("-", "_")
33 try:
34 if action == "create":
35 doc = wopi_store.create_document(kind=kind, title=title, fmt=format, content=content, path=path)
28 - return self._document_response("Created document artifact.", doc)
36 + return self._document_response("Created document artifact.", doc, action=action)
37 if action == "open":
38 doc = self._document_from_input(file_id=file_id, path=path)
31 - return self._document_response("Opened document artifact.", doc)
39 + return self._document_response("Opened document artifact.", doc, action=action)
40 + if action in {"read", "extract"}:
41 + doc = self._document_from_input(file_id=file_id, path=path)
42 + payload = {
43 + "ok": True,
44 + "action": "read",
45 + "document": self._public_doc(doc),
46 + "content": artifact_editor.read_artifact(doc, max_chars=int(max_chars or 12000)),
47 + }
48 + return self._json_response(payload, doc=doc, action="read")
49 + if action in {"edit", "update", "patch"}:
50 + doc = self._document_from_input(file_id=file_id, path=path)
51 + updated_doc, payload = artifact_editor.edit_artifact(
52 + doc,
53 + operation=operation,
54 + content=content,
55 + find=find,
56 + replace=replace,
57 + sheet=sheet,
58 + cells=cells,
59 + rows=rows,
60 + slides=slides,
61 + **kwargs,
62 + )
63 + payload["document"] = self._public_doc(updated_doc)
64 + return self._json_response(payload, doc=updated_doc, action="edit")
65 if action == "inspect":
66 doc = self._document_from_input(file_id=file_id, path=path)
34 - return self._json_response({"ok": True, "document": self._public_doc(doc)}, doc=doc)
67 + return self._json_response({"ok": True, "action": action, "document": self._public_doc(doc)}, doc=doc, action=action)
68 if action == "version_history":
69 doc = self._document_from_input(file_id=file_id, path=path)
70 versions = wopi_store.version_history(doc["file_id"])
38 - return self._json_response({"ok": True, "versions": versions}, doc=doc)
71 + return self._json_response({"ok": True, "action": action, "versions": versions}, doc=doc, action=action)
72 if action == "restore_version":
73 if version_id is None or str(version_id).strip() == "":
74 return Response(message="version_id is required for restore_version.", break_loop=False)
75 doc = self._document_from_input(file_id=file_id, path=path)
76 restored = wopi_store.restore_version(doc["file_id"], int(version_id))
44 - return self._document_response("Restored document artifact version.", restored)
77 + return self._document_response("Restored document artifact version.", restored, action=action)
78 if action == "export":
79 doc = self._document_from_input(file_id=file_id, path=path)
80 target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
@@ -49,11 +82,11 @@ class DocumentArtifact(Tool):
82 return Response(
83 message=f"Export to .{target_format} is not available yet. The source file remains unchanged at {doc['path']}.",
84 break_loop=False,
52 - additional=self._additional(doc),
85 + additional=self._additional(doc, action=action),
86 )
54 - return self._document_response("Document artifact export path is ready.", doc)
87 + return self._document_response("Document artifact export path is ready.", doc, action=action)
88 if action == "status":
56 - return self._json_response({"ok": True, "status": collabora_status.collect_status()})
89 + return self._json_response({"ok": True, "action": action, "status": collabora_status.collect_status()}, action=action)
90 return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
91 except Exception as exc:
92 return Response(message=f"document_artifact {action} failed: {exc}", break_loop=False)
@@ -74,27 +107,28 @@ class DocumentArtifact(Tool):
107 return wopi_store.register_document(path)
108 raise ValueError("file_id or path is required")
109
77 - def _document_response(self, message: str, doc: dict[str, Any]) -> Response:
78 - payload = {"ok": True, "message": message, "document": self._public_doc(doc)}
110 + def _document_response(self, message: str, doc: dict[str, Any], action: str = "") -> Response:
111 + payload = {"ok": True, "action": action, "message": message, "document": self._public_doc(doc)}
112 return Response(
113 message=json.dumps(payload, indent=2, ensure_ascii=False),
114 break_loop=False,
82 - additional=self._additional(doc),
115 + additional=self._additional(doc, action=action),
116 )
117
85 - def _json_response(self, payload: dict[str, Any], doc: dict[str, Any] | None = None) -> Response:
118 + def _json_response(self, payload: dict[str, Any], doc: dict[str, Any] | None = None, action: str = "") -> Response:
119 return Response(
120 message=json.dumps(payload, indent=2, ensure_ascii=False, default=str),
121 break_loop=False,
89 - additional=self._additional(doc) if doc else {"_tool_name": self.name, "canvas_surface": "office"},
122 + additional=self._additional(doc, action=action) if doc else {"_tool_name": self.name, "canvas_surface": "office", "action": action},
123 )
124
92 - def _additional(self, doc: dict[str, Any] | None) -> dict[str, Any]:
125 + def _additional(self, doc: dict[str, Any] | None, action: str = "") -> dict[str, Any]:
126 if not doc:
94 - return {"_tool_name": self.name, "canvas_surface": "office"}
127 + return {"_tool_name": self.name, "canvas_surface": "office", "action": action}
128 return {
129 "_tool_name": self.name,
130 "canvas_surface": "office",
131 + "action": action,
132 "file_id": doc["file_id"],
133 "title": doc["basename"],
134 "format": doc["extension"],
tests/test_office_wopi_store.py
+81 -1
@@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 if str(PROJECT_ROOT) not in sys.path:
11 sys.path.insert(0, str(PROJECT_ROOT))
12
13 -from plugins._office.helpers import wopi_routes, wopi_store
13 +from plugins._office.helpers import artifact_editor, canvas_context, wopi_routes, wopi_store
14
15
16 @pytest.fixture()
@@ -159,3 +159,83 @@ def test_office_proxy_accepts_encoded_wopi_socket_token_without_session_cookie(o
159 assert proxy.upstream_websocket_url(scope).startswith("ws://127.0.0.1:32080/office/cool/")
160 assert all(key.lower() not in {"host", "origin", "sec-websocket-key"} for key, _ in headers)
161 assert ("user-agent", "qa") in headers
162 +
163 +
164 +def test_document_artifact_docx_edit_replaces_text_and_tracks_version(office_state):
165 + doc = wopi_store.create_document("document", "Edit Text", "docx", "The old phrase stays here.")
166 +
167 + updated, payload = artifact_editor.edit_artifact(
168 + doc,
169 + operation="replace_text",
170 + find="old phrase",
171 + replace="new phrase",
172 + )
173 + content = artifact_editor.read_artifact(updated)
174 +
175 + assert payload["changed"] is True
176 + assert payload["replacements"] == 1
177 + assert "new phrase" in content["text"]
178 + assert "old phrase" not in content["text"]
179 + assert int(updated["version"]) == 2
180 + assert wopi_store.version_history(doc["file_id"])
181 +
182 +
183 +def test_document_artifact_xlsx_edit_sets_cells_and_appends_rows(office_state):
184 + doc = wopi_store.create_document("spreadsheet", "Budget", "xlsx", "Name,Amount")
185 +
186 + updated, payload = artifact_editor.edit_artifact(
187 + doc,
188 + operation="set_cells",
189 + cells={"A2": "Tools", "B2": 12500},
190 + )
191 + updated, payload = artifact_editor.edit_artifact(
192 + updated,
193 + operation="append_rows",
194 + rows=[["Research", 9800]],
195 + )
196 + content = artifact_editor.read_artifact(updated)
197 + rows = content["sheets"][0]["preview_rows"]
198 +
199 + assert payload["changed"] is True
200 + assert ["Tools", 12500] in rows
201 + assert ["Research", 9800] in rows
202 +
203 +
204 +def test_document_artifact_pptx_edit_sets_slides(office_state):
205 + doc = wopi_store.create_document("presentation", "Roadmap", "pptx", "Initial")
206 +
207 + updated, payload = artifact_editor.edit_artifact(
208 + doc,
209 + operation="set_slides",
210 + slides=[
211 + {"title": "Vision", "bullets": ["Elegant", "Useful"]},
212 + {"title": "Plan", "bullets": ["Build", "Verify"]},
213 + ],
214 + )
215 + content = artifact_editor.read_artifact(updated)
216 +
217 + assert payload["changed"] is True
218 + assert content["slide_count"] == 2
219 + assert [slide["title"] for slide in content["slides"]] == ["Vision", "Plan"]
220 +
221 +
222 +def test_office_canvas_context_lists_active_metadata_without_file_contents(office_state):
223 + doc = wopi_store.create_document("document", "Canvas Context", "docx", "private body text")
224 + wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
225 +
226 + context = canvas_context.build_context()
227 +
228 + assert "Canvas Context.docx" in context
229 + assert doc["file_id"] in context
230 + assert "private body text" not in context
231 +
232 +
233 +def test_office_artifacts_skill_metadata_is_valid():
234 + skill_path = PROJECT_ROOT / "plugins" / "_office" / "skills" / "office-artifacts" / "SKILL.md"
235 + text = skill_path.read_text(encoding="utf-8")
236 +
237 + assert text.startswith("---\n")
238 + assert "\nname: office-artifacts\n" in text
239 + assert "description:" in text
240 + assert "allowed_tools:" in text
241 + assert "document_artifact" in text