From 2c4e1fce8f40b0cb5028f1afcb184b6e58c33041 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 29 Jun 2026 11:13:36 -0500 Subject: [PATCH] refac --- backend/open_webui/routers/memories.py | 83 +++++++-- backend/open_webui/tools/builtin.py | 81 ++++++++- backend/open_webui/utils/memory.py | 232 ++++++++++++++++++++++++- backend/open_webui/utils/tools.py | 4 + 4 files changed, 374 insertions(+), 26 deletions(-) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 0ea21c1ed0..971c4de51f 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -14,7 +14,15 @@ from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_verified_user -from open_webui.utils.memory import clean_memory_content, clean_memory_path, memory_vector_text, validate_memory_operations +from open_webui.utils.memory import ( + clean_memory_content, + clean_memory_path, + list_memory_path_groups, + memory_vector_text, + read_memory_path_rows, + search_memory_rows, + validate_memory_operations, +) from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -96,6 +104,19 @@ class SearchMemoriesForm(BaseModel): limit: int = 20 +class ListMemoryPathsForm(BaseModel): + query: str | None = None + type: Literal['user', 'context', 'all'] = 'all' + limit: int = 100 + + +class ReadMemoryPathForm(BaseModel): + path: str + type: Literal['user', 'context', 'all'] = 'all' + include_children: bool = True + limit: int = 50 + + def _memory_metadata(memory: MemoryModel) -> dict: return { 'created_at': memory.created_at, @@ -316,23 +337,51 @@ async def search_memories( await check_memories_permission(user) memories = await Memories.get_memories_by_user_id(user.id) - if form_data.memory_id: - memories = [memory for memory in memories if memory.id == form_data.memory_id] - if form_data.type != 'all': - memories = [memory for memory in memories if memory.type == form_data.type] - path = clean_memory_path(form_data.path) - if path: - memories = [memory for memory in memories if (memory.path or '').startswith(path)] - query = (form_data.query or '').strip().lower() - if query: - memories = [ - memory - for memory in memories - if query in memory.content.lower() or query in (memory.path or '').lower() - ] + return search_memory_rows( + memories, + query=form_data.query, + path=form_data.path, + memory_id=form_data.memory_id, + memory_type=form_data.type, + limit=form_data.limit, + ) - limit = max(1, min(form_data.limit or 20, 100)) - return sorted(memories, key=lambda memory: memory.updated_at, reverse=True)[:limit] + +@router.post('/paths') +async def list_memory_paths( + form_data: ListMemoryPathsForm, + user=Depends(get_verified_user), +): + await check_memories_permission(user) + + memories = await Memories.get_memories_by_user_id(user.id) + return list_memory_path_groups( + memories, + query=form_data.query or '', + memory_type=form_data.type, + limit=form_data.limit, + ) + + +@router.post('/path') +async def read_memory_path( + form_data: ReadMemoryPathForm, + user=Depends(get_verified_user), +): + await check_memories_permission(user) + + memories = await Memories.get_memories_by_user_id(user.id) + result = read_memory_path_rows( + memories, + path=form_data.path, + memory_type=form_data.type, + include_children=form_data.include_children, + limit=form_data.limit, + ) + return { + **result, + 'memories': [memory.model_dump() for memory in result['memories']], + } ############################ diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 81fb7fc811..6193c10c53 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -34,9 +34,13 @@ from open_webui.routers.images import ( ) from open_webui.routers.memories import ( AddMemoryForm, + ListMemoryPathsForm, MemoryUpdateModel, + ReadMemoryPathForm, SearchMemoriesForm, UpdateMemoriesForm, + list_memory_paths as _list_memory_paths, + read_memory_path as _read_memory_path, search_memories as _search_memories, update_memories as _update_memories, update_memory_by_id, @@ -591,6 +595,71 @@ async def execute_code( # ============================================================================= +async def list_memory_paths( + query: str = '', + count: int = 100, + type: str = 'all', + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + List saved memory paths to find existing memory groups before writing or moving memories. + + :param query: Optional query to filter memory paths or contents + :param count: Maximum number of paths to return + :param type: "user", "context", or "all" + :return: JSON with memory paths, counts, children, and update times + """ + try: + user = UserModel(**__user__) if __user__ else None + result = await _list_memory_paths( + ListMemoryPathsForm( + query=query or None, + type=type if type in {'user', 'context', 'all'} else 'all', + limit=count, + ), + user, + ) + return json.dumps(result, ensure_ascii=False) + except Exception as e: + log.exception(f'list_memory_paths error: {e}') + return json.dumps({'error': str(e)}) + + +async def read_memory_path( + path: str, + count: int = 50, + type: str = 'all', + include_children: bool = True, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Read saved memories at a memory path, including nearby parent and child paths. + + :param path: Memory path to read + :param count: Maximum number of memories to return + :param type: "user", "context", or "all" + :param include_children: Include memories under child paths + :return: JSON with parent paths, child paths, and memories at the path + """ + try: + user = UserModel(**__user__) if __user__ else None + result = await _read_memory_path( + ReadMemoryPathForm( + path=path, + type=type if type in {'user', 'context', 'all'} else 'all', + include_children=include_children, + limit=count, + ), + user, + ) + return json.dumps(result, ensure_ascii=False) + except Exception as e: + log.exception(f'read_memory_path error: {e}') + return json.dumps({'error': str(e)}) + + async def search_memories( query: str = '', count: int = 5, @@ -601,12 +670,12 @@ async def search_memories( __user__: dict = None, ) -> str: """ - Search or browse saved user memories. + Search or browse saved memories by content, path, type, or memory ID. :param query: Optional query to search memory content and path :param count: Number of memories to return (default 5) :param type: "user", "context", or "all" - :param path: Optional memory path prefix + :param path: Optional memory path to search around :param memory_id: Optional exact memory ID to read :return: JSON with matching memories and their dates """ @@ -661,7 +730,7 @@ async def add_memory( :param content: The memory content to store :param type: Use "user" for facts/preferences about the user, or "context" for other durable context - :param path: Optional memory path + :param path: Optional stable memory address for grouping related memories :return: Confirmation that the memory was stored """ if __request__ is None: @@ -695,7 +764,9 @@ async def update_memory( Use type "user" for facts, preferences, or instructions about the user. Use type "context" for other durable context that may help future chats. - Use path when there is a clear path for the memory. Leave path empty when unsure. + Path is optional. Use it as a stable memory address to group related memories. + Prefer an existing path from list_memory_paths when one fits. + Leave path empty when no useful grouping is clear. Operation shapes: - {"action": "add", "content": "...", "type": "user"|"context", "path": "..."} @@ -736,7 +807,7 @@ async def replace_memory_content( :param memory_id: The ID of the memory to update :param content: The new content for the memory :param type: Optional "user" or "context" type for the updated memory - :param path: Optional memory path + :param path: Optional stable memory address for grouping related memories :return: Confirmation that the memory was updated """ if __request__ is None: diff --git a/backend/open_webui/utils/memory.py b/backend/open_webui/utils/memory.py index a7a249701f..180462f860 100644 --- a/backend/open_webui/utils/memory.py +++ b/backend/open_webui/utils/memory.py @@ -40,6 +40,214 @@ def memory_vector_text(content: str, path: str | None = None) -> str: return f'{path}\n{content}' if path else content +def memory_label(memory) -> str: + return f'{memory.path}: {memory.content}' if memory.path else memory.content + + +def _path_parts(path: str | None) -> list[str]: + return [part for part in (path or '').split('/') if part] + + +def _parent_path(path: str | None) -> str | None: + parts = _path_parts(path) + return '/'.join(parts[:-1]) if len(parts) > 1 else None + + +def _path_rank(memory_path: str | None, lookup_path: str | None) -> tuple | None: + if not lookup_path: + return None + + memory_path = clean_memory_path(memory_path) + lookup_path = clean_memory_path(lookup_path) + if not memory_path or not lookup_path: + return None + + if memory_path == lookup_path: + return (0, 0) + if memory_path.startswith(f'{lookup_path}/'): + return (1, len(_path_parts(memory_path)) - len(_path_parts(lookup_path))) + if lookup_path.startswith(f'{memory_path}/'): + return (2, len(_path_parts(lookup_path)) - len(_path_parts(memory_path))) + if _parent_path(memory_path) and _parent_path(memory_path) == _parent_path(lookup_path): + return (3, 0) + + memory_parts = set(_path_parts(memory_path)) + lookup_parts = set(_path_parts(lookup_path)) + shared = len(memory_parts & lookup_parts) + if shared: + return (4, -shared) + if _path_parts(memory_path)[-1:] == _path_parts(lookup_path)[-1:]: + return (5, 0) + + return None + + +def _memory_matches_query(memory, query: str) -> bool: + value = query.strip().lower() + if not value: + return True + return value in (memory.content or '').lower() or value in (memory.path or '').lower() + + +def search_memory_rows( + memories: list, + *, + query: str | None = None, + path: str | None = None, + memory_id: str | None = None, + memory_type: str = 'all', + limit: int = 20, +) -> list: + rows = list(memories or []) + if memory_id: + rows = [memory for memory in rows if memory.id == memory_id] + if memory_type != 'all': + rows = [memory for memory in rows if memory.type == memory_type] + + query = (query or '').strip() + lookup_path = clean_memory_path(path) + if lookup_path: + basename = _path_parts(lookup_path)[-1] if _path_parts(lookup_path) else lookup_path + + def related(memory) -> bool: + rank = _path_rank(memory.path, lookup_path) + if rank is not None: + return True + haystack = f'{memory.path or ""}\n{memory.content or ""}'.lower() + return lookup_path.lower() in haystack or basename.lower() in haystack + + rows = [memory for memory in rows if related(memory)] + + if query: + rows = [memory for memory in rows if _memory_matches_query(memory, query)] + + def sort_key(memory): + rank = _path_rank(memory.path, lookup_path) if lookup_path else None + return rank if rank is not None else (9, 0), -(memory.updated_at or 0) + + return sorted(rows, key=sort_key)[: max(1, min(limit or 20, 100))] + + +def list_memory_path_groups( + memories: list, + *, + query: str = '', + memory_type: str = 'all', + limit: int = 100, +) -> dict: + rows = [ + memory + for memory in (memories or []) + if (memory_type == 'all' or memory.type == memory_type) and _memory_matches_query(memory, query) + ] + grouped: dict[tuple[str | None, str], dict] = {} + for memory in rows: + key = (memory.path, memory.type) + group = grouped.setdefault( + key, + { + 'path': memory.path, + 'type': memory.type, + 'count': 0, + 'updated_at': 0, + 'children': [], + }, + ) + group['count'] += 1 + group['updated_at'] = max(group['updated_at'], memory.updated_at or 0) + + paths = [path for path, _ in grouped if path] + for group in grouped.values(): + path = group['path'] + if not path: + continue + prefix = f'{path}/' + children = [] + for candidate in paths: + if not candidate.startswith(prefix): + continue + remainder = candidate[len(prefix) :] + child = f'{prefix}{remainder.split("/", 1)[0]}' + if child not in children: + children.append(child) + group['children'] = children[:20] + + groups = sorted(grouped.values(), key=lambda item: item['updated_at'], reverse=True) + return {'paths': groups[: max(1, min(limit or 100, 500))], 'count': len(groups)} + + +def read_memory_path_rows( + memories: list, + *, + path: str, + memory_type: str = 'all', + include_children: bool = True, + limit: int = 50, +) -> dict: + lookup_path = clean_memory_path(path) + if not lookup_path: + raise HTTPException(status_code=400, detail='Memory path is required') + + rows = [memory for memory in (memories or []) if memory_type == 'all' or memory.type == memory_type] + path_set = {memory.path for memory in rows if memory.path} + parents = [ + '/'.join(_path_parts(lookup_path)[:idx]) + for idx in range(1, len(_path_parts(lookup_path))) + if '/'.join(_path_parts(lookup_path)[:idx]) in path_set + ] + children = sorted( + { + f'{lookup_path}/{memory.path[len(lookup_path) + 1 :].split("/", 1)[0]}' + for memory in rows + if memory.path and memory.path.startswith(f'{lookup_path}/') + } + ) + + def selected(memory) -> bool: + if memory.path == lookup_path: + return True + if memory.path in parents: + return True + return bool(include_children and memory.path and memory.path.startswith(f'{lookup_path}/')) + + selected_rows = [memory for memory in rows if selected(memory)] + + def sort_key(memory): + if memory.path == lookup_path: + return (0, 0, -(memory.updated_at or 0)) + if memory.path and memory.path.startswith(f'{lookup_path}/'): + return (1, len(_path_parts(memory.path)), -(memory.updated_at or 0)) + return (2, -len(_path_parts(memory.path)), -(memory.updated_at or 0)) + + return { + 'path': lookup_path, + 'parents': parents, + 'children': children[:50], + 'memories': sorted(selected_rows, key=sort_key)[: max(1, min(limit or 50, 100))], + } + + +def memory_path_hints(query: str, memories: list, limit: int = 6) -> list[str]: + lowered = (query or '').lower() + if not lowered: + return [] + + hints: list[str] = [] + for memory in memories or []: + path = memory.path + if not path or path in hints: + continue + parts = _path_parts(path) + last = parts[-1] if parts else path + if path.lower() in lowered or last.lower() in lowered: + hints.append(path) + elif any(len(part) >= 3 and part.lower() in lowered for part in parts): + hints.append(path) + if len(hints) >= limit: + break + return hints + + def validate_memory_operations(form_data) -> list[dict]: if not form_data.operations: raise HTTPException(status_code=400, detail='No memory operations provided') @@ -108,14 +316,26 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None except Exception as e: log.debug(e) - sections = {'user': [], 'context': []} + sections = {'user': [], 'neighborhood': [], 'context': []} seen_ids = set() for memory in sorted( [memory for memory in (all_memories or []) if memory.type == 'user'], key=lambda item: (item.path or '', item.updated_at), ): seen_ids.add(memory.id) - sections['user'].append(f'{memory.path}: {memory.content}' if memory.path else memory.content) + sections['user'].append(memory_label(memory)) + + for hint in memory_path_hints(query, all_memories): + for memory in search_memory_rows( + all_memories, + path=hint, + memory_type='context', + limit=4, + ): + if memory.id in seen_ids: + continue + seen_ids.add(memory.id) + sections['neighborhood'].append(memory_label(memory)) if results and hasattr(results, 'documents') and results.documents: for doc_idx, doc in enumerate(results.documents[0]): @@ -143,6 +363,8 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None parts = [] if sections['user']: parts.append('[User Memory]\n' + '\n'.join(f'- {memory}' for memory in sections['user'])) + if sections['neighborhood']: + parts.append('[Memory Neighborhood]\n' + '\n'.join(f'- {memory}' for memory in sections['neighborhood'])) if sections['context']: parts.append('[Relevant Context]\n' + '\n'.join(f'- {memory}' for memory in sections['context'])) if not parts: @@ -167,10 +389,12 @@ async def add_memory_context(request, form_data: dict, user, model: dict | None if end != -1: messages[0]['content'] = (content[:start] + content[end + len(MEMORY_CONTEXT_CLOSE) :]).strip() + user_parts = [part for part in parts if part.startswith('[User Memory]')] + context_parts = [part for part in parts if not part.startswith('[User Memory]')] rendered = '\n\n'.join( [ - parts[0][:user_limit] if parts and parts[0].startswith('[User Memory]') else '', - parts[-1][:context_limit] if parts and parts[-1].startswith('[Relevant Context]') else '', + '\n\n'.join(user_parts)[:user_limit], + '\n\n'.join(context_parts)[:context_limit], ] ).strip() if not rendered: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 3f3455d255..33f0441bfb 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -64,8 +64,10 @@ from open_webui.tools.builtin import ( list_knowledge, list_knowledge_bases, list_memories, + list_memory_paths, query_knowledge_bases, query_knowledge_files, + read_memory_path, replace_memory_content, replace_note_content, search_calendar_events, @@ -558,6 +560,8 @@ async def get_builtin_tools( builtin_functions.extend( [ search_memories, + list_memory_paths, + read_memory_path, list_memories, update_memory, add_memory,