From f9563394b87502a5d7f60f19df16f7702f0c1252 Mon Sep 17 00:00:00 2001 From: kyteinsky Date: Thu, 18 Jun 2026 14:54:37 +0530 Subject: [PATCH 1/3] feat: add on-demand memory storage and recall tools Signed-off-by: kyteinsky Assisted-by: Github Copilot:qwen-3-6-35b-a3b Assisted-by: Github Copilot:claude-sonnet-4-6 --- ex_app/lib/all_tools/lib/task_processing.py | 4 +- ex_app/lib/all_tools/memory.py | 409 ++++++++++++++++++++ 2 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 ex_app/lib/all_tools/memory.py diff --git a/ex_app/lib/all_tools/lib/task_processing.py b/ex_app/lib/all_tools/lib/task_processing.py index 34d1592..d01b054 100644 --- a/ex_app/lib/all_tools/lib/task_processing.py +++ b/ex_app/lib/all_tools/lib/task_processing.py @@ -3,9 +3,9 @@ import asyncio import typing -from niquests import ConnectionError, Timeout from nc_py_api import AsyncNextcloudApp, NextcloudException from nc_py_api.ex_app import LogLvl +from niquests import ConnectionError, Timeout from pydantic import BaseModel, ValidationError from ex_app.lib.logger import log @@ -75,7 +75,7 @@ async def run_task(nc: AsyncNextcloudApp, type, task_input): if task.status != "STATUS_SUCCESSFUL": raise Exception("Nextcloud TaskProcessing Task failed") - if not isinstance(task.output, dict) or all(x not in ["file", "output", "images", "slide_deck"] for x in task.output): + if not isinstance(task.output, dict) or all(x not in ["file", "output", "images", "slide_deck", "sources"] for x in task.output): raise Exception('"output" key not found in Nextcloud TaskProcessing task result') return task \ No newline at end of file diff --git a/ex_app/lib/all_tools/memory.py b/ex_app/lib/all_tools/memory.py new file mode 100644 index 0000000..ce6f5e3 --- /dev/null +++ b/ex_app/lib/all_tools/memory.py @@ -0,0 +1,409 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import asyncio +import json +import logging +import os +from urllib.parse import quote, unquote + +import niquests +from langchain_core.tools import tool +from nc_py_api import AsyncNextcloudApp +from nc_py_api.files.files_async import AsyncFilesAPI +from pydantic import BaseModel, ValidationError, computed_field, field_validator + +from ex_app.lib.all_tools.lib.decorator import safe_tool +from ex_app.lib.all_tools.lib.task_processing import run_task +from ex_app.lib.logger import log + +# The agent only looks at the memory files inside the Memories folder and considers that +# the root folder, the paths exchanged with it are all relative to that. + +CONTEXT_CHAT_SEARCH_TASK_TYPE = 'context_chat:context_chat_search' +FILES_PROVIDER_ID = 'files__default' +MEMORY_FOLDER_PATH = 'Assistant/Context Agent/Memories' # inside the user's Nextcloud home mount +MAX_MEMORY_FOLDER_DEPTH = 2 + + +class AgentFacingError(Exception): + """This exception's message would be returned to the agent to understand it's mistake""" + ... + + +class SourceItem(BaseModel): + id: str # source_id, in the form "appId__providerId: itemId" + label: str + icon: str + url: str + + @computed_field + @property + def file_id(self) -> int: + if not self.id.startswith(f'{FILES_PROVIDER_ID}: '): + raise ValueError(f'Source id does not start with expected prefix: {self.id}') + + try: + return int(self.id[len(f'{FILES_PROVIDER_ID}: '):]) + except ValueError as e: + raise ValueError( + f'Invalid source id format for extracting file_id: {self.id}' + ) from e + + +class SourcesList(BaseModel): + # {"id":"files__default: ","label":"string","icon":"string","url":"string"} + sources: list[SourceItem] + + @field_validator('sources', mode='before') + @classmethod + def parse_sources(cls, v: list) -> list: + result = [] + for item in v: + if isinstance(item, str): + try: + item = json.loads(item) + except json.JSONDecodeError as e: + raise ValueError(f'Invalid JSON in sources list: {item!r}') from e + result.append(item) + return result + + +# async def __get_file_content(nc: AsyncNextcloudApp, file_id: int): +# # Generate a direct download link using the fileId +# info = await nc.ocs('POST', '/ocs/v2.php/apps/dav/api/v1/direct', json={'fileId': file_id}, response_type='json') +# download_url = info.get('ocs', {}).get('data', {}).get('url', None) + +# if not download_url: +# raise RuntimeError('Could not generate download URL from file id') + +# # Download the file from the direct download URL +# response = await niquests.async_api.get(download_url) + +# return response.text + + +async def __is_context_chat_available(nc: AsyncNextcloudApp): + tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys() + return CONTEXT_CHAT_SEARCH_TASK_TYPE in tasktypes + + +async def __get_user_id(nc: AsyncNextcloudApp): + user_obj = await nc.ocs('GET', '/ocs/v2.php/cloud/user') + if 'id' not in user_obj: + raise RuntimeError('User ID not found in the user object') + return user_obj['id'] + + +def __validate_memory_path(path: str, *, allow_folder_path: bool = False) -> tuple[str, str]: + """returns tuple[full_path, memories_scoped_path]""" + if not path: + raise AgentFacingError('Memory path cannot be empty') + + # decode URL-encoded paths (including double-encoding) to catch obfuscated traversal attempts + decoded = unquote(unquote(unquote(path))) + + for candidate in (path, decoded): + if '\x00' in candidate: + raise RuntimeError('Memory path contains null byte') + + if '/..' in candidate or '../' in candidate or '..\\' in candidate: + raise RuntimeError('Agent tried to access directories beyond the memories folder') + + if not allow_folder_path and (not decoded.endswith('.md') and not decoded.endswith('.markdown')): + raise AgentFacingError('Memory file should be a markdown file') + + path_parts = [p for p in decoded.strip('/').split('/') if p] + if len(path_parts) > MAX_MEMORY_FOLDER_DEPTH + 1: # +1 for the filename itself + raise AgentFacingError(f'Memory path exceeds maximum depth of {MAX_MEMORY_FOLDER_DEPTH}') + + # resolve the full absolute path and verify it stays within the memory folder + full_path = os.path.normpath(os.path.join(MEMORY_FOLDER_PATH, decoded.lstrip('/'))) + + if not full_path.startswith(MEMORY_FOLDER_PATH): + raise RuntimeError('Agent tried to access directories beyond the memories folder') + + # url-safe path + return (quote(full_path, safe='/'), decoded.lstrip('/')) + + +async def __create_folders_if_not_exists(nc: AsyncNextcloudApp, adapter: niquests.AsyncSession, user_id: str, scoped_path: str): + """ + Ensures all necessary folders exist for the given scoped_path (relative to the user's DAV root). + First checks and recursively creates the base memories folder, then checks and creates + any subfolders within it derived from scoped_path (excluding the filename). + """ + base_memories_path = quote(MEMORY_FOLDER_PATH, safe='/') + + # Ensure base memories folder exists + propfind = await adapter.request( + 'PROPFIND', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{base_memories_path}", + headers={'Depth': '0'}, + ) + if propfind.status_code == 404: + base_parts = [p for p in base_memories_path.split('/') if p] + for i in range(1, len(base_parts) + 1): + folder_path = '/'.join(base_parts[:i]) + r = await adapter.request('MKCOL', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{folder_path}") + if r.status_code not in (200, 201, 405): # 405 = already exists + raise RuntimeError(f'Failed to create base memory folder {folder_path}: {r.status_code}') + + # Ensure subfolders inside the memories base folder exist (exclude the filename) + scoped_parts = [p for p in scoped_path.split('/') if p] + scoped_folder_parts = scoped_parts[:-1] + if not scoped_folder_parts: + return # file is in the memories root, no subfolder needed + + full_subfolder_path = quote(f"{MEMORY_FOLDER_PATH}/{'/'.join(scoped_folder_parts)}", safe='/') + propfind = await adapter.request( + 'PROPFIND', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_subfolder_path}", + headers={'Depth': '0'}, + ) + if propfind.status_code == 404: + base_parts = [p for p in base_memories_path.split('/') if p] + all_parts = base_parts + scoped_folder_parts + for i in range(len(base_parts) + 1, len(all_parts) + 1): + folder_path = '/'.join(all_parts[:i]) + r = await adapter.request('MKCOL', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{folder_path}") + if r.status_code not in (200, 201, 405): # 405 = already exists + raise RuntimeError(f'Failed to create memory subfolder {folder_path}: {r.status_code}') + + +async def get_tools(nc: AsyncNextcloudApp): + + @tool + @safe_tool + async def list_memory_tree(depth: int = 2): + """ + Recursively list the memories stored in a file tree structure. Max depth is 2. + :return: All the memory folders and filenames at the given depth in the following form: + + ```json + [ + {"id": 42, "name": "work", "children": [ + {"id": 43, "name": "todo.md", "children": null}, + {"id": 44, "name": "projects", "children": [ + {"id": 45, "name": "alpha.md", "children": null} + ]}, + {"id": 46, "name": "issues", "children": [] + ]}, + {"id": 50, "name": "notes.md", "children": null} + ] + ``` + """ + + files_handle = AsyncFilesAPI(nc._session) + fsnode_list = await files_handle.listdir( + path=MEMORY_FOLDER_PATH, + depth=min(depth, MAX_MEMORY_FOLDER_DEPTH), + ) + + prefix = MEMORY_FOLDER_PATH.rstrip('/') + '/' + + # Build a lookup of scoped_path -> node dict + nodes_by_path: dict[str, dict] = {} + for node in fsnode_list: + scoped = node.user_path.removeprefix(prefix).rstrip('/') + if not scoped: + continue # skip the root memories folder itself + nodes_by_path[scoped] = { + 'id': node.info.fileid, + 'name': node.name, + 'children': [] if node.is_dir else None, + } + + # Wire up parent-child relationships + roots = [] + for scoped, node_dict in nodes_by_path.items(): + parent_path = scoped.rsplit('/', 1)[0] if '/' in scoped else None + if parent_path and parent_path in nodes_by_path: + nodes_by_path[parent_path]['children'].append(node_dict) + else: + roots.append(node_dict) + + return roots + + @tool + @safe_tool + async def load_memory(path: str): + """ + Load one particular memory from the memory store identified by full path. + Memory is structured like a file system consisting of markdown files with a max depth of 2. + The memory files are encouraged to be stored in a structed pattern so they are easy to find + later on with folders and subfolders calassifying them based on the subject matter, and + the file/memory names indicating the contents of the memory. + Examples of memory paths would be "/abc/xyz.md", "/abc/def/xyz.md", "/xyz.md". + :param path: The full file path of the memory separated by and started with a forward slash (/), and the file name should end in either .md or .markdown + :return: The requested memory text + """ + try: + full_path, _ = __validate_memory_path(path) + except AgentFacingError as e: + return {'error': str(e)} + except Exception as e: + log(nc, logging.ERROR, f'Memory path validation failed: {e}') + return {'error': 'Invalid memory path given'} + + # let the user-scoped request errors reach the agent, although it leaks the full path of the memory + user_id = await __get_user_id(nc) + response = await nc._session._create_adapter(True).request( + 'GET', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", + headers={"Content-Type": "application/json"}, + ) + return response.text + + @tool + @safe_tool + async def store_memory(path: str, content: str): + """ + Stores a complete memory file overwriting it if it already exists. + This can also be used for editing memories but the full content of the memory must be passed. + Memory is structured like a file system consisting of markdown files with a max depth of 2. + The memory files are encouraged to be stored in a structed pattern so they are easy to find + later on with folders and subfolders calassifying them based on the subject matter, and + the file/memory names indicating the contents of the memory. + Examples of memory paths would be "/abc/xyz.md", "/abc/def/xyz.md", "/xyz.md". + :param path: The full file path of the memory separated by and started with a forward slash (/), and the file name should end in either .md or .markdown + :param content: The text content to store in the memory file. + :return: Status of the operation. + """ + try: + full_path, scoped_path = __validate_memory_path(path) + except AgentFacingError as e: + return {'error': str(e)} + except Exception as e: + log(nc, logging.ERROR, f'Memory path validation failed: {e}') + return {'error': 'Invalid memory path given'} + + user_id = await __get_user_id(nc) + adapter = nc._session._create_adapter(True) + + # Ensure all parent folders exist + await __create_folders_if_not_exists(nc, adapter, user_id, scoped_path) + + response = await adapter.request( + 'PUT', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", + headers={"Content-Type": "text/markdown"}, + data=content, + ) + return {"status": "success", "path": path} + + @tool + @safe_tool + async def delete_memory(path: str): + """ + Deletes a particular memory file identified by a full file/memory path. + Memory is structured like a file system consisting of markdown files with a max depth of 2. + The memory files are encouraged to be stored in a structed pattern so they are easy to find + later on with folders and subfolders calassifying them based on the subject matter, and + the file/memory names indicating the contents of the memory. + Examples of memory paths would be "/abc/xyz.md", "/abc/def/xyz.md", "/xyz.md". + :param path: The full file path of the memory separated by and started with a forward slash (/), and the file name should end in either .md or .markdown + :return: Status of the deletion operation. + """ + if not path.endswith('.md') and not path.endswith('.markdown'): + return {'error': ( + 'Only individual memory files can be deleted using this tool.' + ' Use "delete_memory_folder" to delete entire folders/categories of memories.' + )} + + try: + full_path, _ = __validate_memory_path(path) + except AgentFacingError as e: + return {'error': str(e)} + except Exception as e: + log(nc, logging.ERROR, f'Memory path validation failed: {e}') + return {'error': 'Invalid memory path given'} + + user_id = await __get_user_id(nc) + await nc._session._create_adapter(True).request( + 'DELETE', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", + ) + return {"status": "success", "path": path} + + @tool + async def delete_memory_folder(path: str): + """ + Deletes the whole folder of memories by a full path. + Memory is structured like a file system consisting of markdown files with a max depth of 2. + The memory files are encouraged to be stored in a structed pattern so they are easy to find + later on with folders and subfolders calassifying them based on the subject matter, and + the file/memory names indicating the contents of the memory. + Examples of memory folder paths would be "/abc" and "/abc/def. + :param path: The full path of the folder separated by and started with a forward slash (/). + :return: Status of the deletion operation. + """ + try: + full_path, _ = __validate_memory_path(path, allow_folder_path=True) + except AgentFacingError as e: + return {'error': str(e)} + except Exception as e: + log(nc, logging.ERROR, f'Memory path validation failed: {e}') + return {'error': 'Invalid memory path given'} + + user_id = await __get_user_id(nc) + await nc._session._create_adapter(True).request( + 'DELETE', + f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", + ) + return {"status": "success", "path": path} + + @tool + @safe_tool + async def search_memories(query: str, k: int = 5) -> list[str]: + """ + Do a semantic search over the contents of all the stored memories. + :param query: The subject matter to search for + :param k: No. of memories to return. Max 10 memories can be requested. + :return: Top k matched memories + """ + + files_handle = AsyncFilesAPI(nc._session) + memories_folder_fsnode = await files_handle.by_path(MEMORY_FOLDER_PATH) + + task_input = { + 'prompt': query, + 'scopeType': 'source', + 'scopeList': [f'{FILES_PROVIDER_ID}: {memories_folder_fsnode.info.fileid}'], + 'scopeListMeta': '', + 'limit': min(k, 10), # silent truncation + } + task_output = (await run_task(nc, CONTEXT_CHAT_SEARCH_TASK_TYPE, task_input)).output + try: + sources_list: SourcesList = SourcesList.model_validate(task_output) + except ValidationError as e: + raise RuntimeError(f'Malformed sources found in the output of Context Chat Search task: {e}') + + if sources_list.sources == []: + raise RuntimeError('No memories found with the given query') + + async def fetch(file_id: int) -> str: + fsnode = await files_handle.by_id(file_id) + if fsnode is None: + log(nc, logging.WARNING, f'Could not fetch file by id: {file_id}') + return '' + return (await files_handle.download(fsnode)).decode(errors='replace') + + return await asyncio.gather(*[fetch(source.file_id) for source in sources_list.sources]) + + return [ + list_memory_tree, + load_memory, + store_memory, + delete_memory, + delete_memory_folder, + *([search_memories] if await __is_context_chat_available(nc) else []), + ] + + +def get_category_name(): + return 'Memories' + + +async def is_available(nc: AsyncNextcloudApp): + return True From 175490743332c644cb4f6e8910dc316bd5bb99c7 Mon Sep 17 00:00:00 2001 From: kyteinsky Date: Thu, 18 Jun 2026 15:46:50 +0530 Subject: [PATCH 2/3] fix: address review comments Signed-off-by: kyteinsky --- ex_app/lib/all_tools/memory.py | 98 ++++++++++++---------------------- 1 file changed, 34 insertions(+), 64 deletions(-) diff --git a/ex_app/lib/all_tools/memory.py b/ex_app/lib/all_tools/memory.py index ce6f5e3..7b37a64 100644 --- a/ex_app/lib/all_tools/memory.py +++ b/ex_app/lib/all_tools/memory.py @@ -88,13 +88,6 @@ async def __is_context_chat_available(nc: AsyncNextcloudApp): return CONTEXT_CHAT_SEARCH_TASK_TYPE in tasktypes -async def __get_user_id(nc: AsyncNextcloudApp): - user_obj = await nc.ocs('GET', '/ocs/v2.php/cloud/user') - if 'id' not in user_obj: - raise RuntimeError('User ID not found in the user object') - return user_obj['id'] - - def __validate_memory_path(path: str, *, allow_folder_path: bool = False) -> tuple[str, str]: """returns tuple[full_path, memories_scoped_path]""" if not path: @@ -178,19 +171,13 @@ async def get_tools(nc: AsyncNextcloudApp): async def list_memory_tree(depth: int = 2): """ Recursively list the memories stored in a file tree structure. Max depth is 2. - :return: All the memory folders and filenames at the given depth in the following form: - - ```json - [ - {"id": 42, "name": "work", "children": [ - {"id": 43, "name": "todo.md", "children": null}, - {"id": 44, "name": "projects", "children": [ - {"id": 45, "name": "alpha.md", "children": null} - ]}, - {"id": 46, "name": "issues", "children": [] - ]}, - {"id": 50, "name": "notes.md", "children": null} - ] + :return: All the memory folders and filenames at the given depth as a newline-separated list of paths, e.g.: + + ``` + /work/career_plans.md + /work/projects/alpha_project.md + /personal/hobbies + /general_notes.md ``` """ @@ -202,39 +189,20 @@ async def list_memory_tree(depth: int = 2): prefix = MEMORY_FOLDER_PATH.rstrip('/') + '/' - # Build a lookup of scoped_path -> node dict - nodes_by_path: dict[str, dict] = {} + paths = [] for node in fsnode_list: scoped = node.user_path.removeprefix(prefix).rstrip('/') if not scoped: continue # skip the root memories folder itself - nodes_by_path[scoped] = { - 'id': node.info.fileid, - 'name': node.name, - 'children': [] if node.is_dir else None, - } + paths.append('/' + scoped) - # Wire up parent-child relationships - roots = [] - for scoped, node_dict in nodes_by_path.items(): - parent_path = scoped.rsplit('/', 1)[0] if '/' in scoped else None - if parent_path and parent_path in nodes_by_path: - nodes_by_path[parent_path]['children'].append(node_dict) - else: - roots.append(node_dict) - - return roots + return '\n'.join(paths) @tool @safe_tool async def load_memory(path: str): """ Load one particular memory from the memory store identified by full path. - Memory is structured like a file system consisting of markdown files with a max depth of 2. - The memory files are encouraged to be stored in a structed pattern so they are easy to find - later on with folders and subfolders calassifying them based on the subject matter, and - the file/memory names indicating the contents of the memory. - Examples of memory paths would be "/abc/xyz.md", "/abc/def/xyz.md", "/xyz.md". :param path: The full file path of the memory separated by and started with a forward slash (/), and the file name should end in either .md or .markdown :return: The requested memory text """ @@ -247,7 +215,7 @@ async def load_memory(path: str): return {'error': 'Invalid memory path given'} # let the user-scoped request errors reach the agent, although it leaks the full path of the memory - user_id = await __get_user_id(nc) + user_id = await nc.user response = await nc._session._create_adapter(True).request( 'GET', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", @@ -262,10 +230,11 @@ async def store_memory(path: str, content: str): Stores a complete memory file overwriting it if it already exists. This can also be used for editing memories but the full content of the memory must be passed. Memory is structured like a file system consisting of markdown files with a max depth of 2. - The memory files are encouraged to be stored in a structed pattern so they are easy to find - later on with folders and subfolders calassifying them based on the subject matter, and + The memory files are encouraged to be stored in a structured pattern so they are easy to find + later on with folders and subfolders classifying them based on the subject matter, and the file/memory names indicating the contents of the memory. - Examples of memory paths would be "/abc/xyz.md", "/abc/def/xyz.md", "/xyz.md". + Good examples: "/work/career_plans.md", "/work/projects/alpha_project.md", "/personal/hobbies/reading_list.md", "/general_notes.md". + Use lowercase names with underscores. Folder names should be broad categories (e.g. "work", "personal", "health"). :param path: The full file path of the memory separated by and started with a forward slash (/), and the file name should end in either .md or .markdown :param content: The text content to store in the memory file. :return: Status of the operation. @@ -278,7 +247,7 @@ async def store_memory(path: str, content: str): log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} - user_id = await __get_user_id(nc) + user_id = await nc.user adapter = nc._session._create_adapter(True) # Ensure all parent folders exist @@ -297,11 +266,6 @@ async def store_memory(path: str, content: str): async def delete_memory(path: str): """ Deletes a particular memory file identified by a full file/memory path. - Memory is structured like a file system consisting of markdown files with a max depth of 2. - The memory files are encouraged to be stored in a structed pattern so they are easy to find - later on with folders and subfolders calassifying them based on the subject matter, and - the file/memory names indicating the contents of the memory. - Examples of memory paths would be "/abc/xyz.md", "/abc/def/xyz.md", "/xyz.md". :param path: The full file path of the memory separated by and started with a forward slash (/), and the file name should end in either .md or .markdown :return: Status of the deletion operation. """ @@ -319,7 +283,7 @@ async def delete_memory(path: str): log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} - user_id = await __get_user_id(nc) + user_id = await nc.user await nc._session._create_adapter(True).request( 'DELETE', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", @@ -330,11 +294,6 @@ async def delete_memory(path: str): async def delete_memory_folder(path: str): """ Deletes the whole folder of memories by a full path. - Memory is structured like a file system consisting of markdown files with a max depth of 2. - The memory files are encouraged to be stored in a structed pattern so they are easy to find - later on with folders and subfolders calassifying them based on the subject matter, and - the file/memory names indicating the contents of the memory. - Examples of memory folder paths would be "/abc" and "/abc/def. :param path: The full path of the folder separated by and started with a forward slash (/). :return: Status of the deletion operation. """ @@ -346,7 +305,7 @@ async def delete_memory_folder(path: str): log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} - user_id = await __get_user_id(nc) + user_id = await nc.user await nc._session._create_adapter(True).request( 'DELETE', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_path}", @@ -355,12 +314,12 @@ async def delete_memory_folder(path: str): @tool @safe_tool - async def search_memories(query: str, k: int = 5) -> list[str]: + async def search_memories(query: str, k: int = 5) -> list[dict[str, str]]: """ Do a semantic search over the contents of all the stored memories. :param query: The subject matter to search for :param k: No. of memories to return. Max 10 memories can be requested. - :return: Top k matched memories + :return: Top k matched memories and their paths """ files_handle = AsyncFilesAPI(nc._session) @@ -382,12 +341,23 @@ async def search_memories(query: str, k: int = 5) -> list[str]: if sources_list.sources == []: raise RuntimeError('No memories found with the given query') - async def fetch(file_id: int) -> str: + prefix = MEMORY_FOLDER_PATH.rstrip('/') + '/' + + async def fetch(file_id: int) -> dict[str, str]: + """ + :return: {'path': string, 'content: string} + """ fsnode = await files_handle.by_id(file_id) + filepath = '/' + fsnode.user_path.removeprefix(prefix).rstrip('/') + if fsnode is None: log(nc, logging.WARNING, f'Could not fetch file by id: {file_id}') - return '' - return (await files_handle.download(fsnode)).decode(errors='replace') + return {'path': filepath, 'content': ''} + + return { + 'path': filepath, + 'content': (await files_handle.download(fsnode)).decode(errors='replace'), + } return await asyncio.gather(*[fetch(source.file_id) for source in sources_list.sources]) From 1d997dc47988d5454313a2f00249927dcca33e44 Mon Sep 17 00:00:00 2001 From: kyteinsky Date: Thu, 18 Jun 2026 17:19:01 +0530 Subject: [PATCH 3/3] fix: expose memory tools only when the assistant's folder exists Signed-off-by: kyteinsky --- ex_app/lib/all_tools/memory.py | 105 ++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/ex_app/lib/all_tools/memory.py b/ex_app/lib/all_tools/memory.py index 7b37a64..9d538bf 100644 --- a/ex_app/lib/all_tools/memory.py +++ b/ex_app/lib/all_tools/memory.py @@ -10,6 +10,7 @@ import niquests from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp +from nc_py_api._exceptions import NextcloudExceptionNotFound from nc_py_api.files.files_async import AsyncFilesAPI from pydantic import BaseModel, ValidationError, computed_field, field_validator @@ -22,7 +23,7 @@ CONTEXT_CHAT_SEARCH_TASK_TYPE = 'context_chat:context_chat_search' FILES_PROVIDER_ID = 'files__default' -MEMORY_FOLDER_PATH = 'Assistant/Context Agent/Memories' # inside the user's Nextcloud home mount +MEMORIES_RELATIVE_FOLDER_PATH = 'Context Agent/Memories' # inside the user's Assistant folder MAX_MEMORY_FOLDER_DEPTH = 2 @@ -69,26 +70,12 @@ def parse_sources(cls, v: list) -> list: return result -# async def __get_file_content(nc: AsyncNextcloudApp, file_id: int): -# # Generate a direct download link using the fileId -# info = await nc.ocs('POST', '/ocs/v2.php/apps/dav/api/v1/direct', json={'fileId': file_id}, response_type='json') -# download_url = info.get('ocs', {}).get('data', {}).get('url', None) - -# if not download_url: -# raise RuntimeError('Could not generate download URL from file id') - -# # Download the file from the direct download URL -# response = await niquests.async_api.get(download_url) - -# return response.text - - -async def __is_context_chat_available(nc: AsyncNextcloudApp): +async def __is_context_chat_available(nc: AsyncNextcloudApp, memories_folder_path: str): tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys() return CONTEXT_CHAT_SEARCH_TASK_TYPE in tasktypes -def __validate_memory_path(path: str, *, allow_folder_path: bool = False) -> tuple[str, str]: +def __validate_memory_path(path: str, memories_folder_path: str, *, allow_folder_path: bool = False) -> tuple[str, str]: """returns tuple[full_path, memories_scoped_path]""" if not path: raise AgentFacingError('Memory path cannot be empty') @@ -111,22 +98,22 @@ def __validate_memory_path(path: str, *, allow_folder_path: bool = False) -> tup raise AgentFacingError(f'Memory path exceeds maximum depth of {MAX_MEMORY_FOLDER_DEPTH}') # resolve the full absolute path and verify it stays within the memory folder - full_path = os.path.normpath(os.path.join(MEMORY_FOLDER_PATH, decoded.lstrip('/'))) + full_path = os.path.normpath(os.path.join(memories_folder_path, decoded.lstrip('/'))) - if not full_path.startswith(MEMORY_FOLDER_PATH): + if not full_path.startswith(memories_folder_path): raise RuntimeError('Agent tried to access directories beyond the memories folder') # url-safe path return (quote(full_path, safe='/'), decoded.lstrip('/')) -async def __create_folders_if_not_exists(nc: AsyncNextcloudApp, adapter: niquests.AsyncSession, user_id: str, scoped_path: str): +async def __create_folders_if_not_exists(nc: AsyncNextcloudApp, adapter: niquests.AsyncSession, memories_folder_path: str, user_id: str, scoped_path: str): """ Ensures all necessary folders exist for the given scoped_path (relative to the user's DAV root). First checks and recursively creates the base memories folder, then checks and creates any subfolders within it derived from scoped_path (excluding the filename). """ - base_memories_path = quote(MEMORY_FOLDER_PATH, safe='/') + base_memories_path = quote(memories_folder_path, safe='/') # Ensure base memories folder exists propfind = await adapter.request( @@ -148,7 +135,7 @@ async def __create_folders_if_not_exists(nc: AsyncNextcloudApp, adapter: niquest if not scoped_folder_parts: return # file is in the memories root, no subfolder needed - full_subfolder_path = quote(f"{MEMORY_FOLDER_PATH}/{'/'.join(scoped_folder_parts)}", safe='/') + full_subfolder_path = quote(f"{memories_folder_path}/{'/'.join(scoped_folder_parts)}", safe='/') propfind = await adapter.request( 'PROPFIND', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{full_subfolder_path}", @@ -164,7 +151,36 @@ async def __create_folders_if_not_exists(nc: AsyncNextcloudApp, adapter: niquest raise RuntimeError(f'Failed to create memory subfolder {folder_path}: {r.status_code}') +async def __get_assistant_folder_path(nc: AsyncNextcloudApp) -> str | None: + try: + res = await nc.ocs( + 'GET', + '/ocs/v2.php/apps/assistant/api/v1/assistant-folder-path', + response_type='json', + ) + except NextcloudExceptionNotFound: + return None + except Exception as e: + await log( + nc, + logging.WARNING, + f"Failed to fetch Assistant's folder in user's home dir, user: {await nc.user}, exc: {e}", + ) + return None + + # //files/Assistant + folder_path = res.get('ocs', {}).get('data', {}).get('path') + + if not folder_path or not isinstance(folder_path, str): + return None + + return folder_path.removeprefix(f'/{await nc.user}/files/') + + async def get_tools(nc: AsyncNextcloudApp): + assistant_folder_path = await __get_assistant_folder_path(nc) + memories_folder_path = f'{(assistant_folder_path or "").removesuffix("/")}/{MEMORIES_RELATIVE_FOLDER_PATH}' + @tool @safe_tool @@ -183,11 +199,11 @@ async def list_memory_tree(depth: int = 2): files_handle = AsyncFilesAPI(nc._session) fsnode_list = await files_handle.listdir( - path=MEMORY_FOLDER_PATH, + path=memories_folder_path, depth=min(depth, MAX_MEMORY_FOLDER_DEPTH), ) - prefix = MEMORY_FOLDER_PATH.rstrip('/') + '/' + prefix = memories_folder_path.rstrip('/') + '/' paths = [] for node in fsnode_list: @@ -207,11 +223,11 @@ async def load_memory(path: str): :return: The requested memory text """ try: - full_path, _ = __validate_memory_path(path) + full_path, _ = __validate_memory_path(path, memories_folder_path) except AgentFacingError as e: return {'error': str(e)} except Exception as e: - log(nc, logging.ERROR, f'Memory path validation failed: {e}') + await log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} # let the user-scoped request errors reach the agent, although it leaks the full path of the memory @@ -240,18 +256,18 @@ async def store_memory(path: str, content: str): :return: Status of the operation. """ try: - full_path, scoped_path = __validate_memory_path(path) + full_path, scoped_path = __validate_memory_path(path, memories_folder_path) except AgentFacingError as e: return {'error': str(e)} except Exception as e: - log(nc, logging.ERROR, f'Memory path validation failed: {e}') + await log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} user_id = await nc.user adapter = nc._session._create_adapter(True) # Ensure all parent folders exist - await __create_folders_if_not_exists(nc, adapter, user_id, scoped_path) + await __create_folders_if_not_exists(nc, adapter, memories_folder_path, user_id, scoped_path) response = await adapter.request( 'PUT', @@ -276,11 +292,11 @@ async def delete_memory(path: str): )} try: - full_path, _ = __validate_memory_path(path) + full_path, _ = __validate_memory_path(path, memories_folder_path) except AgentFacingError as e: return {'error': str(e)} except Exception as e: - log(nc, logging.ERROR, f'Memory path validation failed: {e}') + await log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} user_id = await nc.user @@ -298,11 +314,11 @@ async def delete_memory_folder(path: str): :return: Status of the deletion operation. """ try: - full_path, _ = __validate_memory_path(path, allow_folder_path=True) + full_path, _ = __validate_memory_path(path, memories_folder_path, allow_folder_path=True) except AgentFacingError as e: return {'error': str(e)} except Exception as e: - log(nc, logging.ERROR, f'Memory path validation failed: {e}') + await log(nc, logging.ERROR, f'Memory path validation failed: {e}') return {'error': 'Invalid memory path given'} user_id = await nc.user @@ -322,8 +338,11 @@ async def search_memories(query: str, k: int = 5) -> list[dict[str, str]]: :return: Top k matched memories and their paths """ + if query == '': + raise RuntimeError('Query must not be empty') + files_handle = AsyncFilesAPI(nc._session) - memories_folder_fsnode = await files_handle.by_path(MEMORY_FOLDER_PATH) + memories_folder_fsnode = await files_handle.by_path(memories_folder_path) task_input = { 'prompt': query, @@ -341,7 +360,7 @@ async def search_memories(query: str, k: int = 5) -> list[dict[str, str]]: if sources_list.sources == []: raise RuntimeError('No memories found with the given query') - prefix = MEMORY_FOLDER_PATH.rstrip('/') + '/' + prefix = memories_folder_path.rstrip('/') + '/' async def fetch(file_id: int) -> dict[str, str]: """ @@ -351,7 +370,7 @@ async def fetch(file_id: int) -> dict[str, str]: filepath = '/' + fsnode.user_path.removeprefix(prefix).rstrip('/') if fsnode is None: - log(nc, logging.WARNING, f'Could not fetch file by id: {file_id}') + await log(nc, logging.WARNING, f'Could not fetch file by id: {file_id}') return {'path': filepath, 'content': ''} return { @@ -362,12 +381,14 @@ async def fetch(file_id: int) -> dict[str, str]: return await asyncio.gather(*[fetch(source.file_id) for source in sources_list.sources]) return [ - list_memory_tree, - load_memory, - store_memory, - delete_memory, - delete_memory_folder, - *([search_memories] if await __is_context_chat_available(nc) else []), + *([ + list_memory_tree, + load_memory, + store_memory, + delete_memory, + delete_memory_folder, + *([search_memories] if await __is_context_chat_available(nc, memories_folder_path) else []), + ] if assistant_folder_path else []), ]