Make Office artifacts ODF-first

Promote LibreOffice-native ODT, ODS, and ODP as first-class defaults for Writer, Spreadsheet, and Presentation while keeping OOXML as explicit compatibility formats. Add ODF package generation, validation, read/edit support, and focused tests for Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX artifact behavior. Reduce automatic document response triggering so meta-discussions about generated files do not create artifacts, while explicit file and canvas requests still work through the intended Markdown editor or Desktop affordance. Preserve the native A0 browser launcher, sync the live container, and validate the flow with real chats and Playwright.

Alessandro committed May 5, 2026 at 10:01 UTC 2398bd1601ca1e24ee7cce17e4197203ff7714ce
21 files changed +1036 -118
README.md
+2 -2
@@ -82,7 +82,7 @@ The canvas makes agent work visible. You can watch it browse, inspect what chang
82
83 Create, open, and cowork with the AI on documents, spreadsheets, and presentation decks.
84
85 -The document canvas supports Markdown by default, with LibreOffice-backed DOCX, XLSX, and PPTX workflows when binary artifacts are needed. Agents can create substantial deliverables, read their contents, apply precise saved edits, preserve version history, and generate native XLSX charts directly inside spreadsheets.
85 +The document canvas supports Markdown by default, with LibreOffice-native ODT, ODS, and ODP workflows when binary office artifacts are needed. DOCX, XLSX, and PPTX remain available for explicit Microsoft compatibility. Agents can create substantial deliverables, read their contents, apply precise saved edits, preserve version history, and generate native XLSX charts directly inside compatibility spreadsheets.
86
87 ## Native Browser With Annotations and Extensions
88
@@ -176,7 +176,7 @@ Agent Zero supports plugins, MCP, A2A, custom tools, custom prompts, project-sco
176 ## Try These First
177
178 - **Research with a browser:** "Open the browser, compare three project management tools for a small AI team, and summarize the tradeoffs with source links."
179 -- **Cowork on a spreadsheet:** "Create an editable XLSX budget model with assumptions, monthly projections, and a native chart."
179 +- **Cowork on a spreadsheet:** "Create an editable ODS budget model with assumptions and monthly projections."
180 - **Review a web UI:** "Open my local app in the Browser. I will annotate the page with comments; then implement the requested UI fixes."
181 - **Work inside a Git project:** "Clone this repository into a new project, inspect the architecture, and propose the safest first improvement."
182 - **Create a specialist:** "Create an Agent Profile for financial analysis with cautious reasoning, clear assumptions, and spreadsheet-first deliverables."
plugins/_office/api/office_session.py
+4
@@ -33,6 +33,10 @@ class OfficeSession(ApiHandler):
33 )
34 except ValueError as exc:
35 return {"ok": False, "error": str(exc)}
36 + if doc["extension"] in {"odt", "ods", "odp"}:
37 + validation = libreoffice.validate_odf(doc["path"])
38 + if not validation.get("ok"):
39 + return {"ok": False, "error": validation.get("error") or "ODF validation failed."}
40 if doc["extension"] == "docx":
41 validation = libreoffice.validate_docx(doc["path"])
42 if not validation.get("ok"):
plugins/_office/helpers/artifact_editor.py
+409
@@ -20,12 +20,24 @@ 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 +ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
24 +ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
25 +ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
26 +ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
27 +ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
28 +ODS_DIRECT_EDIT_ROW_LIMIT = 10000
29 +ODS_DIRECT_EDIT_COLUMN_LIMIT = 1024
30
31 for prefix, namespace in {
32 "w": W_NS,
33 "a": A_NS,
34 "p": P_NS,
35 "r": R_NS,
36 + "office": ODF_OFFICE_NS,
37 + "text": ODF_TEXT_NS,
38 + "table": ODF_TABLE_NS,
39 + "draw": ODF_DRAW_NS,
40 + "presentation": ODF_PRESENTATION_NS,
41 }.items():
42 ET.register_namespace(prefix, namespace)
43
@@ -40,6 +52,12 @@ def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]
52 ext = str(doc["extension"]).lower()
53 if ext == "md":
54 content = _read_markdown(path)
55 + elif ext == "odt":
56 + content = _read_odt(path)
57 + elif ext == "ods":
58 + content = _read_ods(path)
59 + elif ext == "odp":
60 + content = _read_odp(path)
61 elif ext == "docx":
62 content = _read_docx(path)
63 elif ext == "xlsx":
@@ -74,6 +92,12 @@ def edit_artifact(
92 invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
93 if ext == "md":
94 updated, details = _edit_markdown(before, op, content=content, find=find, replace=replace, **kwargs)
95 + elif ext == "odt":
96 + updated, details = _edit_odt(before, op, content=content, find=find, replace=replace, **kwargs)
97 + elif ext == "ods":
98 + updated, details = _edit_ods(before, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
99 + elif ext == "odp":
100 + updated, details = _edit_odp(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
101 elif ext == "docx":
102 updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
103 elif ext == "xlsx":
@@ -179,6 +203,65 @@ def _read_markdown(path: Path) -> dict[str, Any]:
203 }
204
205
206 +def _read_odt(path: Path) -> dict[str, Any]:
207 + root = _odf_content_root(path)
208 + paragraphs = _odf_text_lines(root)
209 + headings = [
210 + "".join(node.itertext()).strip()
211 + for node in root.iter(qn(ODF_TEXT_NS, "h"))
212 + if "".join(node.itertext()).strip()
213 + ]
214 + return {
215 + "kind": "document",
216 + "format": "odt",
217 + "paragraph_count": len(paragraphs),
218 + "headings": headings[:40],
219 + "text": "\n".join(paragraphs),
220 + "paragraphs": paragraphs[:80],
221 + }
222 +
223 +
224 +def _read_ods(path: Path) -> dict[str, Any]:
225 + sheets = _ods_sheets_from_bytes(
226 + path.read_bytes(),
227 + max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
228 + max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
229 + )
230 + return {
231 + "kind": "spreadsheet",
232 + "format": "ods",
233 + "sheet_count": len(sheets),
234 + "sheets": [
235 + {
236 + "name": sheet["name"],
237 + "max_row": len(sheet["rows"]),
238 + "max_column": max((len(row) for row in sheet["rows"]), default=0),
239 + "chart_count": 0,
240 + "charts": [],
241 + "preview_rows": sheet["rows"][:80],
242 + }
243 + for sheet in sheets[:8]
244 + ],
245 + }
246 +
247 +
248 +def _read_odp(path: Path) -> dict[str, Any]:
249 + slides = _odp_text_slides(path.read_bytes())
250 + return {
251 + "kind": "presentation",
252 + "format": "odp",
253 + "slide_count": len(slides),
254 + "slides": [
255 + {
256 + "index": index + 1,
257 + "title": slide.get("title", ""),
258 + "lines": [slide.get("title", ""), *slide.get("bullets", [])],
259 + }
260 + for index, slide in enumerate(slides[:40])
261 + ],
262 + }
263 +
264 +
265 def _read_docx(path: Path) -> dict[str, Any]:
266 with zipfile.ZipFile(path) as archive:
267 xml = archive.read("word/document.xml")
@@ -272,6 +355,30 @@ def _edit_markdown(before: bytes, op: str, *, content: str = "", find: str = "",
355 return updated.encode("utf-8"), details
356
357
358 +def _edit_odt(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
359 + if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
360 + raise ValueError(f"Unsupported ODT operation: {op}")
361 +
362 + paragraphs = _odf_text_lines(ET.fromstring(_zip_member(before, "content.xml")))
363 + if op == "set_text":
364 + lines = _text_lines(content)
365 + return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
366 + if op == "append_text":
367 + lines = [*paragraphs, *_text_lines(content)]
368 + return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
369 + if op == "prepend_text":
370 + lines = [*_text_lines(content), *paragraphs]
371 + return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
372 +
373 + if not find:
374 + raise ValueError("find is required for replace_text")
375 + replacement = "" if op == "delete_text" else replace
376 + joined, count = _replace_limited("\n".join(paragraphs), find, replacement, _int_or_none(kwargs.get("count")))
377 + if count == 0:
378 + return before, {"replacements": count}
379 + return document_store.odt_bytes_from_paragraphs(joined.splitlines()), {"replacements": count}
380 +
381 +
382 def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
383 if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
384 raise ValueError(f"Unsupported DOCX operation: {op}")
@@ -327,6 +434,75 @@ def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", rep
434 return _zip_from_existing(files), details
435
436
437 +def _edit_ods(
438 + before: bytes,
439 + op: str,
440 + *,
441 + content: str = "",
442 + find: str = "",
443 + replace: str = "",
444 + sheet: str = "",
445 + cells: Any = None,
446 + rows: Any = None,
447 + **kwargs: Any,
448 +) -> tuple[bytes, dict[str, Any]]:
449 + if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
450 + raise ValueError(f"Unsupported ODS operation: {op}")
451 +
452 + sheets = _ods_sheets_from_bytes(
453 + before,
454 + max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
455 + max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
456 + strict_limits=True,
457 + )
458 + if not sheets:
459 + sheets = [{"name": "Sheet1", "rows": []}]
460 + worksheet = _ods_sheet(sheets, sheet)
461 + details: dict[str, Any] = {"sheet": worksheet["name"]}
462 +
463 + if op in {"set_text", "set_rows"}:
464 + parsed_rows = _normalize_rows(rows if rows is not None else content)
465 + worksheet["rows"] = parsed_rows
466 + details["rows_written"] = len(parsed_rows)
467 + elif op in {"append_text", "append_rows"}:
468 + parsed_rows = _normalize_rows(rows if rows is not None else content)
469 + worksheet["rows"].extend(parsed_rows)
470 + details["rows_appended"] = len(parsed_rows)
471 + details["start_row"] = max(len(worksheet["rows"]) - len(parsed_rows) + 1, 1)
472 + elif op == "set_cells":
473 + assignments = _normalize_cells(cells, default_sheet=worksheet["name"])
474 + for sheet_name, cell, value in assignments:
475 + target = _ods_sheet(sheets, sheet_name)
476 + row_idx, col_idx = _cell_indices(cell)
477 + _set_matrix_value(target["rows"], row_idx, col_idx, value)
478 + details["cells_written"] = len(assignments)
479 + else:
480 + if not find:
481 + raise ValueError("find is required for replace_text")
482 + replacement = "" if op == "delete_text" else replace
483 + count = 0
484 + limit = _int_or_none(kwargs.get("count"))
485 + for item in sheets:
486 + for row_idx, row in enumerate(item["rows"]):
487 + for col_idx, value in enumerate(row):
488 + if not isinstance(value, str) or find not in value:
489 + continue
490 + remaining = None if limit is None else max(limit - count, 0)
491 + if remaining == 0:
492 + break
493 + row[col_idx], replaced = _replace_limited(value, find, replacement, remaining)
494 + count += replaced
495 + if limit is not None and count >= limit:
496 + break
497 + if limit is not None and count >= limit:
498 + break
499 + details["replacements"] = count
500 + if count == 0:
501 + return before, details
502 +
503 + return document_store.ods_bytes_from_sheets(sheets), details
504 +
505 +
506 def _edit_xlsx(
507 path: Path,
508 op: str,
@@ -956,6 +1132,57 @@ def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", rep
1132 return _zip_from_existing(files), {"replacements": count}
1133
1134
1135 +def _edit_odp(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
1136 + if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
1137 + raise ValueError(f"Unsupported ODP operation: {op}")
1138 +
1139 + if op in {"set_text", "set_slides"}:
1140 + parsed_slides = _normalize_slides(slides if slides is not None else content)
1141 + return document_store.odp_bytes_from_slides(parsed_slides), {"slides_written": len(parsed_slides)}
1142 +
1143 + existing = _odp_text_slides(before)
1144 + if op in {"append_text", "append_slide"}:
1145 + existing.extend(_normalize_slides(slides if slides is not None else content))
1146 + return document_store.odp_bytes_from_slides(existing), {"slides_written": len(existing)}
1147 +
1148 + if not find:
1149 + raise ValueError("find is required for replace_text")
1150 + replacement = "" if op == "delete_text" else replace
1151 + count = 0
1152 + limit = _int_or_none(kwargs.get("count"))
1153 + for slide in existing:
1154 + title, title_count = _replace_limited(
1155 + str(slide.get("title") or ""),
1156 + find,
1157 + replacement,
1158 + None if limit is None else max(limit - count, 0),
1159 + )
1160 + if title_count:
1161 + slide["title"] = title
1162 + count += title_count
1163 + if limit is not None and count >= limit:
1164 + break
1165 + bullets = []
1166 + for bullet in slide.get("bullets") or []:
1167 + updated, replaced = _replace_limited(
1168 + str(bullet),
1169 + find,
1170 + replacement,
1171 + None if limit is None else max(limit - count, 0),
1172 + )
1173 + bullets.append(updated)
1174 + count += replaced
1175 + if limit is not None and count >= limit:
1176 + bullets.extend(slide.get("bullets", [])[len(bullets):])
1177 + break
1178 + slide["bullets"] = bullets
1179 + if limit is not None and count >= limit:
1180 + break
1181 + if count == 0:
1182 + return before, {"replacements": count}
1183 + return document_store.odp_bytes_from_slides(existing), {"replacements": count}
1184 +
1185 +
1186 def _replace_text_in_paragraphs(
1187 root: ET.Element,
1188 *,
@@ -1140,6 +1367,188 @@ def _markdown_table_rows(lines: list[str]) -> list[list[str]]:
1367 return rows
1368
1369
1370 +def _zip_member(data: bytes, name: str) -> bytes:
1371 + with zipfile.ZipFile(io.BytesIO(data)) as archive:
1372 + return archive.read(name)
1373 +
1374 +
1375 +def _odf_content_root(path: Path) -> ET.Element:
1376 + with zipfile.ZipFile(path) as archive:
1377 + return ET.fromstring(archive.read("content.xml"))
1378 +
1379 +
1380 +def _odf_text_lines(root: ET.Element) -> list[str]:
1381 + lines = []
1382 + for node in root.iter():
1383 + if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
1384 + continue
1385 + text = "".join(node.itertext()).strip()
1386 + if text:
1387 + lines.append(text)
1388 + return lines
1389 +
1390 +
1391 +def _ods_sheets_from_bytes(
1392 + data: bytes,
1393 + *,
1394 + max_rows: int | None = None,
1395 + max_cols: int | None = None,
1396 + strict_limits: bool = False,
1397 +) -> list[dict[str, Any]]:
1398 + root = ET.fromstring(_zip_member(data, "content.xml"))
1399 + sheets = []
1400 + for index, table in enumerate(root.iter(qn(ODF_TABLE_NS, "table")), start=1):
1401 + name = table.get(qn(ODF_TABLE_NS, "name")) or table.get("name") or f"Sheet{index}"
1402 + rows = []
1403 + for row in table:
1404 + if row.tag != qn(ODF_TABLE_NS, "table-row"):
1405 + continue
1406 + values = _ods_row_values(row, max_cols=max_cols, strict_limits=strict_limits)
1407 + repeat_rows = _repeat_count(row.get(qn(ODF_TABLE_NS, "number-rows-repeated")))
1408 + append_count = repeat_rows
1409 + if max_rows is not None:
1410 + remaining = max(max_rows - len(rows), 0)
1411 + append_count = min(repeat_rows, remaining)
1412 + if strict_limits and repeat_rows > append_count and _row_has_content(values):
1413 + raise ValueError(f"ODS direct editing is limited to {max_rows} populated rows per sheet.")
1414 + if remaining == 0:
1415 + if not strict_limits:
1416 + break
1417 + continue
1418 + for _ in range(append_count):
1419 + rows.append(values.copy())
1420 + if max_rows is not None and len(rows) >= max_rows and not strict_limits:
1421 + break
1422 + sheets.append({"name": name, "rows": _trim_blank_edges(rows)})
1423 + return sheets
1424 +
1425 +
1426 +def _ods_row_values(
1427 + row: ET.Element,
1428 + *,
1429 + max_cols: int | None = None,
1430 + strict_limits: bool = False,
1431 +) -> list[Any]:
1432 + values = []
1433 + for cell in row:
1434 + if cell.tag not in {qn(ODF_TABLE_NS, "table-cell"), qn(ODF_TABLE_NS, "covered-table-cell")}:
1435 + continue
1436 + value = _ods_cell_value(cell)
1437 + repeat = _repeat_count(cell.get(qn(ODF_TABLE_NS, "number-columns-repeated")))
1438 + append_count = repeat
1439 + if max_cols is not None:
1440 + remaining = max(max_cols - len(values), 0)
1441 + append_count = min(repeat, remaining)
1442 + if strict_limits and repeat > append_count and _cell_has_content(value):
1443 + raise ValueError(f"ODS direct editing is limited to {max_cols} populated columns per sheet.")
1444 + if remaining == 0:
1445 + if not strict_limits:
1446 + break
1447 + continue
1448 + for _ in range(append_count):
1449 + values.append(value)
1450 + if max_cols is not None and len(values) >= max_cols and not strict_limits:
1451 + break
1452 + return values
1453 +
1454 +
1455 +def _ods_cell_value(cell: ET.Element) -> Any:
1456 + value_type = str(cell.get(qn(ODF_OFFICE_NS, "value-type")) or "").lower()
1457 + if value_type in {"float", "currency", "percentage"}:
1458 + raw = cell.get(qn(ODF_OFFICE_NS, "value"))
1459 + if raw not in (None, ""):
1460 + try:
1461 + number = float(raw)
1462 + return int(number) if number.is_integer() else number
1463 + except ValueError:
1464 + pass
1465 + if value_type == "boolean":
1466 + raw = str(cell.get(qn(ODF_OFFICE_NS, "boolean-value")) or "").lower()
1467 + if raw in {"true", "false"}:
1468 + return raw == "true"
1469 + text = "\n".join("".join(node.itertext()).strip() for node in cell.iter(qn(ODF_TEXT_NS, "p")))
1470 + return text.strip()
1471 +
1472 +
1473 +def _repeat_count(value: Any) -> int:
1474 + try:
1475 + count = int(value or 1)
1476 + except (TypeError, ValueError):
1477 + count = 1
1478 + return max(1, count)
1479 +
1480 +
1481 +def _trim_blank_edges(rows: list[list[Any]]) -> list[list[Any]]:
1482 + trimmed = []
1483 + for row in rows:
1484 + next_row = list(row)
1485 + while next_row and not _cell_has_content(next_row[-1]):
1486 + next_row.pop()
1487 + trimmed.append(next_row)
1488 + while trimmed and not _row_has_content(trimmed[-1]):
1489 + trimmed.pop()
1490 + return trimmed
1491 +
1492 +
1493 +def _cell_has_content(value: Any) -> bool:
1494 + if value is None:
1495 + return False
1496 + if isinstance(value, str):
1497 + return bool(value.strip())
1498 + return True
1499 +
1500 +
1501 +def _row_has_content(row: list[Any]) -> bool:
1502 + return any(_cell_has_content(value) for value in row)
1503 +
1504 +
1505 +def _ods_sheet(sheets: list[dict[str, Any]], name: str = "") -> dict[str, Any]:
1506 + normalized = str(name or "").strip()
1507 + if normalized:
1508 + for sheet in sheets:
1509 + if str(sheet["name"]).casefold() == normalized.casefold():
1510 + return sheet
1511 + sheet = {"name": normalized, "rows": []}
1512 + sheets.append(sheet)
1513 + return sheet
1514 + return sheets[0]
1515 +
1516 +
1517 +def _cell_indices(cell: str) -> tuple[int, int]:
1518 + match = re.fullmatch(r"\$?([A-Za-z]{1,4})\$?([1-9][0-9]*)", str(cell or "").strip())
1519 + if not match:
1520 + raise ValueError(f"Invalid cell reference: {cell}")
1521 + col = 0
1522 + for char in match.group(1).upper():
1523 + col = col * 26 + (ord(char) - 64)
1524 + return int(match.group(2)), col
1525 +
1526 +
1527 +def _set_matrix_value(rows: list[list[Any]], row_idx: int, col_idx: int, value: Any) -> None:
1528 + while len(rows) < row_idx:
1529 + rows.append([])
1530 + row = rows[row_idx - 1]
1531 + while len(row) < col_idx:
1532 + row.append("")
1533 + row[col_idx - 1] = value
1534 +
1535 +
1536 +def _odp_text_slides(data: bytes) -> list[dict[str, Any]]:
1537 + root = ET.fromstring(_zip_member(data, "content.xml"))
1538 + slides = []
1539 + for page in root.iter(qn(ODF_DRAW_NS, "page")):
1540 + lines = []
1541 + for node in page.iter():
1542 + if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
1543 + continue
1544 + text = "".join(node.itertext()).strip()
1545 + if text:
1546 + lines.append(text)
1547 + if lines:
1548 + slides.append({"title": lines[0], "bullets": lines[1:]})
1549 + return slides
1550 +
1551 +
1552 def _pptx_text_slides(data: bytes) -> list[dict[str, Any]]:
1553 slides = []
1554 with zipfile.ZipFile(io.BytesIO(data)) as archive:
plugins/_office/helpers/document_affordance.py
+82 -18
@@ -42,6 +42,9 @@ DOCUMENT_TERMS = {
42 "manual",
43 "markdown",
44 "memo",
45 + "odt",
46 + "open document",
47 + "opendocument",
48 "policy",
49 "proposal",
50 "report",
@@ -49,11 +52,14 @@ DOCUMENT_TERMS = {
52 "spec",
53 "story",
54 "whitepaper",
55 + "writer",
56 }
57
58 SPREADSHEET_TERMS = {
59 "budget",
60 + "calc",
61 "excel",
62 + "ods",
63 "sheet",
64 "spreadsheet",
65 "table",
@@ -63,6 +69,8 @@ SPREADSHEET_TERMS = {
69
70 PRESENTATION_TERMS = {
71 "deck",
72 + "impress",
73 + "odp",
74 "ppt",
75 "pptx",
76 "presentation",
@@ -89,6 +97,9 @@ EXPLICIT_FORMAT_TERMS = {
97 "docx",
98 "md",
99 "markdown",
100 + "odp",
101 + "ods",
102 + "odt",
103 "pptx",
104 "xlsx",
105 }
@@ -98,8 +109,10 @@ HANDOFF_TERMS = {
109 "artifacts",
110 "canvas",
111 "document canvas",
112 + "download",
113 "downloadable",
114 "editable",
115 + "export",
116 "open it",
117 "save it",
118 "save this",
@@ -119,6 +132,36 @@ CHAT_ONLY_TERMS = {
132 "no files",
133 }
134
135 +META_DISCUSSION_TERMS = {
136 + "affordance",
137 + "automatically",
138 + "auto",
139 + "disable",
140 + "issue",
141 + "less triggered",
142 + "problem",
143 + "speedbump",
144 + "stop",
145 + "trigger",
146 + "triggered",
147 + "why",
148 +}
149 +
150 +OOXML_COMPAT_TERMS = {
151 + "docx",
152 + "excel",
153 + "microsoft word",
154 + "powerpoint",
155 + "ppt",
156 + "pptx",
157 + "word",
158 + "xlsx",
159 +}
160 +
161 +ODF_DOCUMENT_TERMS = {"odt", "open document text", "opendocument text", "writer"}
162 +ODF_SPREADSHEET_TERMS = {"calc", "ods", "open document spreadsheet", "opendocument spreadsheet"}
163 +ODF_PRESENTATION_TERMS = {"impress", "odp", "open document presentation", "opendocument presentation"}
164 +
165 SKIP_RESPONSE_PREFIXES = (
166 "i can't",
167 "i cannot",
@@ -200,11 +243,17 @@ def normalize_text(value: str) -> str:
243
244 def infer_kind_and_format(lowered_user: str) -> tuple[str, str]:
245 if has_any(lowered_user, PRESENTATION_TERMS):
203 - return "presentation", "pptx"
246 + if has_any(lowered_user, {"powerpoint", "ppt", "pptx"}):
247 + return "presentation", "pptx"
248 + return "presentation", "odp"
249 if has_any(lowered_user, SPREADSHEET_TERMS):
205 - return "spreadsheet", "xlsx"
206 - if has_any(lowered_user, {"docx"}):
250 + if has_any(lowered_user, {"excel", "xlsx"}):
251 + return "spreadsheet", "xlsx"
252 + return "spreadsheet", "ods"
253 + if has_any(lowered_user, {"docx", "microsoft word", "word"}):
254 return "document", "docx"
255 + if has_any(lowered_user, ODF_DOCUMENT_TERMS):
256 + return "document", "odt"
257 return "document", "md"
258
259
@@ -213,8 +262,6 @@ def artifact_intent(lowered_user: str, response_text: str) -> str | None:
262 return None
263 if has_explicit_handoff_signal(lowered_user):
264 return "explicit_handoff"
216 - if has_any(lowered_user, DELIVERABLE_TERMS) and looks_like_standalone_artifact(response_text):
217 - return "document_intent"
265 return None
266
267
@@ -226,25 +273,42 @@ def has_document_creation_intent(lowered_user: str) -> bool:
273
274
275 def has_explicit_handoff_signal(lowered_user: str) -> bool:
229 - if has_any(lowered_user, EXPLICIT_FORMAT_TERMS | HANDOFF_TERMS):
230 - return True
231 - if has_any(lowered_user, FILE_HANDOFF_TERMS) and has_any(
232 - lowered_user,
233 - DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
234 - ):
276 + if looks_like_affordance_meta_discussion(lowered_user):
277 + return False
278 +
279 + creation = r"(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format|convert|turn|save|export)"
280 + handoff = r"(?:file|files|artifact|artifacts|canvas|download|downloadable|editable file|open in canvas)"
281 + format_name = (
282 + r"(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress|word|excel|"
283 + r"powerpoint|document|spreadsheet|workbook|presentation|deck|slides)"
284 + )
285 +
286 + if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b{handoff}\b", lowered_user):
287 return True
288 if re.search(
237 - r"\b(?:convert|format|save|turn)\b(?:\W+\w+){0,8}?\W+(?:as|to|into)\s+"
238 - r"(?:a|an|the)?\s*(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides|md|docx|xlsx|pptx)\b",
289 + rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+(?:as|to|into)\s+"
290 + rf"(?:a|an|the)?\s*{format_name}\b",
291 lowered_user,
292 ):
293 return True
242 - return bool(re.search(
243 - r"\b(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format)\b"
244 - r"(?:\s+(?:me|us|a|an|the|new|blank|editable|office|word|excel|powerpoint))*"
245 - r"\s+(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides)\b",
294 + if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress)\b", lowered_user):
295 + return True
296 + return bool(
297 + has_any(lowered_user, FILE_HANDOFF_TERMS | HANDOFF_TERMS)
298 + and has_any(
299 + lowered_user,
300 + EXPLICIT_FORMAT_TERMS | ODF_DOCUMENT_TERMS | ODF_SPREADSHEET_TERMS | ODF_PRESENTATION_TERMS | OOXML_COMPAT_TERMS,
301 + )
302 + )
303 +
304 +
305 +def looks_like_affordance_meta_discussion(lowered_user: str) -> bool:
306 + if not has_any(lowered_user, META_DISCUSSION_TERMS):
307 + return False
308 + return has_any(
309 lowered_user,
247 - ))
310 + DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS | EXPLICIT_FORMAT_TERMS | FILE_HANDOFF_TERMS,
311 + )
312
313
314 def has_any(text: str, terms: set[str]) -> bool:
plugins/_office/helpers/document_store.py
+245 -4
@@ -20,9 +20,25 @@ from plugins._office.helpers import pptx_writer
20
21
22 PLUGIN_NAME = "_office"
23 -SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"}
23 +OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"}
24 +OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"}
25 +SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS}
26 DEFAULT_TTL_SECONDS = 8 * 60 * 60
27 MAX_SAVE_BYTES = 512 * 1024 * 1024
28 +ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
29 +ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
30 +ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
31 +ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
32 +ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
33 +ODF_STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0"
34 +ODF_FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
35 +ODF_MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
36 +ODF_VERSION = "1.2"
37 +ODF_MIMETYPES = {
38 + "odt": "application/vnd.oasis.opendocument.text",
39 + "ods": "application/vnd.oasis.opendocument.spreadsheet",
40 + "odp": "application/vnd.oasis.opendocument.presentation",
41 +}
42
43 STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
44 DB_PATH = STATE_DIR / "documents.sqlite3"
@@ -58,8 +74,6 @@ def normalize_extension(value: str) -> str:
74 if not ext:
75 ext = "md"
76 if ext not in SUPPORTED_EXTENSIONS:
61 - if ext == "odt":
62 - raise ValueError("ODT editing is not supported in this migration. Use Markdown or DOCX.")
77 raise ValueError(f"Unsupported document format: {ext}")
78 return ext
79
@@ -599,7 +613,7 @@ def create_document(
613
614
615 def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
602 - base = safe_title(title, "Document")
616 + base = safe_document_stem(title, ext, "Document")
617 root = document_home(context_id) if ext == "md" else document_binary_home(context_id)
618 candidate = root / f"{base}.{ext}"
619 index = 2
@@ -609,10 +623,24 @@ def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
623 return candidate.resolve(strict=False)
624
625
626 +def safe_document_stem(title: str, ext: str, fallback: str = "Document") -> str:
627 + base = safe_title(title, fallback)
628 + suffix = f".{normalize_extension(ext)}"
629 + if base.casefold().endswith(suffix.casefold()):
630 + base = base[: -len(suffix)].rstrip(" ._") or fallback
631 + return base
632 +
633 +
634 def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
635 ext = normalize_extension(ext or "md")
636 if ext == "md":
637 return _markdown(title, content).encode("utf-8")
638 + if ext == "odt":
639 + return odt_bytes(title, content)
640 + if ext == "ods":
641 + return ods_bytes(title, content)
642 + if ext == "odp":
643 + return odp_bytes(title, content)
644 if ext == "docx":
645 return _docx(title, content)
646 if ext == "xlsx":
@@ -638,6 +666,219 @@ def _zip_bytes(files_map: dict[str, str | bytes]) -> bytes:
666 return buffer.getvalue()
667
668
669 +def odf_zip_bytes(ext: str, files_map: dict[str, str | bytes]) -> bytes:
670 + ext = normalize_extension(ext)
671 + if ext not in ODF_MIMETYPES:
672 + raise ValueError(f"Unsupported ODF format: {ext}")
673 + media_type = ODF_MIMETYPES[ext]
674 + buffer = io.BytesIO()
675 + with zipfile.ZipFile(buffer, "w") as archive:
676 + archive.writestr("mimetype", media_type, compress_type=zipfile.ZIP_STORED)
677 + for name, value in files_map.items():
678 + if name == "mimetype":
679 + continue
680 + data = value.encode("utf-8") if isinstance(value, str) else value
681 + archive.writestr(name, data, compress_type=zipfile.ZIP_DEFLATED)
682 + return buffer.getvalue()
683 +
684 +
685 +def odt_bytes(title: str, content: str) -> bytes:
686 + return odt_bytes_from_paragraphs(_document_lines(title, content))
687 +
688 +
689 +def odt_bytes_from_paragraphs(paragraphs: list[str]) -> bytes:
690 + lines = [str(line) for line in paragraphs] or [""]
691 + body = "\n".join(_odt_paragraph(line, index == 0) for index, line in enumerate(lines))
692 + return _odf_package(
693 + "odt",
694 + f"""<?xml version="1.0" encoding="UTF-8"?>
695 +<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
696 + <office:body>
697 + <office:text>
698 + {body}
699 + </office:text>
700 + </office:body>
701 +</office:document-content>
702 +""",
703 + )
704 +
705 +
706 +def ods_bytes(title: str, content: str) -> bytes:
707 + return ods_bytes_from_sheets([{"name": "Sheet1", "rows": _xlsx_rows(title, content)}])
708 +
709 +
710 +def ods_bytes_from_sheets(sheets: list[dict[str, Any]]) -> bytes:
711 + normalized = []
712 + for index, sheet in enumerate(sheets or []):
713 + name = safe_title(str(sheet.get("name") or f"Sheet{index + 1}"), f"Sheet{index + 1}")[:31] or f"Sheet{index + 1}"
714 + rows = sheet.get("rows") or []
715 + normalized.append({"name": name, "rows": rows})
716 + if not normalized:
717 + normalized = [{"name": "Sheet1", "rows": [["Spreadsheet"]]}]
718 +
719 + tables = "\n".join(
720 + f"""<table:table table:name="{escape(sheet['name'])}">
721 + {''.join(_ods_row(row) for row in sheet['rows'])}
722 + </table:table>"""
723 + for sheet in normalized
724 + )
725 + return _odf_package(
726 + "ods",
727 + f"""<?xml version="1.0" encoding="UTF-8"?>
728 +<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
729 + <office:body>
730 + <office:spreadsheet>
731 + {tables}
732 + </office:spreadsheet>
733 + </office:body>
734 +</office:document-content>
735 +""",
736 + )
737 +
738 +
739 +def odp_bytes(title: str, content: str) -> bytes:
740 + return odp_bytes_from_slides(pptx_writer.slides_from_text(title, content))
741 +
742 +
743 +def odp_bytes_from_slides(slides: list[dict[str, Any]]) -> bytes:
744 + normalized = pptx_writer.normalize_slides(slides)
745 + if not normalized:
746 + normalized = [{"title": "Presentation", "bullets": []}]
747 + pages = "\n".join(_odp_page(slide, index) for index, slide in enumerate(normalized, start=1))
748 + return _odf_package(
749 + "odp",
750 + f"""<?xml version="1.0" encoding="UTF-8"?>
751 +<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
752 + <office:body>
753 + <office:presentation>
754 + {pages}
755 + </office:presentation>
756 + </office:body>
757 +</office:document-content>
758 +""",
759 + )
760 +
761 +
762 +def _document_lines(title: str, content: str) -> list[str]:
763 + lines = [str(title or "Document").strip() or "Document"]
764 + lines.extend(line.rstrip() for line in str(content or "").splitlines() if line.strip())
765 + if len(lines) == 1:
766 + lines.append("")
767 + return lines
768 +
769 +
770 +def _odf_package(ext: str, content_xml: str) -> bytes:
771 + return odf_zip_bytes(
772 + ext,
773 + {
774 + "content.xml": content_xml,
775 + "styles.xml": _odf_styles_xml(),
776 + "meta.xml": _odf_meta_xml(),
777 + "settings.xml": _odf_settings_xml(),
778 + "META-INF/manifest.xml": _odf_manifest_xml(ODF_MIMETYPES[ext]),
779 + },
780 + )
781 +
782 +
783 +def _odf_content_namespaces() -> str:
784 + return (
785 + f'xmlns:office="{ODF_OFFICE_NS}" '
786 + f'xmlns:text="{ODF_TEXT_NS}" '
787 + f'xmlns:table="{ODF_TABLE_NS}" '
788 + f'xmlns:draw="{ODF_DRAW_NS}" '
789 + f'xmlns:presentation="{ODF_PRESENTATION_NS}" '
790 + f'xmlns:style="{ODF_STYLE_NS}" '
791 + f'xmlns:fo="{ODF_FO_NS}"'
792 + )
793 +
794 +
795 +def _odf_styles_xml() -> str:
796 + return f"""<?xml version="1.0" encoding="UTF-8"?>
797 +<office:document-styles {_odf_content_namespaces()} office:version="{ODF_VERSION}">
798 + <office:styles>
799 + <style:style style:name="Standard" style:family="paragraph"/>
800 + <style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">
801 + <style:text-properties fo:font-weight="bold" fo:font-size="18pt"/>
802 + </style:style>
803 + </office:styles>
804 +</office:document-styles>
805 +"""
806 +
807 +
808 +def _odf_meta_xml() -> str:
809 + return f"""<?xml version="1.0" encoding="UTF-8"?>
810 +<office:document-meta xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}">
811 + <office:meta/>
812 +</office:document-meta>
813 +"""
814 +
815 +
816 +def _odf_settings_xml() -> str:
817 + return f"""<?xml version="1.0" encoding="UTF-8"?>
818 +<office:document-settings xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}">
819 + <office:settings/>
820 +</office:document-settings>
821 +"""
822 +
823 +
824 +def _odf_manifest_xml(media_type: str) -> str:
825 + return f"""<?xml version="1.0" encoding="UTF-8"?>
826 +<manifest:manifest xmlns:manifest="{ODF_MANIFEST_NS}" manifest:version="{ODF_VERSION}">
827 + <manifest:file-entry manifest:full-path="/" manifest:media-type="{media_type}"/>
828 + <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
829 + <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
830 + <manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
831 + <manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>
832 +</manifest:manifest>
833 +"""
834 +
835 +
836 +def _odt_paragraph(line: str, heading: bool = False) -> str:
837 + text = escape(str(line))
838 + if heading:
839 + return f'<text:h text:outline-level="1">{text}</text:h>'
840 + return f"<text:p>{text}</text:p>"
841 +
842 +
843 +def _ods_row(row: list[Any]) -> str:
844 + cells = "".join(_ods_cell(value) for value in row)
845 + return f"<table:table-row>{cells}</table:table-row>"
846 +
847 +
848 +def _ods_cell(value: Any) -> str:
849 + value = _xlsx_value(value)
850 + if value in (None, ""):
851 + return "<table:table-cell/>"
852 + if isinstance(value, bool):
853 + text = "TRUE" if value else "FALSE"
854 + return (
855 + f'<table:table-cell office:value-type="boolean" office:boolean-value="{str(value).lower()}">'
856 + f"<text:p>{text}</text:p></table:table-cell>"
857 + )
858 + if isinstance(value, (int, float)):
859 + return (
860 + f'<table:table-cell office:value-type="float" office:value="{value}">'
861 + f"<text:p>{value}</text:p></table:table-cell>"
862 + )
863 + text = escape(str(value))
864 + return f'<table:table-cell office:value-type="string"><text:p>{text}</text:p></table:table-cell>'
865 +
866 +
867 +def _odp_page(slide: dict[str, Any], index: int) -> str:
868 + title = escape(str(slide.get("title") or f"Slide {index}"))
869 + bullets = [escape(str(item)) for item in slide.get("bullets") or []]
870 + bullet_items = "".join(f"<text:list-item><text:p>{bullet}</text:p></text:list-item>" for bullet in bullets)
871 + body = f"<text:list>{bullet_items}</text:list>" if bullet_items else "<text:p/>"
872 + return f"""<draw:page draw:name="Slide {index}" draw:master-page-name="Default">
873 + <draw:frame presentation:class="title" draw:name="Title {index}" svg:width="24cm" svg:height="2cm" svg:x="1.5cm" svg:y="1cm" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0">
874 + <draw:text-box><text:p>{title}</text:p></draw:text-box>
875 + </draw:frame>
876 + <draw:frame presentation:class="outline" draw:name="Content {index}" svg:width="24cm" svg:height="12cm" svg:x="1.5cm" svg:y="3.5cm" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0">
877 + <draw:text-box>{body}</draw:text-box>
878 + </draw:frame>
879 +</draw:page>"""
880 +
881 +
882 def _docx(title: str, content: str) -> bytes:
883 lines = [title] + [line for line in content.splitlines() if line.strip()]
884 if len(lines) == 1:
plugins/_office/helpers/libreoffice.py
+28
@@ -11,6 +11,11 @@ from typing import Any
11
12 SOFFICE_BINARIES = ("soffice", "libreoffice")
13 CONVERT_TIMEOUT_SECONDS = 45
14 +ODF_MIMETYPES = {
15 + "odt": "application/vnd.oasis.opendocument.text",
16 + "ods": "application/vnd.oasis.opendocument.spreadsheet",
17 + "odp": "application/vnd.oasis.opendocument.presentation",
18 +}
19
20
21 def find_soffice() -> str:
@@ -73,6 +78,29 @@ def validate_docx(path: str | Path) -> dict[str, Any]:
78 return {"ok": True}
79
80
81 +def validate_odf(path: str | Path) -> dict[str, Any]:
82 + source = Path(path)
83 + if not source.exists():
84 + return {"ok": False, "error": f"File not found: {source}"}
85 + ext = source.suffix.lower().lstrip(".")
86 + expected_mimetype = ODF_MIMETYPES.get(ext)
87 + if not expected_mimetype:
88 + return {"ok": False, "error": f"Unsupported ODF extension: {ext}"}
89 + try:
90 + with zipfile.ZipFile(source) as archive:
91 + first = archive.infolist()[0]
92 + mimetype = archive.read("mimetype").decode("utf-8")
93 + archive.getinfo("content.xml")
94 + archive.getinfo("META-INF/manifest.xml")
95 + except Exception as exc:
96 + return {"ok": False, "error": f"ODF package validation failed: {exc}"}
97 + if first.filename != "mimetype" or first.compress_type != zipfile.ZIP_STORED:
98 + return {"ok": False, "error": "ODF mimetype must be the first uncompressed package entry."}
99 + if mimetype != expected_mimetype:
100 + return {"ok": False, "error": f"ODF mimetype mismatch: expected {expected_mimetype}, got {mimetype}"}
101 + return {"ok": True}
102 +
103 +
104 def convert_document(path: str | Path, target_format: str, output_dir: str | Path | None = None) -> dict[str, Any]:
105 source = Path(path)
106 if not source.exists():
plugins/_office/helpers/libreoffice_desktop.py
+1 -1
@@ -20,7 +20,7 @@ from helpers import files, virtual_desktop
20 from plugins._office.helpers import document_store, libreoffice
21
22
23 -OFFICIAL_EXTENSIONS = {"docx", "xlsx", "pptx"}
23 +OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
24 SYSTEM_SESSION_ID = "agent-zero-desktop"
25 SYSTEM_FILE_ID = "system-desktop"
26 SYSTEM_TITLE = "Desktop"
plugins/_office/plugin.yaml
+1 -1
@@ -1,6 +1,6 @@
1 name: _office
2 title: LibreOffice
3 -description: Markdown-first writing and LibreOffice-backed document artifacts in the right canvas.
3 +description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas.
4 version: "0.1"
5 settings_sections:
6 - developer
plugins/_office/prompts/agent.system.tool.document_artifact.md
+6 -5
@@ -1,14 +1,15 @@
1 ### document_artifact
2 create/open/read/edit reusable document artifacts in the Agent Zero canvas
3 -formats: md docx xlsx pptx
3 +formats: md odt ods odp docx xlsx pptx
4 default format: md
5 methods: create open read edit inspect export version_history restore_version status
6 common args: method action kind title format content path file_id
7 `method` is accepted as an alias for action when the tool_name has no suffix
8 tool results save or update artifacts only; they do not open the canvas automatically
9 created/updated artifacts are shown with explicit Download and Open in canvas message actions
10 -XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts
10 +ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests Microsoft compatibility
11 +DOCX/XLSX/PPTX are compatibility formats, not defaults
12 +XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
13 chart types: line bar column pie area scatter stock ohlc candlestick
12 -XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
13 -ODT/ODS/ODP editing is intentionally unsupported in this migration
14 -for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Word/Excel/Presentation skill first
14 +ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
15 +for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
plugins/_office/skills/excel-workbooks/SKILL.md
+13 -9
@@ -1,10 +1,14 @@
1 ---
2 name: excel-workbooks
3 -description: Use when creating, opening, or editing Excel-compatible XLSX spreadsheets, workbooks, tables, budgets, formulas, sheets, or charts.
4 -version: "1.0.0"
3 +description: Use when creating, opening, or editing LibreOffice Calc ODS spreadsheets, or XLSX workbooks only when Excel compatibility is explicitly required.
4 +version: "1.1.0"
5 author: "Agent Zero Core Team"
6 -tags: ["excel", "xlsx", "spreadsheet", "workbook", "calc", "tables", "charts", "budget"]
6 +tags: ["calc", "ods", "opendocument", "excel", "xlsx", "spreadsheet", "workbook", "tables", "charts", "budget"]
7 triggers:
8 + - "Calc"
9 + - "ODS"
10 + - "ods"
11 + - "OpenDocument Spreadsheet"
12 - "Excel"
13 - "XLSX"
14 - "xlsx"
@@ -17,11 +21,11 @@ allowed_tools:
21 - document_artifact
22 ---
23
20 -# Excel Workbooks
24 +# Calc Spreadsheets
25
22 -Use XLSX when the user asks for Excel, a spreadsheet, a workbook, tables that should remain editable as cells, formulas, or embedded spreadsheet charts.
26 +Use ODS when the user asks for a spreadsheet, workbook, editable table, budget, formulas, or Calc file. Use XLSX only when the user asks for Excel/XLSX compatibility, provides an existing `.xlsx`, or needs embedded spreadsheet charts supported by the tool.
27
24 -The canvas is user-owned UI. Creating or editing an XLSX must save the workbook and return action buttons, but must not open the canvas automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation.
28 +The canvas is user-owned UI. Creating or editing an ODS or XLSX must save the workbook and return action buttons, but must not open the canvas automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation.
29
30 ## Workflow
31
@@ -33,13 +37,13 @@ Create a workbook:
37 "tool_args": {
38 "kind": "spreadsheet",
39 "title": "Budget",
36 - "format": "xlsx",
40 + "format": "ods",
41 "content": "Item,Amount\nPlatform,1000"
42 }
43 }
44 ```
45
42 -For a blank workbook request, create a simple workbook with the requested title and `format: "xlsx"`; do not call `status` first unless the user asked for availability.
46 +For a blank workbook request, create a simple workbook with the requested title and `format: "ods"`; do not call `status` first unless the user asked for availability.
47
48 Edit cells:
49
@@ -61,5 +65,5 @@ Practical rules:
65
66 - `content` may be CSV, TSV, or a Markdown table; the tool writes real spreadsheet cells.
67 - Use `rows` for whole-table replacement, `append_rows` for adding records, and `set_cells` for precise edits.
64 -- Use `create_chart` with a chart object for embedded charts before reaching for code execution.
68 +- Use `create_chart` with a chart object for embedded charts when working in XLSX compatibility format; otherwise use Calc/Desktop or code execution for chart workflows that ODS direct editing does not yet cover.
69 - Do not open Calc/canvas automatically. The user can choose Open in canvas when they want the visible spreadsheet.
plugins/_office/skills/linux-desktop/SKILL.md
+10 -9
@@ -23,13 +23,14 @@ Use the Desktop as a full Linux GUI when the user explicitly needs a visual work
23
24 ## Operating Model
25
26 -1. Prefer `document_artifact` for creating, reading, and editing Markdown, DOCX, XLSX, and PPTX files.
26 +1. Prefer `document_artifact` for creating, reading, and editing Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX files.
27 2. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
28 -3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
29 -4. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
30 -5. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
31 -6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
32 -7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
28 +3. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit Microsoft compatibility.
29 +4. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
30 +5. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
31 +6. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
32 +7. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
33 +8. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
34
35 ## Control Flow
36
@@ -95,9 +96,9 @@ Use these folders when the user asks to inspect or manipulate project files, ski
96
97 ## App Map
98
98 -- `LibreOffice Writer`: word processing and DOCX layout.
99 -- `LibreOffice Calc`: spreadsheets, formulas, tables, charts.
100 -- `LibreOffice Impress`: presentations and slide polish.
99 +- `LibreOffice Writer`: ODT word processing and DOCX compatibility layout.
100 +- `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility.
101 +- `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility.
102 - `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`).
103 - `Terminal`: shell work inside the Agent Zero runtime.
104 - `Settings`: XFCE system settings.
plugins/_office/skills/markdown-documents/SKILL.md
+2 -2
@@ -18,7 +18,7 @@ allowed_tools:
18
19 # Markdown Documents
20
21 -Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for DOCX, XLSX, PPTX, or another binary Office-compatible format.
21 +Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for a binary office file. When they do ask for a LibreOffice office file, prefer ODF: ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only for explicit Microsoft compatibility.
22
23 The canvas is user-owned UI. Create or update the saved Markdown artifact, but never open the canvas automatically. The document message will provide explicit Download and Open in canvas actions.
24
@@ -45,7 +45,7 @@ Minimal create:
45
46 Practical rules:
47
48 -- Prefer Markdown over DOCX for writing unless Word compatibility is explicitly needed.
48 +- Prefer Markdown over ODT/DOCX for writing unless a binary Writer/Word file is explicitly needed.
49 - Keep agent-only cleanup simple: if the user asks to fix a typo, update the file and finish; do not force a canvas workflow.
50 - Use clear headings and Markdown tables when they improve editability.
51 - The custom Markdown editor is available when the user chooses Open in canvas.
plugins/_office/skills/office-artifacts/SKILL.md
+20 -15
@@ -1,17 +1,21 @@
1 ---
2 name: office-artifacts
3 -description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool.
4 -version: "1.3.0"
3 +description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, LibreOffice-native ODT/ODS/ODP files, and compatibility DOCX/XLSX/PPTX files with the document_artifact tool.
4 +version: "1.4.0"
5 author: "Agent Zero Core Team"
6 -tags: ["documents", "markdown", "md", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations"]
6 +tags: ["documents", "markdown", "md", "odt", "ods", "odp", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations", "libreoffice", "opendocument"]
7 triggers:
8 - "document canvas"
9 - "markdown document"
10 - "editable document"
11 - "md"
12 + - "odt"
13 + - "ods"
14 + - "odp"
15 - "docx"
16 - "xlsx"
17 - "pptx"
18 + - "writer"
19 - "spreadsheet"
20 - "presentation"
21 allowed_tools:
@@ -20,16 +24,16 @@ allowed_tools:
24
25 # Document Artifacts
26
23 -Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas. Markdown is the first-class document format and the default for writing, notes, reports, briefs, and drafts. Use DOCX, XLSX, or PPTX only when the user explicitly asks for that binary format, provides an existing file in that format, or needs a Word/Excel/PowerPoint-compatible artifact.
27 +Use `document_artifact` for substantial deliverables that should remain editable in the custom document canvas or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for Microsoft compatibility, provides an existing file in that format, or needs that compatibility surface.
28
29 The canvas is user-owned UI. Creating, reading, or editing an artifact must save the file and update its state, but it must not open the canvas automatically if the user has not opened it. Tool results provide explicit Download and Open in canvas actions for the user.
30
31 For format-specific work, prefer the matching skill when available:
32
33 - `markdown-documents` for Markdown-first editable writing.
30 -- `word-documents` for DOCX/Word-compatible files.
31 -- `excel-workbooks` for XLSX/Excel-compatible spreadsheets.
32 -- `presentation-decks` for PPTX/PowerPoint-compatible decks.
34 +- `word-documents` for Writer/ODT files and DOCX compatibility files.
35 +- `excel-workbooks` for Calc/ODS spreadsheets and XLSX compatibility workbooks.
36 +- `presentation-decks` for Impress/ODP decks and PPTX compatibility decks.
37
38 ## Workflow
39
@@ -67,7 +71,7 @@ Read:
71 }
72 ```
73
70 -Edit text in a Markdown, DOCX, or PPTX file:
74 +Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
75 ```json
76 {
77 "tool_name": "document_artifact:edit",
@@ -85,7 +89,7 @@ Set spreadsheet cells:
89 {
90 "tool_name": "document_artifact:edit",
91 "tool_args": {
88 - "path": "/a0/usr/workdir/documents/Budget.xlsx",
92 + "path": "/a0/usr/workdir/documents/Budget.ods",
93 "operation": "set_cells",
94 "cells": {
95 "Sheet1!B2": 12500,
@@ -118,16 +122,17 @@ Create an embedded spreadsheet chart:
122
123 ## Edit Operations
124
121 -- MD and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
122 -- XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`.
123 -- PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
125 +- MD, ODT, and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
126 +- ODS and XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
127 +- XLSX only: `create_chart` for embedded spreadsheet charts.
128 +- ODP and PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
129
130 Arguments:
131
132 - `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
133 - `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
134 - `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
130 -- `create_chart` accepts `chart` as an object or JSON string. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`.
135 +- `create_chart` accepts `chart` as an object or JSON string for XLSX compatibility workbooks. Supported XLSX chart types: `line`, `bar`, `column`, `pie`, `area`, `scatter`, `stock`, `ohlc`, `candlestick`. Use `data_range`, `categories`/`labels`, `position`, `title`, `width`, and `height`. For stock-style charts only, provide Open/High/Low/Close columns in that order, or rely on a sheet whose headers are `Date, Open, High, Low, Close`.
136 - `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`.
137 - `count` limits text replacements.
138
@@ -136,10 +141,10 @@ Arguments:
141 - Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
142 - Use `read` before editing unless the current saved content is already known.
143 - Do not create an artifact for tiny one-shot edits or answers the agent can finish cleanly in chat or by directly editing the file.
139 -- For document-style requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
144 +- For document-style writing requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
145 +- For spreadsheet or presentation file requests with no Microsoft compatibility requirement, create ODS or ODP.
146 - The Desktop runtime may be warmed during Agent Zero startup, but visible Desktop/canvas use remains opt-in. Treat LibreOffice GUI work as appropriate for explicit GUI requests, binary Office visual polish, or final layout inspection.
147 - Never open the canvas automatically from a tool result. If the user has not opened the canvas, leave the saved artifact available through the normal UI affordance.
142 -- Do not create ODT, ODS, or ODP in this pass; return a clear unsupported response if asked.
148 - Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
149 - Use `edit` for precise saved changes; use the visual document canvas for human/manual layout polish.
150 - Direct edits update version history and refresh the canvas on edit/open results.
plugins/_office/skills/presentation-decks/SKILL.md
+12 -8
@@ -1,10 +1,14 @@
1 ---
2 name: presentation-decks
3 -description: Use when creating, opening, or editing PowerPoint-compatible PPTX presentations, slide decks, talks, briefing decks, or LibreOffice Impress files.
4 -version: "1.0.0"
3 +description: Use when creating, opening, or editing LibreOffice Impress ODP presentations, or PPTX decks only when PowerPoint compatibility is explicitly required.
4 +version: "1.1.0"
5 author: "Agent Zero Core Team"
6 -tags: ["presentation", "pptx", "powerpoint", "slides", "deck", "impress"]
6 +tags: ["presentation", "odp", "opendocument", "pptx", "powerpoint", "slides", "deck", "impress"]
7 triggers:
8 + - "Impress"
9 + - "ODP"
10 + - "odp"
11 + - "OpenDocument Presentation"
12 - "PowerPoint"
13 - "PPTX"
14 - "pptx"
@@ -17,11 +21,11 @@ allowed_tools:
21 - document_artifact
22 ---
23
20 -# Presentation Decks
24 +# Impress Presentations
25
22 -Use PPTX when the user asks for PowerPoint, a presentation, slides, a deck, or an Impress-compatible artifact.
26 +Use ODP when the user asks for a presentation, slides, a deck, or an Impress artifact. Use PPTX only when the user asks for PowerPoint/PPTX compatibility or provides an existing `.pptx`.
27
24 -The canvas is user-owned UI. Creating or editing a PPTX must save the deck and return action buttons, but must not open the canvas automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation.
28 +The canvas is user-owned UI. Creating or editing an ODP or PPTX must save the deck and return action buttons, but must not open the canvas automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation.
29
30 ## Workflow
31
@@ -33,7 +37,7 @@ Create:
37 "tool_args": {
38 "kind": "presentation",
39 "title": "Roadmap",
36 - "format": "pptx",
40 + "format": "odp",
41 "content": "Title Slide\n\n---\n\nNext Steps"
42 }
43 }
@@ -59,5 +63,5 @@ Practical rules:
63
64 - Use `slides` arrays for structured decks and `---` separators for simple text-to-slide creation.
65 - Keep slide text concise and scannable.
62 -- Do not create ODP in this workflow.
66 +- Treat PPTX as a compatibility export/request, not the default presentation format.
67 - Do not open Impress/canvas automatically. The user can choose Open in canvas when they want to inspect or polish the deck visually.
plugins/_office/skills/word-documents/SKILL.md
+13 -9
@@ -1,10 +1,14 @@
1 ---
2 name: word-documents
3 -description: Use when creating, opening, or editing Word-compatible DOCX documents, including requests for Word files, DOCX reports, memos, contracts, resumes, or documents that must work in Microsoft Word or LibreOffice Writer.
4 -version: "1.0.0"
3 +description: Use when creating, opening, or editing LibreOffice Writer ODT documents, or DOCX documents only when Microsoft Word compatibility is explicitly required.
4 +version: "1.1.0"
5 author: "Agent Zero Core Team"
6 -tags: ["word", "docx", "writer", "documents", "reports", "memos", "contracts"]
6 +tags: ["writer", "odt", "opendocument", "word", "docx", "documents", "reports", "memos", "contracts"]
7 triggers:
8 + - "Writer"
9 + - "ODT"
10 + - "odt"
11 + - "OpenDocument Text"
12 - "Word"
13 - "DOCX"
14 - "docx"
@@ -15,11 +19,11 @@ allowed_tools:
19 - document_artifact
20 ---
21
18 -# Word Documents
22 +# Writer Documents
23
20 -Use DOCX only when the user explicitly asks for Word/DOCX compatibility, provides an existing `.docx`, or needs a binary Office file. For ordinary writing with no binary requirement, use Markdown instead.
24 +Use ODT for LibreOffice Writer documents. Use DOCX only when the user explicitly asks for Word/DOCX/Microsoft compatibility, provides an existing `.docx`, or needs that compatibility format. For ordinary writing with no binary requirement, use Markdown instead.
25
22 -The canvas is user-owned UI. Creating or editing a DOCX must save the file and return action buttons, but must not open the canvas automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation.
26 +The canvas is user-owned UI. Creating or editing an ODT or DOCX must save the file and return action buttons, but must not open the canvas automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation.
27
28 ## Workflow
29
@@ -31,7 +35,7 @@ Create:
35 "tool_args": {
36 "kind": "document",
37 "title": "Board Memo",
34 - "format": "docx",
38 + "format": "odt",
39 "content": "Memo body text."
40 }
41 }
@@ -45,6 +49,6 @@ Edit:
49
50 Practical rules:
51
48 -- Keep DOCX content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested.
49 -- Do not create ODT in this workflow.
52 +- Keep Writer content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested.
53 +- Treat DOCX as a compatibility export/request, not the default Writer format.
54 - Do not say the document is open. Say it was created or updated, and rely on the Open in canvas action for user-controlled viewing.
plugins/_office/tools/document_artifact.py
+7
@@ -42,6 +42,13 @@ class DocumentArtifact(Tool):
42 path=path,
43 context_id=self._context_id(),
44 )
45 + if doc["extension"] in {"odt", "ods", "odp"}:
46 + validation = libreoffice.validate_odf(doc["path"])
47 + if not validation.get("ok"):
48 + return Response(
49 + message=f"document_artifact create failed: {validation.get('error')}",
50 + break_loop=False,
51 + )
52 if doc["extension"] == "docx":
53 validation = libreoffice.validate_docx(doc["path"])
54 if not validation.get("ok"):
plugins/_office/webui/office-panel.html
+4 -4
@@ -21,15 +21,15 @@
21 <span class="material-symbols-outlined">article</span>
22 <span class="office-button-label">Markdown</span>
23 </button>
24 - <button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
24 + <button type="button" class="office-icon-button office-command-button" aria-label="New Writer document" @click="$store.office.create('document', 'odt')">
25 <span class="material-symbols-outlined">description</span>
26 - <span class="office-button-label">DOCX</span>
26 + <span class="office-button-label">Writer</span>
27 </button>
28 - <button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
28 + <button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'ods')">
29 <span class="material-symbols-outlined">table_chart</span>
30 <span class="office-button-label">Spreadsheet</span>
31 </button>
32 - <button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
32 + <button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'odp')">
33 <span class="material-symbols-outlined">co_present</span>
34 <span class="office-button-label">Presentation</span>
35 </button>
plugins/_office/webui/office-store.js
+6 -5
@@ -360,7 +360,7 @@ const model = {
360 },
361
362 async create(kind = "document", format = "") {
363 - const fmt = String(format || (kind === "spreadsheet" ? "xlsx" : kind === "presentation" ? "pptx" : "md")).toLowerCase();
363 + const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
364 const title = this.defaultTitle(kind, fmt);
365 await this.openSession({
366 action: "create",
@@ -831,7 +831,7 @@ const model = {
831
832 isBinaryOffice(tab = this.session) {
833 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
834 - return ext === "docx" || ext === "xlsx" || ext === "pptx";
834 + return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
835 },
836
837 hasOfficialOffice(tab = this.session) {
@@ -1872,6 +1872,7 @@ const model = {
1872 defaultTitle(kind, fmt) {
1873 const date = new Date().toISOString().slice(0, 10);
1874 if (fmt === "md") return `Document ${date}`;
1875 + if (fmt === "odt") return `Writer ${date}`;
1876 if (fmt === "docx") return `DOCX ${date}`;
1877 if (kind === "spreadsheet") return `Spreadsheet ${date}`;
1878 if (kind === "presentation") return `Presentation ${date}`;
@@ -1891,9 +1892,9 @@ const model = {
1892 const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
1893 if (this.isDesktopSession(tab)) return "desktop_windows";
1894 if (ext === "md") return "article";
1894 - if (ext === "docx") return "description";
1895 - if (ext === "xlsx") return "table_chart";
1896 - if (ext === "pptx") return "co_present";
1895 + if (ext === "odt" || ext === "docx") return "description";
1896 + if (ext === "ods" || ext === "xlsx") return "table_chart";
1897 + if (ext === "odp" || ext === "pptx") return "co_present";
1898 return "draft";
1899 },
1900
tests/test_office_canvas_setup.py
+11 -4
@@ -119,6 +119,11 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
119 assert "setupTitle()" not in panel
120 assert "Setup in progress" not in store
121 assert "office-log" not in panel
122 + assert "New Writer document" in panel
123 + assert "DOCX</span>" not in panel
124 + assert "$store.office.create('document', 'odt')" in panel
125 + assert "$store.office.create('spreadsheet', 'ods')" in panel
126 + assert "$store.office.create('presentation', 'odp')" in panel
127
128
129 def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
@@ -407,7 +412,8 @@ def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
412 PROJECT_ROOT / "plugins" / "_office" / "skills" / "presentation-decks" / "SKILL.md"
413 ).read_text(encoding="utf-8")
414
410 - assert "Markdown is the first-class document format" in office_skill
415 + assert "ODF is first-class" in office_skill
416 + assert "DOCX, XLSX, or PPTX only" in office_skill
417 assert "custom document canvas" in office_skill
418 assert "must not open the canvas automatically" in office_skill
419 assert "Download and Open in canvas actions" in office_skill
@@ -418,10 +424,11 @@ def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
424 assert "persistent Desktop runtime during initial startup" in desktop_skill
425 assert '"format": "md"' in markdown_skill
426 assert "never open the canvas automatically" in markdown_skill
421 - assert '"format": "docx"' in word_skill
427 + assert '"format": "odt"' in word_skill
428 + assert "DOCX only" in word_skill
429 assert "must not open the canvas automatically" in word_skill
423 - assert '"format": "xlsx"' in excel_skill
430 + assert '"format": "ods"' in excel_skill
431 assert "For a blank workbook request" in excel_skill
432 assert "must not open the canvas automatically" in excel_skill
426 - assert '"format": "pptx"' in presentation_skill
433 + assert '"format": "odp"' in presentation_skill
434 assert "must not open the canvas automatically" in presentation_skill
tests/test_office_document_affordance.py
+33 -4
@@ -53,10 +53,32 @@ def test_explicit_spreadsheet_file_request_creates_spreadsheet_artifact():
53
54 assert decision is not None
55 assert decision.kind == "spreadsheet"
56 - assert decision.fmt == "xlsx"
56 + assert decision.fmt == "ods"
57 assert decision.reason == "explicit_handoff"
58
59
60 +def test_explicit_excel_request_keeps_xlsx_compatibility_format():
61 + decision = document_affordance.decide_response_artifact(
62 + "Build an editable Excel XLSX file for this budget.",
63 + substantial_text(),
64 + )
65 +
66 + assert decision is not None
67 + assert decision.kind == "spreadsheet"
68 + assert decision.fmt == "xlsx"
69 +
70 +
71 +def test_explicit_presentation_file_request_uses_odp_by_default():
72 + decision = document_affordance.decide_response_artifact(
73 + "Create a presentation file for this roadmap.",
74 + substantial_text(),
75 + )
76 +
77 + assert decision is not None
78 + assert decision.kind == "presentation"
79 + assert decision.fmt == "odp"
80 +
81 +
82 def test_convert_into_document_creates_document_artifact():
83 decision = document_affordance.decide_response_artifact(
84 "Convert this into a document.",
@@ -111,9 +133,16 @@ def test_deliverable_request_with_artifact_shape_creates_document_artifact():
133 standalone_report(),
134 )
135
114 - assert decision is not None
115 - assert decision.kind == "document"
116 - assert decision.reason == "document_intent"
136 + assert decision is None
137 +
138 +
139 +def test_meta_discussion_about_auto_md_files_does_not_create_artifact():
140 + decision = document_affordance.decide_response_artifact(
141 + "Why are .md files being created automatically by the document affordance?",
142 + standalone_report(),
143 + )
144 +
145 + assert decision is None
146
147
148 def test_chat_only_instruction_blocks_even_explicit_file_request():
tests/test_office_document_store.py
+127 -18
@@ -72,6 +72,21 @@ def test_document_artifact_create_defaults_to_markdown(office_state):
72 assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note")
73
74
75 +@pytest.mark.parametrize(
76 + ("kind", "title", "fmt", "expected_name"),
77 + [
78 + ("document", "real-chat-canvas-smoke.md", "md", "real-chat-canvas-smoke.md"),
79 + ("document", "Board Memo.ODT", "odt", "Board Memo.odt"),
80 + ("spreadsheet", "Budget.ods", "ods", "Budget.ods"),
81 + ("presentation", "Roadmap.odp", "odp", "Roadmap.odp"),
82 + ],
83 +)
84 +def test_create_document_does_not_duplicate_matching_extension(office_state, kind, title, fmt, expected_name):
85 + doc = document_store.create_document(kind, title, fmt, content="Smoke")
86 +
87 + assert Path(doc["path"]).name == expected_name
88 +
89 +
90 def test_explicit_docx_creates_valid_word_package(office_state):
91 doc = document_store.create_document("document", "Board Memo", "docx", "A careful memo.")
92
@@ -82,6 +97,23 @@ def test_explicit_docx_creates_valid_word_package(office_state):
97 assert "word/document.xml" in archive.namelist()
98
99
100 +def test_odf_formats_create_valid_libreoffice_packages(office_state):
101 + writer = document_store.create_document("document", "Board Memo", "odt", "A careful memo.")
102 + sheet = document_store.create_document("spreadsheet", "Budget", "ods", "Name,Amount\nPlatform,1000")
103 + deck = document_store.create_document("presentation", "Roadmap", "odp", "Roadmap\nLaunch sequence")
104 +
105 + assert writer["extension"] == "odt"
106 + assert sheet["extension"] == "ods"
107 + assert deck["extension"] == "odp"
108 + assert Path(writer["path"]).parent == office_state.documents
109 + assert libreoffice.validate_odf(writer["path"])["ok"] is True
110 + assert libreoffice.validate_odf(sheet["path"])["ok"] is True
111 + assert libreoffice.validate_odf(deck["path"])["ok"] is True
112 + assert artifact_editor.read_artifact(writer)["text"].startswith("Board Memo")
113 + assert artifact_editor.read_artifact(sheet)["sheets"][0]["preview_rows"][1][1] == 1000
114 + assert artifact_editor.read_artifact(deck)["slides"][0]["title"] == "Roadmap"
115 +
116 +
117 def test_blank_docx_includes_editable_body_paragraph(office_state):
118 doc = document_store.create_document("document", "Blank Memo", "docx", "")
119 with zipfile.ZipFile(doc["path"]) as archive:
@@ -93,7 +125,57 @@ def test_blank_docx_includes_editable_body_paragraph(office_state):
125 assert 'xml:space="preserve">&#160;</w:t>' in xml
126
127
96 -def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state):
128 +def test_odf_and_ooxml_creation_and_direct_edits_still_work(office_state):
129 + odt = document_store.create_document("document", "Writer Memo", "odt", "Old phrase")
130 + updated_odt, odt_payload = artifact_editor.edit_artifact(
131 + odt,
132 + operation="replace_text",
133 + find="Old phrase",
134 + replace="New phrase",
135 + )
136 + odt_read = artifact_editor.read_artifact(updated_odt)
137 +
138 + assert odt_payload["changed"] is True
139 + assert "New phrase" in odt_read["text"]
140 +
141 + ods = document_store.create_document(
142 + "spreadsheet",
143 + "Budget ODS",
144 + "ods",
145 + "Name,Amount\nPlatform,1000",
146 + )
147 + updated_ods, ods_payload = artifact_editor.edit_artifact(
148 + ods,
149 + operation="set_cells",
150 + cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700},
151 + )
152 + ods_read = artifact_editor.read_artifact(updated_ods)
153 + ods_rows = ods_read["sheets"][0]["preview_rows"]
154 +
155 + assert ods_payload["changed"] is True
156 + assert ods_rows[1][1] == 12500
157 + assert ods_rows[2][0] == "Research"
158 +
159 + odp = document_store.create_document(
160 + "presentation",
161 + "Roadmap ODP",
162 + "odp",
163 + "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout",
164 + )
165 + updated_odp, odp_payload = artifact_editor.edit_artifact(
166 + odp,
167 + operation="set_slides",
168 + slides=[
169 + {"title": "Now", "bullets": ["Stabilize"]},
170 + {"title": "Next", "bullets": ["Polish"]},
171 + ],
172 + )
173 + odp_read = artifact_editor.read_artifact(updated_odp)
174 +
175 + assert odp_payload["changed"] is True
176 + assert odp_read["slide_count"] == 2
177 + assert odp_read["slides"][1]["title"] == "Next"
178 +
179 sheet = document_store.create_document(
180 "spreadsheet",
181 "Budget",
@@ -142,7 +224,29 @@ def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state):
224 assert deck_read["slides"][1]["title"] == "Next"
225
226
145 -def test_document_artifact_accepts_method_alias_for_xlsx_create(office_state, monkeypatch):
227 +def test_ods_direct_edit_preserves_rows_beyond_preview_window_and_blank_separators(office_state):
228 + rows = [["Row", "Value"], ["alpha", 1], [], ["separator-survives", 2]]
229 + rows.extend([[f"item-{index}", index] for index in range(4, 96)])
230 + doc = document_store.create_document("spreadsheet", "Long ODS", "ods", "")
231 + updated, payload = artifact_editor.edit_artifact(
232 + doc,
233 + operation="set_rows",
234 + rows=rows,
235 + )
236 + updated, payload = artifact_editor.edit_artifact(
237 + updated,
238 + operation="set_cells",
239 + cells={"Sheet1!B90": 9000},
240 + )
241 + parsed = artifact_editor._ods_sheets_from_bytes(Path(updated["path"]).read_bytes(), max_rows=120, max_cols=10)
242 +
243 + assert payload["changed"] is True
244 + assert parsed[0]["rows"][2] == []
245 + assert parsed[0]["rows"][3][0] == "separator-survives"
246 + assert parsed[0]["rows"][89][1] == 9000
247 +
248 +
249 +def test_document_artifact_accepts_method_alias_for_ods_create(office_state, monkeypatch):
250 tool_module = types.ModuleType("helpers.tool")
251
252 class Response:
@@ -185,30 +289,32 @@ def test_document_artifact_accepts_method_alias_for_xlsx_create(office_state, mo
289 tool.execute(
290 method="create",
291 kind="document",
188 - title="New Excel Workbook",
189 - format="xlsx",
292 + title="New Calc Workbook",
293 + format="ods",
294 content="Sheet1\n",
295 )
296 )
297 payload = json.loads(response.message)
298
299 assert payload["action"] == "create"
196 - assert payload["document"]["extension"] == "xlsx"
197 - assert Path(payload["document"]["path"]).name == "New Excel Workbook.xlsx"
300 + assert payload["document"]["extension"] == "ods"
301 + assert Path(payload["document"]["path"]).name == "New Calc Workbook.ods"
302 assert Path(document_store._path_from_a0(payload["document"]["path"])).exists()
303
304
201 -def test_odt_is_not_advertised_and_returns_clear_unsupported_response(office_state):
305 +def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state):
306 prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.document_artifact.md").read_text(
307 encoding="utf-8",
308 )
309
206 - assert "formats: md docx xlsx pptx" in prompt
310 + assert "formats: md odt ods odp docx xlsx pptx" in prompt
311 + assert "ODF is first-class for LibreOffice" in prompt
312 + assert "DOCX/XLSX/PPTX are compatibility formats" in prompt
313 assert "`method` is accepted as an alias for action" in prompt
314 assert "they do not open the canvas automatically" in prompt
315 assert "Download and Open in canvas message actions" in prompt
210 - with pytest.raises(ValueError, match="ODT editing is not supported"):
211 - document_store.create_document("document", "Skip ODT", "odt", "")
316 + doc = document_store.create_document("document", "Use ODT", "odt", "")
317 + assert doc["extension"] == "odt"
318
319
320 def test_project_scoped_creation_uses_active_project_root(office_state, monkeypatch):
@@ -224,15 +330,15 @@ def test_project_scoped_creation_uses_active_project_root(office_state, monkeypa
330 monkeypatch.setattr(office_state.project_helpers, "get_project_folder", lambda name: str(project_root))
331
332 markdown = document_store.create_document("document", "Project Note", "md", "Scoped.", context_id="ctx-project")
227 - docx = document_store.create_document("document", "Project Memo", "docx", "Scoped.", context_id="ctx-project")
333 + odt = document_store.create_document("document", "Project Memo", "odt", "Scoped.", context_id="ctx-project")
334
335 assert Path(markdown["path"]).parent == project_root
230 - assert Path(docx["path"]).parent == project_root / "documents"
336 + assert Path(odt["path"]).parent == project_root / "documents"
337
338
339 def test_non_project_creation_uses_configured_workdir(office_state):
340 markdown = document_store.create_document("document", "Workdir Note", content="Plain.")
235 - spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "xlsx", "Name,Value")
341 + spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "ods", "Name,Value")
342
343 assert markdown["extension"] == "md"
344 assert Path(markdown["path"]).parent == office_state.workdir
@@ -324,9 +430,9 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp
430
431 def test_markdown_session_rejects_office_binaries(office_state):
432 manager = markdown_sessions.MarkdownSessionManager()
327 - doc = document_store.create_document("document", "Desktop Only", "docx", "Native text")
433 + doc = document_store.create_document("document", "Desktop Only", "odt", "Native text")
434
329 - with pytest.raises(ValueError, match="Open .docx files in the Desktop"):
435 + with pytest.raises(ValueError, match="Open .odt files in the Desktop"):
436 manager.open(doc)
437
438
@@ -439,12 +545,12 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
545 monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_spawn_desktop_locked", fake_spawn)
546 monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_open_document_locked", fake_open_document)
547
442 - doc = document_store.create_document("spreadsheet", "Official Sheet", "xlsx", "Name,Value\nA,1")
548 + doc = document_store.create_document("spreadsheet", "Official Sheet", "ods", "Name,Value\nA,1")
549 manager = libreoffice_desktop.LibreOfficeDesktopManager()
550 payload = manager.open(doc)
551
552 assert payload["available"] is True
447 - assert payload["extension"] == "xlsx"
553 + assert payload["extension"] == "ods"
554 assert payload["url"].startswith("/desktop/session/")
555 registry = tmp_path / "desktop" / "profiles" / payload["session_id"] / "user" / "registrymodifications.xcu"
556 registry_text = registry.read_text(encoding="utf-8")
@@ -463,10 +569,13 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
569 settings_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Settings.desktop"
570 terminal_text = terminal_launcher.read_text(encoding="utf-8")
571 settings_text = settings_launcher.read_text(encoding="utf-8")
572 + browser_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop"
573 + browser_text = browser_launcher.read_text(encoding="utf-8")
574 assert "xfce4-terminal" in terminal_text
575 assert "org.xfce.terminal" in terminal_text
576 assert not files_launcher.exists()
469 - assert not (tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop").exists()
577 + assert "open-url" in browser_text
578 + assert "firefox" not in browser_text.lower()
579 assert "xfce4-settings-manager" in settings_text
580 assert "org.xfce.settings.manager" in settings_text
581 link_targets = {