From c8648bf80c5b5cb1aab088dc4987b105c808e08c Mon Sep 17 00:00:00 2001 From: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:04:09 +0200 Subject: [PATCH 1/4] feat(collectives): add Collectives support for page management Add a new tool module wrapping the Collectives OCS API (under /ocs/v2.php/apps/collectives/api/v1.0) plus WebDAV for page markdown content, giving the agent 13 tools covering the full page lifecycle. Read (safe): - list_collectives: enumerate the user's collectives with permissions - list_collective_pages: flat list of pages with tree metadata (parentId + subpageOrder) for a collective - get_page: metadata for a single page - get_page_content: markdown body via WebDAV, empty string on 404 - list_page_trash: enumerate trashed pages Write (dangerous): - create_page: new page under a parent - update_page_content: overwrite markdown body via WebDAV PUT - rename_page: change title (also renames the .md file) - move_page: change parentId within the collective - set_page_emoji: set/clear page emoji - trash_page: soft-delete - restore_page: from trash - delete_page_permanently: purge a trashed page Markdown I/O uses the same WebDAV adapter pattern as files.py (nc._session._create_adapter(True)) and the page's collectivePath + filePath + fileName from the metadata. Paths are URL-encoded via urllib.parse.quote. Collectives does not register an OCS capability, so is_available() probes the API (matching the pattern in mail.py) rather than checking nc.capabilities. Unified search already exposes three Collectives providers (collectives, collectives-pages, collectives-page-content) via search.py, so no separate search tool is added here. Tested against Nextcloud 32.0.8 with Collectives 3.6.1 on the full lifecycle (create, write, read, rename, move, emoji, trash, restore, permanent delete) plus URL construction for top-level and nested pages with spaces in names. Signed-off-by: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com> --- ex_app/lib/all_tools/collectives.py | 245 ++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 ex_app/lib/all_tools/collectives.py diff --git a/ex_app/lib/all_tools/collectives.py b/ex_app/lib/all_tools/collectives.py new file mode 100644 index 0000000..4eb8a0d --- /dev/null +++ b/ex_app/lib/all_tools/collectives.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +import json +from urllib.parse import quote +from langchain_core.tools import tool +from nc_py_api import AsyncNextcloudApp + +from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool + + +async def get_tools(nc: AsyncNextcloudApp): + + async def _user_id() -> str: + return (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))['id'] + + async def _page_webdav_url(user_id: str, page: dict) -> str: + # A page's markdown file lives at: + # /remote.php/dav/files/{user}/{collectivePath}/{filePath}/{fileName} + # filePath is empty for top-level pages. + parts = [page['collectivePath'], page.get('filePath') or '', page['fileName']] + encoded = [quote(p) for p in parts if p] + return f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{'/'.join(encoded)}" + + # --- Collectives --- + + @tool + @safe_tool + async def list_collectives(): + """ + List all Collectives (wiki-like knowledge bases) the current user is a member of. + Each collective contains pages of Markdown content, organized in a tree. + :return: list of collectives with id, name, emoji, slug, and the user's permissions (canEdit, canShare) + """ + return json.dumps(await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives')) + + # --- Pages (read) --- + + @tool + @safe_tool + async def list_collective_pages(collective_id: int): + """ + List all pages in a Collective as a flat list with tree information. + Pages form a tree via parentId (0 = top-level / landing page). Each page has an id needed + by every other page tool, a title, and metadata (emoji, tags, last editor, trashed status). + Markdown content is not included - fetch it with get_page_content. + :param collective_id: the id of the collective (obtainable with list_collectives) + :return: list of pages with id, title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size, trashTimestamp + """ + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages')) + + @tool + @safe_tool + async def get_page(collective_id: int, page_id: int): + """ + Get metadata for a single Collectives page (without the markdown body). + Use get_page_content for the markdown body. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page (obtainable with list_collective_pages) + :return: page metadata including title, emoji, parentId, subpageOrder, tags, lastUserId, timestamp, size + """ + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')) + + @tool + @safe_tool + async def get_page_content(collective_id: int, page_id: int): + """ + Get the Markdown content of a Collectives page. + Fetches the underlying .md file via WebDAV. Returns an empty string for pages that have + never been written to (newly created pages materialize their file on first write). + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page (obtainable with list_collective_pages) + :return: the markdown content of the page, or empty string if the file has not been written yet + """ + page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}') + page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp + user_id = await _user_id() + url = await _page_webdav_url(user_id, page) + response = await nc._session._create_adapter(True).request('GET', url, headers={ + 'Content-Type': 'application/json', + }) + if response.status_code == 404: + return '' + return response.text + + @tool + @safe_tool + async def list_page_trash(collective_id: int): + """ + List trashed pages in a Collective. Trashed pages can be restored with restore_page or + removed permanently with delete_page_permanently. Trashed pages are eventually removed by + a background job after an admin-configured retention period. + :param collective_id: the id of the collective (obtainable with list_collectives) + :return: list of trashed pages with id, title, trashTimestamp, parentId, and the rest of the page metadata + """ + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash')) + + # --- Pages (write) --- + + @tool + @dangerous_tool + async def create_page(collective_id: int, parent_id: int, title: str): + """ + Create a new page in a Collective as a child of an existing page. + Use parent_id = landing page id (from list_collective_pages, the page with parentId=0) for + a top-level page. The page is created with an empty body; call update_page_content afterward + to write markdown. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param parent_id: the id of the parent page (obtainable with list_collective_pages) + :param title: the title for the new page + :return: the created page's metadata including its id + """ + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{parent_id}', json={ + 'title': title, + })) + + @tool + @dangerous_tool + async def update_page_content(collective_id: int, page_id: int, content: str): + """ + Overwrite the Markdown content of a Collectives page. + Replaces the entire page body. To append, first read with get_page_content and concatenate. + If another user has the page open in the real-time editor, their session may overwrite this + write on save - consider rename_page or trash_page for destructive intent instead. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page (obtainable with list_collective_pages) + :param content: the new markdown body for the page (replaces existing content) + :return: success confirmation with the page id + """ + page_resp = await nc.ocs('GET', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}') + page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp + user_id = await _user_id() + url = await _page_webdav_url(user_id, page) + await nc._session._create_adapter(True).request('PUT', url, headers={ + 'Content-Type': 'text/markdown', + }, data=content) + return json.dumps({'status': 'success', 'page_id': page_id}) + + @tool + @dangerous_tool + async def rename_page(collective_id: int, page_id: int, title: str): + """ + Change the title of a Collectives page. Also renames the underlying .md file on disk. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page (obtainable with list_collective_pages) + :param title: the new title + :return: the updated page metadata + """ + return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={ + 'title': title, + })) + + @tool + @dangerous_tool + async def move_page(collective_id: int, page_id: int, parent_id: int): + """ + Move a page under a different parent within the same collective. + Use parent_id = landing page id to move the page to top-level. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page to move (obtainable with list_collective_pages) + :param parent_id: the id of the new parent page (obtainable with list_collective_pages) + :return: the updated page metadata + """ + return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}', json={ + 'parentId': parent_id, + })) + + @tool + @dangerous_tool + async def set_page_emoji(collective_id: int, page_id: int, emoji: str): + """ + Set or clear the emoji icon for a Collectives page. + The emoji is displayed in the page tree and title bar. Pass an empty string to clear. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page (obtainable with list_collective_pages) + :param emoji: a single emoji character (e.g. "📝"), or empty string to clear + :return: the updated page metadata + """ + return json.dumps(await nc.ocs('PUT', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}/emoji', json={ + 'emoji': emoji, + })) + + @tool + @dangerous_tool + async def trash_page(collective_id: int, page_id: int): + """ + Soft-delete a page by moving it to the collective's page trash. + Trashed pages can be restored with restore_page until a background job purges them after + the admin-configured retention period. Use delete_page_permanently on a trashed page to + remove it immediately. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the page (obtainable with list_collective_pages) + :return: the trashed page metadata with trashTimestamp set + """ + return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/{page_id}')) + + @tool + @dangerous_tool + async def restore_page(collective_id: int, page_id: int): + """ + Restore a previously trashed page back to the collective. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the trashed page (obtainable with list_page_trash) + :return: the restored page metadata with trashTimestamp cleared + """ + return json.dumps(await nc.ocs('PATCH', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}')) + + @tool + @dangerous_tool + async def delete_page_permanently(collective_id: int, page_id: int): + """ + Permanently delete a page that is already in the trash. This cannot be undone. + To delete a live page, call trash_page first, then this tool. + :param collective_id: the id of the collective (obtainable with list_collectives) + :param page_id: the id of the trashed page (obtainable with list_page_trash) + :return: confirmation of permanent deletion + """ + return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collective_id}/pages/trash/{page_id}')) + + return [ + list_collectives, + list_collective_pages, + get_page, + get_page_content, + list_page_trash, + create_page, + update_page_content, + rename_page, + move_page, + set_page_emoji, + trash_page, + restore_page, + delete_page_permanently, + ] + + +def get_category_name(): + return "Collectives" + + +async def is_available(nc: AsyncNextcloudApp): + try: + await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives') + except: + return False + return True From 3d764e25fc53d7f95987680f1d0689867f91f609 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 26 Aug 2026 15:15:49 +0200 Subject: [PATCH 2/4] fix(collectives): Make update_page_content ensure there is an AI-edited note at the bottom Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/collectives.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ex_app/lib/all_tools/collectives.py b/ex_app/lib/all_tools/collectives.py index 4eb8a0d..9d939e1 100644 --- a/ex_app/lib/all_tools/collectives.py +++ b/ex_app/lib/all_tools/collectives.py @@ -121,6 +121,7 @@ async def update_page_content(collective_id: int, page_id: int, content: str): Replaces the entire page body. To append, first read with get_page_content and concatenate. If another user has the page open in the real-time editor, their session may overwrite this write on save - consider rename_page or trash_page for destructive intent instead. + Make sure that there is a note at the bottom of the page content, that this content was edited with Artificial Intelligence. :param collective_id: the id of the collective (obtainable with list_collectives) :param page_id: the id of the page (obtainable with list_collective_pages) :param content: the new markdown body for the page (replaces existing content) From 2df6b3d8ea284a4f6a01b1bb71c05ce361ea3e25 Mon Sep 17 00:00:00 2001 From: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:00:46 +0200 Subject: [PATCH 3/4] fix(collectives): let update_page_content append the AI-edit note itself 3d764e2 asks the model, through the tool description, to make sure a note is at the bottom of the page. This replaces that instruction with the tool appending the note on its own, which closes two gaps the instruction leaves open. The note is only there when the model remembers it. update_page_content replaces the whole page, and the agent normally builds the new body by reading the old one back with get_page_content first, so every edit is another chance to drop it. And the wording the model re-emits drifts between edits, which leaves no stable string to match against - so a note cannot be replaced, only added, and copies pile up. Appending it in the tool makes the note a property of the write rather than of the prompt, which is how the other write tools in this repo behave. The tool strips its own note before re-appending it, so exactly one survives any number of edits. The pattern matches only that note, on a line of its own, and tolerates what a markdown serializer can do to it on the way back - a dropped emoji or variation selector, backslash-escaped emphasis, CRLF - while leaving a blockquote a user wrote themselves alone, even one that names the Assistant. Removing the note closes only the seam it leaves behind; blank runs anywhere else on the page are kept byte-for-byte, which matters inside fenced code blocks. The docstrings of update_page_content and get_page_content now say the note is appended automatically and replaced rather than duplicated, so the model neither writes one itself nor tries to strip the one it reads back. Verified against Nextcloud 32.0.8 / Collectives 3.6.1: two consecutive Assistant edits leave one note with the first edit's content intact, a human editing the page in the Text editor in between round-trips the note byte-identically, a user-authored blockquote mentioning the Assistant survives, and blank lines inside a fenced code block are untouched. Signed-off-by: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com> --- ex_app/lib/all_tools/collectives.py | 44 +++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/collectives.py b/ex_app/lib/all_tools/collectives.py index 9d939e1..cb79b36 100644 --- a/ex_app/lib/all_tools/collectives.py +++ b/ex_app/lib/all_tools/collectives.py @@ -1,12 +1,46 @@ # SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import json +import re from urllib.parse import quote from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool +# Unlike the other write tools, which append their AI note to a value they create, +# update_page_content replaces a whole page the agent usually read back first - so the +# note has to be stripped before it is re-appended, or it stacks once per edit. +_AI_DISCLAIMER = '> â„šī¸ **This page was edited with the help of Nextcloud AI Assistant.**' + +# Matches only our own note, on a line of its own, together with the newlines around it. +# Anchored to line starts so a blockquote elsewhere on the page is never touched, and +# tolerant of the emoji or its variation selector being dropped and of the emphasis +# markers being backslash-escaped, since the page may come back through a markdown +# serializer between two edits. +_DISCLAIMER_RE = re.compile( + r'(?P\n*)' + r'^[ \t]*>[ \t]*(?:\u2139\ufe0f?[ \t]*)?\\?\*\\?\*' + r'This page was edited with the help of Nextcloud AI Assistant\.?' + r'\\?\*\\?\*[ \t]*\r?$' + r'(?P\n*)', + re.MULTILINE, +) + + +def _close_gap(match) -> str: + # Removing the note leaves the blank lines from both of its sides stacked together. + # Rejoin with one blank line when it sat between two blocks, and with nothing when it + # sat at the very start or end. Only this seam is touched: blank runs elsewhere on the + # page are left alone, since they are significant inside fenced code blocks. + if match.group('before') and match.group('after'): + return '\n\n' + return '' + + +def _strip_ai_disclaimer(markdown: str) -> str: + return _DISCLAIMER_RE.sub(_close_gap, markdown) + async def get_tools(nc: AsyncNextcloudApp): @@ -67,6 +101,8 @@ async def get_page_content(collective_id: int, page_id: int): Get the Markdown content of a Collectives page. Fetches the underlying .md file via WebDAV. Returns an empty string for pages that have never been written to (newly created pages materialize their file on first write). + Pages last written by update_page_content end with its AI-authored note; that note is + part of the returned content and is replaced, not duplicated, on the next write. :param collective_id: the id of the collective (obtainable with list_collectives) :param page_id: the id of the page (obtainable with list_collective_pages) :return: the markdown content of the page, or empty string if the file has not been written yet @@ -121,7 +157,9 @@ async def update_page_content(collective_id: int, page_id: int, content: str): Replaces the entire page body. To append, first read with get_page_content and concatenate. If another user has the page open in the real-time editor, their session may overwrite this write on save - consider rename_page or trash_page for destructive intent instead. - Make sure that there is a note at the bottom of the page content, that this content was edited with Artificial Intelligence. + A note saying the page was edited with the help of the AI Assistant is appended + automatically - do not write one yourself, and leave any existing one in the content + you pass; it is replaced rather than duplicated. :param collective_id: the id of the collective (obtainable with list_collectives) :param page_id: the id of the page (obtainable with list_collective_pages) :param content: the new markdown body for the page (replaces existing content) @@ -131,9 +169,11 @@ async def update_page_content(collective_id: int, page_id: int, content: str): page = page_resp['page'] if isinstance(page_resp, dict) and 'page' in page_resp else page_resp user_id = await _user_id() url = await _page_webdav_url(user_id, page) + body = _strip_ai_disclaimer(content).rstrip() + stamped = f"{body}\n\n{_AI_DISCLAIMER}\n" if body else f"{_AI_DISCLAIMER}\n" await nc._session._create_adapter(True).request('PUT', url, headers={ 'Content-Type': 'text/markdown', - }, data=content) + }, data=stamped) return json.dumps({'status': 'success', 'page_id': page_id}) @tool From 9dd7962c2e8e798aa02a1419854135409e3edeb7 Mon Sep 17 00:00:00 2001 From: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:22:02 +0200 Subject: [PATCH 4/4] fix(collectives): surface failed requests instead of passing them on Three findings from the Copilot review on #159, all the same shape: a failed request was treated as a successful one. get_page_content only special-cased 404. Any other failure was handed back as if the response body were the page's markdown - in practice a DAV error document, which the agent would then summarize as page content. The 404 contract is unchanged and still comes first; every 4xx and 5xx now raises. update_page_content discarded the PUT response entirely and always reported success, so a refused write looked like a completed one to the agent and to the user. The realistic case is the one the tool's own docstring warns about: a page held open in the real-time editor is locked, the write comes back 423, and the tool said it had gone through. Both use response.raise_for_status(). The review suggested it verbatim for the read; the write is the same problem and gets the same treatment. It is also what nc_py_api's own check_error() uses internally. Its message carries status, reason and URL, and graph.py's handle_tool_error hands repr() of the exception back to the model, so the agent sees what failed rather than a bare status code. 2xx multi-status responses do not raise. is_available caught bare, which also swallows asyncio.CancelledError and turns a cancellation during tool discovery into "Collectives unavailable" while discovery carries on - freezing an incomplete tool list in the 60-second cache around it. It now catches Exception, so cancellation propagates. Signed-off-by: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com> --- ex_app/lib/all_tools/collectives.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/collectives.py b/ex_app/lib/all_tools/collectives.py index cb79b36..beaad6b 100644 --- a/ex_app/lib/all_tools/collectives.py +++ b/ex_app/lib/all_tools/collectives.py @@ -116,6 +116,7 @@ async def get_page_content(collective_id: int, page_id: int): }) if response.status_code == 404: return '' + response.raise_for_status() return response.text @tool @@ -171,9 +172,10 @@ async def update_page_content(collective_id: int, page_id: int, content: str): url = await _page_webdav_url(user_id, page) body = _strip_ai_disclaimer(content).rstrip() stamped = f"{body}\n\n{_AI_DISCLAIMER}\n" if body else f"{_AI_DISCLAIMER}\n" - await nc._session._create_adapter(True).request('PUT', url, headers={ + response = await nc._session._create_adapter(True).request('PUT', url, headers={ 'Content-Type': 'text/markdown', }, data=stamped) + response.raise_for_status() return json.dumps({'status': 'success', 'page_id': page_id}) @tool @@ -281,6 +283,6 @@ def get_category_name(): async def is_available(nc: AsyncNextcloudApp): try: await nc.ocs('GET', '/ocs/v2.php/apps/collectives/api/v1.0/collectives') - except: + except Exception: return False return True