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.
This commit is contained in:
Alessandro 2026-05-05 10:01:09 +02:00
parent d326513983
commit 2398bd1601
21 changed files with 1036 additions and 118 deletions

View file

@ -33,6 +33,10 @@ class OfficeSession(ApiHandler):
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if doc["extension"] in {"odt", "ods", "odp"}:
validation = libreoffice.validate_odf(doc["path"])
if not validation.get("ok"):
return {"ok": False, "error": validation.get("error") or "ODF validation failed."}
if doc["extension"] == "docx":
validation = libreoffice.validate_docx(doc["path"])
if not validation.get("ok"):

View file

@ -20,12 +20,24 @@ R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
XML_NS = "http://www.w3.org/XML/1998/namespace"
ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
ODS_DIRECT_EDIT_ROW_LIMIT = 10000
ODS_DIRECT_EDIT_COLUMN_LIMIT = 1024
for prefix, namespace in {
"w": W_NS,
"a": A_NS,
"p": P_NS,
"r": R_NS,
"office": ODF_OFFICE_NS,
"text": ODF_TEXT_NS,
"table": ODF_TABLE_NS,
"draw": ODF_DRAW_NS,
"presentation": ODF_PRESENTATION_NS,
}.items():
ET.register_namespace(prefix, namespace)
@ -40,6 +52,12 @@ def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]
ext = str(doc["extension"]).lower()
if ext == "md":
content = _read_markdown(path)
elif ext == "odt":
content = _read_odt(path)
elif ext == "ods":
content = _read_ods(path)
elif ext == "odp":
content = _read_odp(path)
elif ext == "docx":
content = _read_docx(path)
elif ext == "xlsx":
@ -74,6 +92,12 @@ def edit_artifact(
invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
if ext == "md":
updated, details = _edit_markdown(before, op, content=content, find=find, replace=replace, **kwargs)
elif ext == "odt":
updated, details = _edit_odt(before, op, content=content, find=find, replace=replace, **kwargs)
elif ext == "ods":
updated, details = _edit_ods(before, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, **kwargs)
elif ext == "odp":
updated, details = _edit_odp(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
elif ext == "docx":
updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
elif ext == "xlsx":
@ -179,6 +203,65 @@ def _read_markdown(path: Path) -> dict[str, Any]:
}
def _read_odt(path: Path) -> dict[str, Any]:
root = _odf_content_root(path)
paragraphs = _odf_text_lines(root)
headings = [
"".join(node.itertext()).strip()
for node in root.iter(qn(ODF_TEXT_NS, "h"))
if "".join(node.itertext()).strip()
]
return {
"kind": "document",
"format": "odt",
"paragraph_count": len(paragraphs),
"headings": headings[:40],
"text": "\n".join(paragraphs),
"paragraphs": paragraphs[:80],
}
def _read_ods(path: Path) -> dict[str, Any]:
sheets = _ods_sheets_from_bytes(
path.read_bytes(),
max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
)
return {
"kind": "spreadsheet",
"format": "ods",
"sheet_count": len(sheets),
"sheets": [
{
"name": sheet["name"],
"max_row": len(sheet["rows"]),
"max_column": max((len(row) for row in sheet["rows"]), default=0),
"chart_count": 0,
"charts": [],
"preview_rows": sheet["rows"][:80],
}
for sheet in sheets[:8]
],
}
def _read_odp(path: Path) -> dict[str, Any]:
slides = _odp_text_slides(path.read_bytes())
return {
"kind": "presentation",
"format": "odp",
"slide_count": len(slides),
"slides": [
{
"index": index + 1,
"title": slide.get("title", ""),
"lines": [slide.get("title", ""), *slide.get("bullets", [])],
}
for index, slide in enumerate(slides[:40])
],
}
def _read_docx(path: Path) -> dict[str, Any]:
with zipfile.ZipFile(path) as archive:
xml = archive.read("word/document.xml")
@ -272,6 +355,30 @@ def _edit_markdown(before: bytes, op: str, *, content: str = "", find: str = "",
return updated.encode("utf-8"), details
def _edit_odt(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
raise ValueError(f"Unsupported ODT operation: {op}")
paragraphs = _odf_text_lines(ET.fromstring(_zip_member(before, "content.xml")))
if op == "set_text":
lines = _text_lines(content)
return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
if op == "append_text":
lines = [*paragraphs, *_text_lines(content)]
return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
if op == "prepend_text":
lines = [*_text_lines(content), *paragraphs]
return document_store.odt_bytes_from_paragraphs(lines), {"paragraphs_written": len(lines)}
if not find:
raise ValueError("find is required for replace_text")
replacement = "" if op == "delete_text" else replace
joined, count = _replace_limited("\n".join(paragraphs), find, replacement, _int_or_none(kwargs.get("count")))
if count == 0:
return before, {"replacements": count}
return document_store.odt_bytes_from_paragraphs(joined.splitlines()), {"replacements": count}
def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
raise ValueError(f"Unsupported DOCX operation: {op}")
@ -327,6 +434,75 @@ def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", rep
return _zip_from_existing(files), details
def _edit_ods(
before: bytes,
op: str,
*,
content: str = "",
find: str = "",
replace: str = "",
sheet: str = "",
cells: Any = None,
rows: Any = None,
**kwargs: Any,
) -> tuple[bytes, dict[str, Any]]:
if op not in {"set_text", "set_rows", "append_text", "append_rows", "set_cells", "replace_text", "delete_text"}:
raise ValueError(f"Unsupported ODS operation: {op}")
sheets = _ods_sheets_from_bytes(
before,
max_rows=ODS_DIRECT_EDIT_ROW_LIMIT,
max_cols=ODS_DIRECT_EDIT_COLUMN_LIMIT,
strict_limits=True,
)
if not sheets:
sheets = [{"name": "Sheet1", "rows": []}]
worksheet = _ods_sheet(sheets, sheet)
details: dict[str, Any] = {"sheet": worksheet["name"]}
if op in {"set_text", "set_rows"}:
parsed_rows = _normalize_rows(rows if rows is not None else content)
worksheet["rows"] = parsed_rows
details["rows_written"] = len(parsed_rows)
elif op in {"append_text", "append_rows"}:
parsed_rows = _normalize_rows(rows if rows is not None else content)
worksheet["rows"].extend(parsed_rows)
details["rows_appended"] = len(parsed_rows)
details["start_row"] = max(len(worksheet["rows"]) - len(parsed_rows) + 1, 1)
elif op == "set_cells":
assignments = _normalize_cells(cells, default_sheet=worksheet["name"])
for sheet_name, cell, value in assignments:
target = _ods_sheet(sheets, sheet_name)
row_idx, col_idx = _cell_indices(cell)
_set_matrix_value(target["rows"], row_idx, col_idx, value)
details["cells_written"] = len(assignments)
else:
if not find:
raise ValueError("find is required for replace_text")
replacement = "" if op == "delete_text" else replace
count = 0
limit = _int_or_none(kwargs.get("count"))
for item in sheets:
for row_idx, row in enumerate(item["rows"]):
for col_idx, value in enumerate(row):
if not isinstance(value, str) or find not in value:
continue
remaining = None if limit is None else max(limit - count, 0)
if remaining == 0:
break
row[col_idx], replaced = _replace_limited(value, find, replacement, remaining)
count += replaced
if limit is not None and count >= limit:
break
if limit is not None and count >= limit:
break
details["replacements"] = count
if count == 0:
return before, details
return document_store.ods_bytes_from_sheets(sheets), details
def _edit_xlsx(
path: Path,
op: str,
@ -956,6 +1132,57 @@ def _edit_pptx(before: bytes, op: str, *, content: str = "", find: str = "", rep
return _zip_from_existing(files), {"replacements": count}
def _edit_odp(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", slides: Any = None, **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
if op not in {"set_text", "set_slides", "append_text", "append_slide", "replace_text", "delete_text"}:
raise ValueError(f"Unsupported ODP operation: {op}")
if op in {"set_text", "set_slides"}:
parsed_slides = _normalize_slides(slides if slides is not None else content)
return document_store.odp_bytes_from_slides(parsed_slides), {"slides_written": len(parsed_slides)}
existing = _odp_text_slides(before)
if op in {"append_text", "append_slide"}:
existing.extend(_normalize_slides(slides if slides is not None else content))
return document_store.odp_bytes_from_slides(existing), {"slides_written": len(existing)}
if not find:
raise ValueError("find is required for replace_text")
replacement = "" if op == "delete_text" else replace
count = 0
limit = _int_or_none(kwargs.get("count"))
for slide in existing:
title, title_count = _replace_limited(
str(slide.get("title") or ""),
find,
replacement,
None if limit is None else max(limit - count, 0),
)
if title_count:
slide["title"] = title
count += title_count
if limit is not None and count >= limit:
break
bullets = []
for bullet in slide.get("bullets") or []:
updated, replaced = _replace_limited(
str(bullet),
find,
replacement,
None if limit is None else max(limit - count, 0),
)
bullets.append(updated)
count += replaced
if limit is not None and count >= limit:
bullets.extend(slide.get("bullets", [])[len(bullets):])
break
slide["bullets"] = bullets
if limit is not None and count >= limit:
break
if count == 0:
return before, {"replacements": count}
return document_store.odp_bytes_from_slides(existing), {"replacements": count}
def _replace_text_in_paragraphs(
root: ET.Element,
*,
@ -1140,6 +1367,188 @@ def _markdown_table_rows(lines: list[str]) -> list[list[str]]:
return rows
def _zip_member(data: bytes, name: str) -> bytes:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
return archive.read(name)
def _odf_content_root(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
return ET.fromstring(archive.read("content.xml"))
def _odf_text_lines(root: ET.Element) -> list[str]:
lines = []
for node in root.iter():
if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
continue
text = "".join(node.itertext()).strip()
if text:
lines.append(text)
return lines
def _ods_sheets_from_bytes(
data: bytes,
*,
max_rows: int | None = None,
max_cols: int | None = None,
strict_limits: bool = False,
) -> list[dict[str, Any]]:
root = ET.fromstring(_zip_member(data, "content.xml"))
sheets = []
for index, table in enumerate(root.iter(qn(ODF_TABLE_NS, "table")), start=1):
name = table.get(qn(ODF_TABLE_NS, "name")) or table.get("name") or f"Sheet{index}"
rows = []
for row in table:
if row.tag != qn(ODF_TABLE_NS, "table-row"):
continue
values = _ods_row_values(row, max_cols=max_cols, strict_limits=strict_limits)
repeat_rows = _repeat_count(row.get(qn(ODF_TABLE_NS, "number-rows-repeated")))
append_count = repeat_rows
if max_rows is not None:
remaining = max(max_rows - len(rows), 0)
append_count = min(repeat_rows, remaining)
if strict_limits and repeat_rows > append_count and _row_has_content(values):
raise ValueError(f"ODS direct editing is limited to {max_rows} populated rows per sheet.")
if remaining == 0:
if not strict_limits:
break
continue
for _ in range(append_count):
rows.append(values.copy())
if max_rows is not None and len(rows) >= max_rows and not strict_limits:
break
sheets.append({"name": name, "rows": _trim_blank_edges(rows)})
return sheets
def _ods_row_values(
row: ET.Element,
*,
max_cols: int | None = None,
strict_limits: bool = False,
) -> list[Any]:
values = []
for cell in row:
if cell.tag not in {qn(ODF_TABLE_NS, "table-cell"), qn(ODF_TABLE_NS, "covered-table-cell")}:
continue
value = _ods_cell_value(cell)
repeat = _repeat_count(cell.get(qn(ODF_TABLE_NS, "number-columns-repeated")))
append_count = repeat
if max_cols is not None:
remaining = max(max_cols - len(values), 0)
append_count = min(repeat, remaining)
if strict_limits and repeat > append_count and _cell_has_content(value):
raise ValueError(f"ODS direct editing is limited to {max_cols} populated columns per sheet.")
if remaining == 0:
if not strict_limits:
break
continue
for _ in range(append_count):
values.append(value)
if max_cols is not None and len(values) >= max_cols and not strict_limits:
break
return values
def _ods_cell_value(cell: ET.Element) -> Any:
value_type = str(cell.get(qn(ODF_OFFICE_NS, "value-type")) or "").lower()
if value_type in {"float", "currency", "percentage"}:
raw = cell.get(qn(ODF_OFFICE_NS, "value"))
if raw not in (None, ""):
try:
number = float(raw)
return int(number) if number.is_integer() else number
except ValueError:
pass
if value_type == "boolean":
raw = str(cell.get(qn(ODF_OFFICE_NS, "boolean-value")) or "").lower()
if raw in {"true", "false"}:
return raw == "true"
text = "\n".join("".join(node.itertext()).strip() for node in cell.iter(qn(ODF_TEXT_NS, "p")))
return text.strip()
def _repeat_count(value: Any) -> int:
try:
count = int(value or 1)
except (TypeError, ValueError):
count = 1
return max(1, count)
def _trim_blank_edges(rows: list[list[Any]]) -> list[list[Any]]:
trimmed = []
for row in rows:
next_row = list(row)
while next_row and not _cell_has_content(next_row[-1]):
next_row.pop()
trimmed.append(next_row)
while trimmed and not _row_has_content(trimmed[-1]):
trimmed.pop()
return trimmed
def _cell_has_content(value: Any) -> bool:
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
return True
def _row_has_content(row: list[Any]) -> bool:
return any(_cell_has_content(value) for value in row)
def _ods_sheet(sheets: list[dict[str, Any]], name: str = "") -> dict[str, Any]:
normalized = str(name or "").strip()
if normalized:
for sheet in sheets:
if str(sheet["name"]).casefold() == normalized.casefold():
return sheet
sheet = {"name": normalized, "rows": []}
sheets.append(sheet)
return sheet
return sheets[0]
def _cell_indices(cell: str) -> tuple[int, int]:
match = re.fullmatch(r"\$?([A-Za-z]{1,4})\$?([1-9][0-9]*)", str(cell or "").strip())
if not match:
raise ValueError(f"Invalid cell reference: {cell}")
col = 0
for char in match.group(1).upper():
col = col * 26 + (ord(char) - 64)
return int(match.group(2)), col
def _set_matrix_value(rows: list[list[Any]], row_idx: int, col_idx: int, value: Any) -> None:
while len(rows) < row_idx:
rows.append([])
row = rows[row_idx - 1]
while len(row) < col_idx:
row.append("")
row[col_idx - 1] = value
def _odp_text_slides(data: bytes) -> list[dict[str, Any]]:
root = ET.fromstring(_zip_member(data, "content.xml"))
slides = []
for page in root.iter(qn(ODF_DRAW_NS, "page")):
lines = []
for node in page.iter():
if node.tag not in {qn(ODF_TEXT_NS, "h"), qn(ODF_TEXT_NS, "p")}:
continue
text = "".join(node.itertext()).strip()
if text:
lines.append(text)
if lines:
slides.append({"title": lines[0], "bullets": lines[1:]})
return slides
def _pptx_text_slides(data: bytes) -> list[dict[str, Any]]:
slides = []
with zipfile.ZipFile(io.BytesIO(data)) as archive:

View file

@ -42,6 +42,9 @@ DOCUMENT_TERMS = {
"manual",
"markdown",
"memo",
"odt",
"open document",
"opendocument",
"policy",
"proposal",
"report",
@ -49,11 +52,14 @@ DOCUMENT_TERMS = {
"spec",
"story",
"whitepaper",
"writer",
}
SPREADSHEET_TERMS = {
"budget",
"calc",
"excel",
"ods",
"sheet",
"spreadsheet",
"table",
@ -63,6 +69,8 @@ SPREADSHEET_TERMS = {
PRESENTATION_TERMS = {
"deck",
"impress",
"odp",
"ppt",
"pptx",
"presentation",
@ -89,6 +97,9 @@ EXPLICIT_FORMAT_TERMS = {
"docx",
"md",
"markdown",
"odp",
"ods",
"odt",
"pptx",
"xlsx",
}
@ -98,8 +109,10 @@ HANDOFF_TERMS = {
"artifacts",
"canvas",
"document canvas",
"download",
"downloadable",
"editable",
"export",
"open it",
"save it",
"save this",
@ -119,6 +132,36 @@ CHAT_ONLY_TERMS = {
"no files",
}
META_DISCUSSION_TERMS = {
"affordance",
"automatically",
"auto",
"disable",
"issue",
"less triggered",
"problem",
"speedbump",
"stop",
"trigger",
"triggered",
"why",
}
OOXML_COMPAT_TERMS = {
"docx",
"excel",
"microsoft word",
"powerpoint",
"ppt",
"pptx",
"word",
"xlsx",
}
ODF_DOCUMENT_TERMS = {"odt", "open document text", "opendocument text", "writer"}
ODF_SPREADSHEET_TERMS = {"calc", "ods", "open document spreadsheet", "opendocument spreadsheet"}
ODF_PRESENTATION_TERMS = {"impress", "odp", "open document presentation", "opendocument presentation"}
SKIP_RESPONSE_PREFIXES = (
"i can't",
"i cannot",
@ -200,11 +243,17 @@ def normalize_text(value: str) -> str:
def infer_kind_and_format(lowered_user: str) -> tuple[str, str]:
if has_any(lowered_user, PRESENTATION_TERMS):
return "presentation", "pptx"
if has_any(lowered_user, {"powerpoint", "ppt", "pptx"}):
return "presentation", "pptx"
return "presentation", "odp"
if has_any(lowered_user, SPREADSHEET_TERMS):
return "spreadsheet", "xlsx"
if has_any(lowered_user, {"docx"}):
if has_any(lowered_user, {"excel", "xlsx"}):
return "spreadsheet", "xlsx"
return "spreadsheet", "ods"
if has_any(lowered_user, {"docx", "microsoft word", "word"}):
return "document", "docx"
if has_any(lowered_user, ODF_DOCUMENT_TERMS):
return "document", "odt"
return "document", "md"
@ -213,8 +262,6 @@ def artifact_intent(lowered_user: str, response_text: str) -> str | None:
return None
if has_explicit_handoff_signal(lowered_user):
return "explicit_handoff"
if has_any(lowered_user, DELIVERABLE_TERMS) and looks_like_standalone_artifact(response_text):
return "document_intent"
return None
@ -226,25 +273,42 @@ def has_document_creation_intent(lowered_user: str) -> bool:
def has_explicit_handoff_signal(lowered_user: str) -> bool:
if has_any(lowered_user, EXPLICIT_FORMAT_TERMS | HANDOFF_TERMS):
return True
if has_any(lowered_user, FILE_HANDOFF_TERMS) and has_any(
lowered_user,
DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
):
if looks_like_affordance_meta_discussion(lowered_user):
return False
creation = r"(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format|convert|turn|save|export)"
handoff = r"(?:file|files|artifact|artifacts|canvas|download|downloadable|editable file|open in canvas)"
format_name = (
r"(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress|word|excel|"
r"powerpoint|document|spreadsheet|workbook|presentation|deck|slides)"
)
if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b{handoff}\b", lowered_user):
return True
if re.search(
r"\b(?:convert|format|save|turn)\b(?:\W+\w+){0,8}?\W+(?:as|to|into)\s+"
r"(?:a|an|the)?\s*(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides|md|docx|xlsx|pptx)\b",
rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+(?:as|to|into)\s+"
rf"(?:a|an|the)?\s*{format_name}\b",
lowered_user,
):
return True
return bool(re.search(
r"\b(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format)\b"
r"(?:\s+(?:me|us|a|an|the|new|blank|editable|office|word|excel|powerpoint))*"
r"\s+(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides)\b",
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):
return True
return bool(
has_any(lowered_user, FILE_HANDOFF_TERMS | HANDOFF_TERMS)
and has_any(
lowered_user,
EXPLICIT_FORMAT_TERMS | ODF_DOCUMENT_TERMS | ODF_SPREADSHEET_TERMS | ODF_PRESENTATION_TERMS | OOXML_COMPAT_TERMS,
)
)
def looks_like_affordance_meta_discussion(lowered_user: str) -> bool:
if not has_any(lowered_user, META_DISCUSSION_TERMS):
return False
return has_any(
lowered_user,
))
DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS | EXPLICIT_FORMAT_TERMS | FILE_HANDOFF_TERMS,
)
def has_any(text: str, terms: set[str]) -> bool:

View file

@ -20,9 +20,25 @@ from plugins._office.helpers import pptx_writer
PLUGIN_NAME = "_office"
SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"}
OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"}
OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"}
SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS}
DEFAULT_TTL_SECONDS = 8 * 60 * 60
MAX_SAVE_BYTES = 512 * 1024 * 1024
ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
ODF_TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
ODF_TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
ODF_DRAW_NS = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
ODF_PRESENTATION_NS = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
ODF_STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0"
ODF_FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
ODF_MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
ODF_VERSION = "1.2"
ODF_MIMETYPES = {
"odt": "application/vnd.oasis.opendocument.text",
"ods": "application/vnd.oasis.opendocument.spreadsheet",
"odp": "application/vnd.oasis.opendocument.presentation",
}
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
DB_PATH = STATE_DIR / "documents.sqlite3"
@ -58,8 +74,6 @@ def normalize_extension(value: str) -> str:
if not ext:
ext = "md"
if ext not in SUPPORTED_EXTENSIONS:
if ext == "odt":
raise ValueError("ODT editing is not supported in this migration. Use Markdown or DOCX.")
raise ValueError(f"Unsupported document format: {ext}")
return ext
@ -599,7 +613,7 @@ def create_document(
def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
base = safe_title(title, "Document")
base = safe_document_stem(title, ext, "Document")
root = document_home(context_id) if ext == "md" else document_binary_home(context_id)
candidate = root / f"{base}.{ext}"
index = 2
@ -609,10 +623,24 @@ def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
return candidate.resolve(strict=False)
def safe_document_stem(title: str, ext: str, fallback: str = "Document") -> str:
base = safe_title(title, fallback)
suffix = f".{normalize_extension(ext)}"
if base.casefold().endswith(suffix.casefold()):
base = base[: -len(suffix)].rstrip(" ._") or fallback
return base
def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
ext = normalize_extension(ext or "md")
if ext == "md":
return _markdown(title, content).encode("utf-8")
if ext == "odt":
return odt_bytes(title, content)
if ext == "ods":
return ods_bytes(title, content)
if ext == "odp":
return odp_bytes(title, content)
if ext == "docx":
return _docx(title, content)
if ext == "xlsx":
@ -638,6 +666,219 @@ def _zip_bytes(files_map: dict[str, str | bytes]) -> bytes:
return buffer.getvalue()
def odf_zip_bytes(ext: str, files_map: dict[str, str | bytes]) -> bytes:
ext = normalize_extension(ext)
if ext not in ODF_MIMETYPES:
raise ValueError(f"Unsupported ODF format: {ext}")
media_type = ODF_MIMETYPES[ext]
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("mimetype", media_type, compress_type=zipfile.ZIP_STORED)
for name, value in files_map.items():
if name == "mimetype":
continue
data = value.encode("utf-8") if isinstance(value, str) else value
archive.writestr(name, data, compress_type=zipfile.ZIP_DEFLATED)
return buffer.getvalue()
def odt_bytes(title: str, content: str) -> bytes:
return odt_bytes_from_paragraphs(_document_lines(title, content))
def odt_bytes_from_paragraphs(paragraphs: list[str]) -> bytes:
lines = [str(line) for line in paragraphs] or [""]
body = "\n".join(_odt_paragraph(line, index == 0) for index, line in enumerate(lines))
return _odf_package(
"odt",
f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
<office:body>
<office:text>
{body}
</office:text>
</office:body>
</office:document-content>
""",
)
def ods_bytes(title: str, content: str) -> bytes:
return ods_bytes_from_sheets([{"name": "Sheet1", "rows": _xlsx_rows(title, content)}])
def ods_bytes_from_sheets(sheets: list[dict[str, Any]]) -> bytes:
normalized = []
for index, sheet in enumerate(sheets or []):
name = safe_title(str(sheet.get("name") or f"Sheet{index + 1}"), f"Sheet{index + 1}")[:31] or f"Sheet{index + 1}"
rows = sheet.get("rows") or []
normalized.append({"name": name, "rows": rows})
if not normalized:
normalized = [{"name": "Sheet1", "rows": [["Spreadsheet"]]}]
tables = "\n".join(
f"""<table:table table:name="{escape(sheet['name'])}">
{''.join(_ods_row(row) for row in sheet['rows'])}
</table:table>"""
for sheet in normalized
)
return _odf_package(
"ods",
f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
<office:body>
<office:spreadsheet>
{tables}
</office:spreadsheet>
</office:body>
</office:document-content>
""",
)
def odp_bytes(title: str, content: str) -> bytes:
return odp_bytes_from_slides(pptx_writer.slides_from_text(title, content))
def odp_bytes_from_slides(slides: list[dict[str, Any]]) -> bytes:
normalized = pptx_writer.normalize_slides(slides)
if not normalized:
normalized = [{"title": "Presentation", "bullets": []}]
pages = "\n".join(_odp_page(slide, index) for index, slide in enumerate(normalized, start=1))
return _odf_package(
"odp",
f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document-content {_odf_content_namespaces()} office:version="{ODF_VERSION}">
<office:body>
<office:presentation>
{pages}
</office:presentation>
</office:body>
</office:document-content>
""",
)
def _document_lines(title: str, content: str) -> list[str]:
lines = [str(title or "Document").strip() or "Document"]
lines.extend(line.rstrip() for line in str(content or "").splitlines() if line.strip())
if len(lines) == 1:
lines.append("")
return lines
def _odf_package(ext: str, content_xml: str) -> bytes:
return odf_zip_bytes(
ext,
{
"content.xml": content_xml,
"styles.xml": _odf_styles_xml(),
"meta.xml": _odf_meta_xml(),
"settings.xml": _odf_settings_xml(),
"META-INF/manifest.xml": _odf_manifest_xml(ODF_MIMETYPES[ext]),
},
)
def _odf_content_namespaces() -> str:
return (
f'xmlns:office="{ODF_OFFICE_NS}" '
f'xmlns:text="{ODF_TEXT_NS}" '
f'xmlns:table="{ODF_TABLE_NS}" '
f'xmlns:draw="{ODF_DRAW_NS}" '
f'xmlns:presentation="{ODF_PRESENTATION_NS}" '
f'xmlns:style="{ODF_STYLE_NS}" '
f'xmlns:fo="{ODF_FO_NS}"'
)
def _odf_styles_xml() -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles {_odf_content_namespaces()} office:version="{ODF_VERSION}">
<office:styles>
<style:style style:name="Standard" style:family="paragraph"/>
<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">
<style:text-properties fo:font-weight="bold" fo:font-size="18pt"/>
</style:style>
</office:styles>
</office:document-styles>
"""
def _odf_meta_xml() -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}">
<office:meta/>
</office:document-meta>
"""
def _odf_settings_xml() -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document-settings xmlns:office="{ODF_OFFICE_NS}" office:version="{ODF_VERSION}">
<office:settings/>
</office:document-settings>
"""
def _odf_manifest_xml(media_type: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="{ODF_MANIFEST_NS}" manifest:version="{ODF_VERSION}">
<manifest:file-entry manifest:full-path="/" manifest:media-type="{media_type}"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
"""
def _odt_paragraph(line: str, heading: bool = False) -> str:
text = escape(str(line))
if heading:
return f'<text:h text:outline-level="1">{text}</text:h>'
return f"<text:p>{text}</text:p>"
def _ods_row(row: list[Any]) -> str:
cells = "".join(_ods_cell(value) for value in row)
return f"<table:table-row>{cells}</table:table-row>"
def _ods_cell(value: Any) -> str:
value = _xlsx_value(value)
if value in (None, ""):
return "<table:table-cell/>"
if isinstance(value, bool):
text = "TRUE" if value else "FALSE"
return (
f'<table:table-cell office:value-type="boolean" office:boolean-value="{str(value).lower()}">'
f"<text:p>{text}</text:p></table:table-cell>"
)
if isinstance(value, (int, float)):
return (
f'<table:table-cell office:value-type="float" office:value="{value}">'
f"<text:p>{value}</text:p></table:table-cell>"
)
text = escape(str(value))
return f'<table:table-cell office:value-type="string"><text:p>{text}</text:p></table:table-cell>'
def _odp_page(slide: dict[str, Any], index: int) -> str:
title = escape(str(slide.get("title") or f"Slide {index}"))
bullets = [escape(str(item)) for item in slide.get("bullets") or []]
bullet_items = "".join(f"<text:list-item><text:p>{bullet}</text:p></text:list-item>" for bullet in bullets)
body = f"<text:list>{bullet_items}</text:list>" if bullet_items else "<text:p/>"
return f"""<draw:page draw:name="Slide {index}" draw:master-page-name="Default">
<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">
<draw:text-box><text:p>{title}</text:p></draw:text-box>
</draw:frame>
<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">
<draw:text-box>{body}</draw:text-box>
</draw:frame>
</draw:page>"""
def _docx(title: str, content: str) -> bytes:
lines = [title] + [line for line in content.splitlines() if line.strip()]
if len(lines) == 1:

View file

@ -11,6 +11,11 @@ from typing import Any
SOFFICE_BINARIES = ("soffice", "libreoffice")
CONVERT_TIMEOUT_SECONDS = 45
ODF_MIMETYPES = {
"odt": "application/vnd.oasis.opendocument.text",
"ods": "application/vnd.oasis.opendocument.spreadsheet",
"odp": "application/vnd.oasis.opendocument.presentation",
}
def find_soffice() -> str:
@ -73,6 +78,29 @@ def validate_docx(path: str | Path) -> dict[str, Any]:
return {"ok": True}
def validate_odf(path: str | Path) -> dict[str, Any]:
source = Path(path)
if not source.exists():
return {"ok": False, "error": f"File not found: {source}"}
ext = source.suffix.lower().lstrip(".")
expected_mimetype = ODF_MIMETYPES.get(ext)
if not expected_mimetype:
return {"ok": False, "error": f"Unsupported ODF extension: {ext}"}
try:
with zipfile.ZipFile(source) as archive:
first = archive.infolist()[0]
mimetype = archive.read("mimetype").decode("utf-8")
archive.getinfo("content.xml")
archive.getinfo("META-INF/manifest.xml")
except Exception as exc:
return {"ok": False, "error": f"ODF package validation failed: {exc}"}
if first.filename != "mimetype" or first.compress_type != zipfile.ZIP_STORED:
return {"ok": False, "error": "ODF mimetype must be the first uncompressed package entry."}
if mimetype != expected_mimetype:
return {"ok": False, "error": f"ODF mimetype mismatch: expected {expected_mimetype}, got {mimetype}"}
return {"ok": True}
def convert_document(path: str | Path, target_format: str, output_dir: str | Path | None = None) -> dict[str, Any]:
source = Path(path)
if not source.exists():

View file

@ -20,7 +20,7 @@ from helpers import files, virtual_desktop
from plugins._office.helpers import document_store, libreoffice
OFFICIAL_EXTENSIONS = {"docx", "xlsx", "pptx"}
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
SYSTEM_SESSION_ID = "agent-zero-desktop"
SYSTEM_FILE_ID = "system-desktop"
SYSTEM_TITLE = "Desktop"

View file

@ -1,6 +1,6 @@
name: _office
title: LibreOffice
description: Markdown-first writing and LibreOffice-backed document artifacts in the right canvas.
description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas.
version: "0.1"
settings_sections:
- developer

View file

@ -1,14 +1,15 @@
### document_artifact
create/open/read/edit reusable document artifacts in the Agent Zero canvas
formats: md docx xlsx pptx
formats: md odt ods odp docx xlsx pptx
default format: md
methods: create open read edit inspect export version_history restore_version status
common args: method action kind title format content path file_id
`method` is accepted as an alias for action when the tool_name has no suffix
tool results save or update artifacts only; they do not open the canvas automatically
created/updated artifacts are shown with explicit Download and Open in canvas message actions
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts
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
DOCX/XLSX/PPTX are compatibility formats, not defaults
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
chart types: line bar column pie area scatter stock ohlc candlestick
XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
ODT/ODS/ODP editing is intentionally unsupported in this migration
for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Word/Excel/Presentation skill first
ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first

View file

@ -1,10 +1,14 @@
---
name: excel-workbooks
description: Use when creating, opening, or editing Excel-compatible XLSX spreadsheets, workbooks, tables, budgets, formulas, sheets, or charts.
version: "1.0.0"
description: Use when creating, opening, or editing LibreOffice Calc ODS spreadsheets, or XLSX workbooks only when Excel compatibility is explicitly required.
version: "1.1.0"
author: "Agent Zero Core Team"
tags: ["excel", "xlsx", "spreadsheet", "workbook", "calc", "tables", "charts", "budget"]
tags: ["calc", "ods", "opendocument", "excel", "xlsx", "spreadsheet", "workbook", "tables", "charts", "budget"]
triggers:
- "Calc"
- "ODS"
- "ods"
- "OpenDocument Spreadsheet"
- "Excel"
- "XLSX"
- "xlsx"
@ -17,11 +21,11 @@ allowed_tools:
- document_artifact
---
# Excel Workbooks
# Calc Spreadsheets
Use XLSX when the user asks for Excel, a spreadsheet, a workbook, tables that should remain editable as cells, formulas, or embedded spreadsheet charts.
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.
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.
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.
## Workflow
@ -33,13 +37,13 @@ Create a workbook:
"tool_args": {
"kind": "spreadsheet",
"title": "Budget",
"format": "xlsx",
"format": "ods",
"content": "Item,Amount\nPlatform,1000"
}
}
```
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.
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.
Edit cells:
@ -61,5 +65,5 @@ Practical rules:
- `content` may be CSV, TSV, or a Markdown table; the tool writes real spreadsheet cells.
- Use `rows` for whole-table replacement, `append_rows` for adding records, and `set_cells` for precise edits.
- Use `create_chart` with a chart object for embedded charts before reaching for code execution.
- 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.
- Do not open Calc/canvas automatically. The user can choose Open in canvas when they want the visible spreadsheet.

View file

@ -23,13 +23,14 @@ Use the Desktop as a full Linux GUI when the user explicitly needs a visual work
## Operating Model
1. Prefer `document_artifact` for creating, reading, and editing Markdown, DOCX, XLSX, and PPTX files.
1. Prefer `document_artifact` for creating, reading, and editing Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX files.
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.
3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
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.
5. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
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.
4. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
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.
6. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
7. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
8. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
## Control Flow
@ -95,9 +96,9 @@ Use these folders when the user asks to inspect or manipulate project files, ski
## App Map
- `LibreOffice Writer`: word processing and DOCX layout.
- `LibreOffice Calc`: spreadsheets, formulas, tables, charts.
- `LibreOffice Impress`: presentations and slide polish.
- `LibreOffice Writer`: ODT word processing and DOCX compatibility layout.
- `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility.
- `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility.
- `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`).
- `Terminal`: shell work inside the Agent Zero runtime.
- `Settings`: XFCE system settings.

View file

@ -18,7 +18,7 @@ allowed_tools:
# Markdown Documents
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.
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.
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.
@ -45,7 +45,7 @@ Minimal create:
Practical rules:
- Prefer Markdown over DOCX for writing unless Word compatibility is explicitly needed.
- Prefer Markdown over ODT/DOCX for writing unless a binary Writer/Word file is explicitly needed.
- Keep agent-only cleanup simple: if the user asks to fix a typo, update the file and finish; do not force a canvas workflow.
- Use clear headings and Markdown tables when they improve editability.
- The custom Markdown editor is available when the user chooses Open in canvas.

View file

@ -1,17 +1,21 @@
---
name: office-artifacts
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.
version: "1.3.0"
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.
version: "1.4.0"
author: "Agent Zero Core Team"
tags: ["documents", "markdown", "md", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations"]
tags: ["documents", "markdown", "md", "odt", "ods", "odp", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations", "libreoffice", "opendocument"]
triggers:
- "document canvas"
- "markdown document"
- "editable document"
- "md"
- "odt"
- "ods"
- "odp"
- "docx"
- "xlsx"
- "pptx"
- "writer"
- "spreadsheet"
- "presentation"
allowed_tools:
@ -20,16 +24,16 @@ allowed_tools:
# Document Artifacts
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.
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.
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.
For format-specific work, prefer the matching skill when available:
- `markdown-documents` for Markdown-first editable writing.
- `word-documents` for DOCX/Word-compatible files.
- `excel-workbooks` for XLSX/Excel-compatible spreadsheets.
- `presentation-decks` for PPTX/PowerPoint-compatible decks.
- `word-documents` for Writer/ODT files and DOCX compatibility files.
- `excel-workbooks` for Calc/ODS spreadsheets and XLSX compatibility workbooks.
- `presentation-decks` for Impress/ODP decks and PPTX compatibility decks.
## Workflow
@ -67,7 +71,7 @@ Read:
}
```
Edit text in a Markdown, DOCX, or PPTX file:
Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
```json
{
"tool_name": "document_artifact:edit",
@ -85,7 +89,7 @@ Set spreadsheet cells:
{
"tool_name": "document_artifact:edit",
"tool_args": {
"path": "/a0/usr/workdir/documents/Budget.xlsx",
"path": "/a0/usr/workdir/documents/Budget.ods",
"operation": "set_cells",
"cells": {
"Sheet1!B2": 12500,
@ -118,16 +122,17 @@ Create an embedded spreadsheet chart:
## Edit Operations
- MD and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
- XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`.
- PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
- MD, ODT, and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
- ODS and XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
- XLSX only: `create_chart` for embedded spreadsheet charts.
- ODP and PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
Arguments:
- `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
- `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
- `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
- `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`.
- `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`.
- `slides` accepts `[{"title":"Slide title","bullets":["point"]}]`. Text slides can be separated with a line containing `---`.
- `count` limits text replacements.
@ -136,10 +141,10 @@ Arguments:
- Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
- Use `read` before editing unless the current saved content is already known.
- 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.
- For document-style requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
- For document-style writing requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive surface.
- For spreadsheet or presentation file requests with no Microsoft compatibility requirement, create ODS or ODP.
- 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.
- 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.
- Do not create ODT, ODS, or ODP in this pass; return a clear unsupported response if asked.
- 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.
- Use `edit` for precise saved changes; use the visual document canvas for human/manual layout polish.
- Direct edits update version history and refresh the canvas on edit/open results.

View file

@ -1,10 +1,14 @@
---
name: presentation-decks
description: Use when creating, opening, or editing PowerPoint-compatible PPTX presentations, slide decks, talks, briefing decks, or LibreOffice Impress files.
version: "1.0.0"
description: Use when creating, opening, or editing LibreOffice Impress ODP presentations, or PPTX decks only when PowerPoint compatibility is explicitly required.
version: "1.1.0"
author: "Agent Zero Core Team"
tags: ["presentation", "pptx", "powerpoint", "slides", "deck", "impress"]
tags: ["presentation", "odp", "opendocument", "pptx", "powerpoint", "slides", "deck", "impress"]
triggers:
- "Impress"
- "ODP"
- "odp"
- "OpenDocument Presentation"
- "PowerPoint"
- "PPTX"
- "pptx"
@ -17,11 +21,11 @@ allowed_tools:
- document_artifact
---
# Presentation Decks
# Impress Presentations
Use PPTX when the user asks for PowerPoint, a presentation, slides, a deck, or an Impress-compatible artifact.
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`.
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.
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.
## Workflow
@ -33,7 +37,7 @@ Create:
"tool_args": {
"kind": "presentation",
"title": "Roadmap",
"format": "pptx",
"format": "odp",
"content": "Title Slide\n\n---\n\nNext Steps"
}
}
@ -59,5 +63,5 @@ Practical rules:
- Use `slides` arrays for structured decks and `---` separators for simple text-to-slide creation.
- Keep slide text concise and scannable.
- Do not create ODP in this workflow.
- Treat PPTX as a compatibility export/request, not the default presentation format.
- Do not open Impress/canvas automatically. The user can choose Open in canvas when they want to inspect or polish the deck visually.

View file

@ -1,10 +1,14 @@
---
name: word-documents
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.
version: "1.0.0"
description: Use when creating, opening, or editing LibreOffice Writer ODT documents, or DOCX documents only when Microsoft Word compatibility is explicitly required.
version: "1.1.0"
author: "Agent Zero Core Team"
tags: ["word", "docx", "writer", "documents", "reports", "memos", "contracts"]
tags: ["writer", "odt", "opendocument", "word", "docx", "documents", "reports", "memos", "contracts"]
triggers:
- "Writer"
- "ODT"
- "odt"
- "OpenDocument Text"
- "Word"
- "DOCX"
- "docx"
@ -15,11 +19,11 @@ allowed_tools:
- document_artifact
---
# Word Documents
# Writer Documents
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.
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.
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.
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.
## Workflow
@ -31,7 +35,7 @@ Create:
"tool_args": {
"kind": "document",
"title": "Board Memo",
"format": "docx",
"format": "odt",
"content": "Memo body text."
}
}
@ -45,6 +49,6 @@ Edit:
Practical rules:
- Keep DOCX content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested.
- Do not create ODT in this workflow.
- Keep Writer content clean and structured. Use headings and paragraphs; avoid over-formatting unless requested.
- Treat DOCX as a compatibility export/request, not the default Writer format.
- 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.

View file

@ -42,6 +42,13 @@ class DocumentArtifact(Tool):
path=path,
context_id=self._context_id(),
)
if doc["extension"] in {"odt", "ods", "odp"}:
validation = libreoffice.validate_odf(doc["path"])
if not validation.get("ok"):
return Response(
message=f"document_artifact create failed: {validation.get('error')}",
break_loop=False,
)
if doc["extension"] == "docx":
validation = libreoffice.validate_docx(doc["path"])
if not validation.get("ok"):

View file

@ -21,15 +21,15 @@
<span class="material-symbols-outlined">article</span>
<span class="office-button-label">Markdown</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
<button type="button" class="office-icon-button office-command-button" aria-label="New Writer document" @click="$store.office.create('document', 'odt')">
<span class="material-symbols-outlined">description</span>
<span class="office-button-label">DOCX</span>
<span class="office-button-label">Writer</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
<button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'ods')">
<span class="material-symbols-outlined">table_chart</span>
<span class="office-button-label">Spreadsheet</span>
</button>
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
<button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'odp')">
<span class="material-symbols-outlined">co_present</span>
<span class="office-button-label">Presentation</span>
</button>

View file

@ -360,7 +360,7 @@ const model = {
},
async create(kind = "document", format = "") {
const fmt = String(format || (kind === "spreadsheet" ? "xlsx" : kind === "presentation" ? "pptx" : "md")).toLowerCase();
const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
const title = this.defaultTitle(kind, fmt);
await this.openSession({
action: "create",
@ -831,7 +831,7 @@ const model = {
isBinaryOffice(tab = this.session) {
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
return ext === "docx" || ext === "xlsx" || ext === "pptx";
return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
},
hasOfficialOffice(tab = this.session) {
@ -1872,6 +1872,7 @@ const model = {
defaultTitle(kind, fmt) {
const date = new Date().toISOString().slice(0, 10);
if (fmt === "md") return `Document ${date}`;
if (fmt === "odt") return `Writer ${date}`;
if (fmt === "docx") return `DOCX ${date}`;
if (kind === "spreadsheet") return `Spreadsheet ${date}`;
if (kind === "presentation") return `Presentation ${date}`;
@ -1891,9 +1892,9 @@ const model = {
const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
if (this.isDesktopSession(tab)) return "desktop_windows";
if (ext === "md") return "article";
if (ext === "docx") return "description";
if (ext === "xlsx") return "table_chart";
if (ext === "pptx") return "co_present";
if (ext === "odt" || ext === "docx") return "description";
if (ext === "ods" || ext === "xlsx") return "table_chart";
if (ext === "odp" || ext === "pptx") return "co_present";
return "draft";
},