diff --git a/migrations/env.py b/migrations/env.py index d4daa9f..062342f 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -2,10 +2,11 @@ from logging.config import fileConfig from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + from memlord.config import settings from memlord.models import Memory, MemoryTag, OAuthClient, SchemaVersion, Tag # noqa: F401 from memlord.models.base import Base -from sqlalchemy.ext.asyncio import create_async_engine config = context.config diff --git a/migrations/versions/2026_03_19_1754-ffd5e65054bc_add_workspace_description.py b/migrations/versions/2026_03_19_1754-ffd5e65054bc_add_workspace_description.py index a534df3..1637b83 100644 --- a/migrations/versions/2026_03_19_1754-ffd5e65054bc_add_workspace_description.py +++ b/migrations/versions/2026_03_19_1754-ffd5e65054bc_add_workspace_description.py @@ -5,6 +5,7 @@ Create Date: 2026-03-19 17:54:13.555231 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ # revision identifiers, used by Alembic. -revision: str = 'ffd5e65054bc' -down_revision: Union[str, Sequence[str], None] = '8f44baaf5579' +revision: str = "ffd5e65054bc" +down_revision: Union[str, Sequence[str], None] = "8f44baaf5579" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/migrations/versions/2026_03_20_0001-a1b2c3d4e5f6_add_revoked_tokens.py b/migrations/versions/2026_03_20_0001-a1b2c3d4e5f6_add_revoked_tokens.py index 646f7a3..c9752ef 100644 --- a/migrations/versions/2026_03_20_0001-a1b2c3d4e5f6_add_revoked_tokens.py +++ b/migrations/versions/2026_03_20_0001-a1b2c3d4e5f6_add_revoked_tokens.py @@ -5,6 +5,7 @@ Create Date: 2026-03-20 00:01:00.000000 """ + from typing import Sequence, Union from alembic import op diff --git a/migrations/versions/2026_04_08_0002-f8c1fc9be252_add_memory_name.py b/migrations/versions/2026_04_08_0002-f8c1fc9be252_add_memory_name.py new file mode 100644 index 0000000..f5a148e --- /dev/null +++ b/migrations/versions/2026_04_08_0002-f8c1fc9be252_add_memory_name.py @@ -0,0 +1,87 @@ +"""add memory name + +Revision ID: f8c1fc9be252 +Revises: b942da1196f0 +Create Date: 2026-04-06 12:45:26.136961 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f8c1fc9be252" +down_revision: Union[str, Sequence[str], None] = "c1d2e3f4a5b6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("memories", sa.Column("name", sa.Text(), nullable=True)) + op.create_unique_constraint( + "uq_memories_name_workspace", "memories", ["name", "workspace_id"] + ) + # ### end Alembic commands ### + + # Pass 1: extract name from "name: content" pattern where unambiguous. + op.execute(sa.text(""" + UPDATE memories + SET + name = TRIM(SPLIT_PART(content, ':', 1)) + WHERE + POSITION(':' IN content) > 0 + AND CHAR_LENGTH(TRIM(SPLIT_PART(content, ':', 1))) BETWEEN 3 AND 60 + AND name IS NULL + AND NOT EXISTS ( + SELECT 1 FROM memories m2 + WHERE m2.workspace_id = memories.workspace_id + AND m2.id != memories.id + AND POSITION(':' IN m2.content) > 0 + AND CHAR_LENGTH(TRIM(SPLIT_PART(m2.content, ':', 1))) BETWEEN 3 AND 60 + AND TRIM(SPLIT_PART(m2.content, ':', 1)) = TRIM(SPLIT_PART(memories.content, ':', 1)) + ) + """)) + + op.execute(sa.text(""" + UPDATE memories + SET + name = TRIM(SPLIT_PART(content, '\n', 1)) + WHERE + POSITION('\n' IN content) > 0 + AND CHAR_LENGTH(TRIM(SPLIT_PART(content, '\n', 1))) BETWEEN 3 AND 60 + AND name IS NULL + AND NOT EXISTS ( + SELECT 1 FROM memories m2 + WHERE m2.workspace_id = memories.workspace_id + AND m2.id != memories.id + AND POSITION('\n' IN m2.content) > 0 + AND CHAR_LENGTH(TRIM(SPLIT_PART(m2.content, '\n', 1))) BETWEEN 3 AND 60 + AND TRIM(SPLIT_PART(m2.content, '\n', 1)) = TRIM(SPLIT_PART(memories.content, '\n', 1)) + ) + """)) + + # Pass 2: fill remaining NULLs from the first 60 chars of content. + op.execute(sa.text(""" + UPDATE memories m + SET name = CASE + WHEN LENGTH(m.content) > 60 + THEN LEFT(TRIM(m.content), 60) || '...' + ELSE m.content + END + WHERE m.name IS NULL + """)) + + op.alter_column("memories", "name", nullable=False) + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_memories_name_workspace", "memories", type_="unique") + op.drop_column("memories", "name") + # ### end Alembic commands ### diff --git a/ruff.toml b/ruff.toml index 5fa1835..4732206 100644 --- a/ruff.toml +++ b/ruff.toml @@ -21,7 +21,7 @@ select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes - # "I", # isort + "I", # isort "C", # flake8-comprehensions "B", # flake8-bugbear "PLC0415", # import outside toplevel @@ -33,7 +33,7 @@ ignore = [ ] # Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F"] +fixable = ["A", "B", "C", "D", "E", "F", "I"] unfixable = [] # Allow unused variables when underscore-prefixed. diff --git a/src/memlord/api/__init__.py b/src/memlord/api/__init__.py index 10ca0d5..d567f70 100644 --- a/src/memlord/api/__init__.py +++ b/src/memlord/api/__init__.py @@ -4,7 +4,7 @@ from .search import router as search_router from .workspaces import router as workspaces_router -router = APIRouter(prefix="/api") +router = APIRouter(prefix="/api", tags=["API"]) router.include_router(memories_router) router.include_router(search_router) router.include_router(workspaces_router) diff --git a/src/memlord/api/memories.py b/src/memlord/api/memories.py index 1ac8ce4..e3a4c89 100644 --- a/src/memlord/api/memories.py +++ b/src/memlord/api/memories.py @@ -1,5 +1,3 @@ -import math - import sqlalchemy as sa from fastapi import APIRouter, HTTPException from sqlalchemy import select @@ -8,12 +6,12 @@ from memlord.dao.workspace import WorkspaceDao from memlord.db import APISessionDep from memlord.models import Memory, MemoryTag, Tag -from memlord.schemas import ( +from memlord.schemas import MemoryType +from memlord.schemas.api import ( MemoriesFilter, MemoriesResponse, MemoryDetail, MemoryItem, - MemoryType, MoveRequest, UpdateMemoryRequest, WorkspaceSimple, @@ -25,6 +23,7 @@ _COLS = ( Memory.id, + Memory.name, Memory.content, Memory.memory_type, Memory.created_at, @@ -78,7 +77,6 @@ async def list_memories( .mappings() .all() ) - total_pages = math.ceil(total / page_size) if total else 0 ids = [row["id"] for row in rows] tags_map = await MemoryDao(s, user.id).fetch_tags(ids) ws_display = {ws.id: ("Personal" if ws.is_personal else ws.name) for ws in workspaces} @@ -86,6 +84,7 @@ async def list_memories( memories = [ MemoryItem( id=row["id"], + name=row["name"], content=row["content"], memory_type=row["memory_type"], created_at=row["created_at"].strftime("%Y-%m-%d %H:%M:%S"), @@ -97,11 +96,10 @@ async def list_memories( ] return MemoriesResponse( - memories=memories, + items=memories, total=total, page=body.page, page_size=page_size, - total_pages=total_pages, ) @@ -114,6 +112,7 @@ def _build_detail(memory, workspaces) -> MemoryDetail: ] return MemoryDetail( id=memory.id, + name=memory.name, content=memory.content, memory_type=memory.memory_type, created_at=memory.created_at.strftime("%Y-%m-%d %H:%M:%S"), @@ -132,7 +131,7 @@ async def get_memory( s: APISessionDep, user: APIUserDep, ) -> MemoryDetail: - memory = await MemoryDao(s, user.id).get(id, workspace_id) + memory = await MemoryDao(s, user.id).get(id=id, workspace_id=workspace_id) if memory is None: raise HTTPException(status_code=404, detail="Memory not found") workspaces = await WorkspaceDao(s, user.id).list_workspaces() @@ -148,7 +147,7 @@ async def update_memory( user: APIUserDep, ) -> MemoryDetail: dao = MemoryDao(s, user.id) - existing = await dao.get(id, workspace_id) + existing = await dao.get(id=id, workspace_id=workspace_id) if existing is None: raise HTTPException(status_code=404, detail="Memory not found") @@ -165,8 +164,11 @@ async def update_memory( } if new_content != existing.content: data["content"] = new_content + if body.name is not None: + data["name"] = body.name - await dao.update(**data) + _, final_name = await dao.update(**data) + existing.name = final_name # type: ignore[assignment] existing.content = new_content existing.memory_type = new_type # type: ignore[assignment] existing.tags = new_tags # type: ignore[assignment] @@ -198,7 +200,7 @@ async def move_memory( user: APIUserDep, ) -> MemoryDetail: dao = MemoryDao(s, user.id) - memory = await dao.get(id, workspace_id) + memory = await dao.get(id=id, workspace_id=workspace_id) if memory is None: raise HTTPException(status_code=404, detail="Memory not found") @@ -207,7 +209,7 @@ async def move_memory( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e - moved = await dao.get(id, body.to_workspace_id) + moved = await dao.get(id=id, workspace_id=body.to_workspace_id) if moved is None: raise HTTPException(status_code=404, detail="Memory not found after move") workspaces = await WorkspaceDao(s, user.id).list_workspaces() diff --git a/src/memlord/api/search.py b/src/memlord/api/search.py index b690b7c..c3be69f 100644 --- a/src/memlord/api/search.py +++ b/src/memlord/api/search.py @@ -6,10 +6,9 @@ from memlord.dao.workspace import WorkspaceDao from memlord.db import APISessionDep from memlord.models import Memory -from memlord.schemas import SearchItem, SearchResponse +from memlord.schemas.api import SearchItem, SearchResponse from memlord.search import hybrid_search from memlord.ui.utils import APIUserDep -from memlord.utils.dt import utcnow router = APIRouter(prefix="/search") @@ -44,17 +43,15 @@ async def search( await s.execute(select(Memory.id, Memory.created_at).where(Memory.id.in_(ids))) ).all() } - ws_ids = {r.workspace_id for r in raw if r.workspace_id} - ws_names = await WorkspaceDao(s, user.id).get_names_by_ids(ws_ids) if ws_ids else {} results = [ SearchItem( id=r.id, content=r.content, memory_type=r.memory_type, - created_at=(created_map.get(r.id) or utcnow()).strftime("%Y-%m-%d %H:%M:%S"), - workspace_id=r.workspace_id, - workspace_name=ws_names.get(r.workspace_id) if r.workspace_id else None, + created_at=created_map[r.id].strftime("%Y-%m-%d %H:%M:%S"), + workspace_id=None, + workspace_name=r.workspace, tags=sorted(tags_map.get(r.id, set())), rrf_score=round(r.rrf_score, 4), ) diff --git a/src/memlord/api/workspaces.py b/src/memlord/api/workspaces.py index 86e003b..ead11bb 100644 --- a/src/memlord/api/workspaces.py +++ b/src/memlord/api/workspaces.py @@ -12,14 +12,13 @@ from memlord.schemas import ( CreateWorkspaceRequest, DescriptionRequest, - ImportItem, - ImportResult, InviteRequest, InviteResponse, RenameRequest, WorkspaceDetailResponse, WorkspaceInfo, ) +from memlord.schemas.api import ImportItem, ImportResult from memlord.schemas.workspace import WorkspaceRole from memlord.ui.utils import APIUserDep from memlord.utils.dt import utcnow @@ -182,6 +181,7 @@ async def export_memories( await s.execute( select( Memory.id, + Memory.name, Memory.content, Memory.memory_type, Memory.created_at, @@ -237,6 +237,7 @@ async def import_memories( memory_type=parsed.memory_type, metadata=parsed.metadata, tags=parsed.tags, + name=parsed.name, workspace_id=workspace_id, force=True, ) diff --git a/src/memlord/auth.py b/src/memlord/auth.py index f9bd4d5..f86268f 100644 --- a/src/memlord/auth.py +++ b/src/memlord/auth.py @@ -1,12 +1,11 @@ from contextlib import asynccontextmanager import bcrypt +from fastmcp.dependencies import Depends as MCPDepends from fastmcp.server.dependencies import get_access_token from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fastmcp.dependencies import Depends as MCPDepends - from memlord.config import settings from memlord.db import MCPSessionDep from memlord.models.oauth_client import OAuthClient diff --git a/src/memlord/dao/memory.py b/src/memlord/dao/memory.py index 9f7f914..5ce18ea 100644 --- a/src/memlord/dao/memory.py +++ b/src/memlord/dao/memory.py @@ -1,8 +1,9 @@ from datetime import datetime from typing import Any +import sqlalchemy as sa from pgvector.sqlalchemy import Vector -from sqlalchemy import bindparam, delete, Float, insert, select, update +from sqlalchemy import Float, bindparam, delete, insert, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession @@ -83,18 +84,29 @@ async def _check_near_duplicate(self, vector: list[float], workspace_id: int) -> f"Review with get_memory({dup_row['id']}). Pass force=True to store anyway." ) - async def _accessible_workspace_ids(self, write: bool = False) -> list[int]: - return await self._ws_dao.get_accessible_workspace_ids(write=write) + async def get_id_by_name(self, name: str, workspace_id: int | None = None) -> int | None: + if workspace_id is None: + workspace_id = await self._personal_workspace_id() + return await self._s.scalar( + select(Memory.id).where( + Memory.name == name, + Memory.workspace_id == workspace_id, + ) + ) async def _personal_workspace_id(self) -> int: return (await self._ws_dao.get_personal()).id + async def _accessible_workspace_ids(self, write: bool = False) -> list[int]: + return await self._ws_dao.get_accessible_workspace_ids(write=write) + async def create( self, content: str, memory_type: MemoryType, metadata: dict, tags: set[str], + name: str, workspace_id: int | None = None, force: bool = False, ) -> tuple[int, bool]: @@ -127,6 +139,7 @@ async def create( embedding=vector, created_by=self._uid, workspace_id=workspace_id, + name=name, ) .returning(Memory.id) ) @@ -139,12 +152,13 @@ async def update( self, id: int, workspace_id: int | None = None, - content: str = _UNSET, - memory_type: MemoryType = _UNSET, - metadata: dict = _UNSET, - tags: set[str] = _UNSET, - ) -> int: - """Update memory fields. Pass _UNSET to leave a field unchanged; None sets it to NULL.""" + content: str = _UNSET, # type: ignore[assignment] + memory_type: MemoryType = _UNSET, # type: ignore[assignment] + metadata: dict = _UNSET, # type: ignore[assignment] + tags: set[str] = _UNSET, # type: ignore[assignment] + name: str | None = _UNSET, # type: ignore[assignment] + ) -> tuple[int, str]: + """Update memory fields. Pass _UNSET to leave a field unchanged.""" if workspace_id is None: workspace_id = await self._personal_workspace_id() else: @@ -162,6 +176,8 @@ async def update( values["memory_type"] = MemoryType(memory_type) if metadata is not _UNSET: values["extra_data"] = metadata or {} + if name is not _UNSET: + values["name"] = name if content is not _UNSET or tags is not _UNSET: new_content = ( @@ -177,12 +193,18 @@ async def update( values["embedding"] = await embed(_embed_text(new_content, new_tags)) if values: - await self._s.execute(update(Memory).where(Memory.id == memory_id).values(**values)) + final_name: str = await self._s.scalar( # type: ignore[assignment] + update(Memory).where(Memory.id == memory_id).values(**values).returning(Memory.name) + ) + else: + final_name = await self._s.scalar( # type: ignore[assignment] + select(Memory.name).where(Memory.id == memory_id) + ) if tags is not _UNSET: await self._replace_tags(memory_id, tags) - return memory_id + return memory_id, final_name async def delete(self, id: int, workspace_id: int | None = None) -> None: if workspace_id is None: @@ -203,50 +225,41 @@ async def delete(self, id: int, workspace_id: int | None = None) -> None: raise ValueError(f"Memory with id={id} not found") await self._cleanup_orphan_tags() - async def get(self, id: int, workspace_id: int | None = None) -> MemoryListItem | None: + async def get( + self, + *, + id: int | None = None, + name: str | None = None, + workspace_id: int | None = None, + ) -> MemoryListItem | None: + if id is None and name is None: + raise ValueError("Either id or name must be provided") if workspace_id is None: workspace_id = await self._personal_workspace_id() elif not await self._ws_dao.can_read(workspace_id): raise ValueError(f"No read access to workspace {workspace_id}") - row = ( - ( - await self._s.execute( - select( - Memory.id, - Memory.content, - Memory.memory_type, - Memory.extra_data.label("metadata"), - Memory.created_at, - Memory.workspace_id, - ).where(Memory.id == id, Memory.workspace_id == workspace_id) - ) - ) - .mappings() - .one_or_none() - ) + q = select( + Memory.id, + Memory.name, + Memory.content, + Memory.memory_type, + Memory.extra_data.label("metadata"), + Memory.created_at, + Memory.workspace_id, + ).where(Memory.workspace_id == workspace_id) + if id is not None: + q = q.where(Memory.id == id) + if name is not None: + q = q.where(Memory.name == name) + + row = (await self._s.execute(q)).mappings().one_or_none() if row is None: return None - tags = (await self.fetch_tags([id])).get(id, set()) + memory_id: int = row["id"] + tags = (await self.fetch_tags([memory_id])).get(memory_id, set()) return MemoryListItem(**row, tags=tags) - async def move_by_name( - self, id: int, to_workspace: str, from_workspace: str | None = None - ) -> None: - if from_workspace is not None: - from_ws = await self._ws_dao.get_by_name(from_workspace) - if from_ws is None: - raise ValueError(f"Workspace {from_workspace!r} not found") - from_workspace_id = from_ws.id - else: - from_workspace_id = await self._personal_workspace_id() - - to_ws = await self._ws_dao.get_by_name(to_workspace) - if to_ws is None: - raise ValueError(f"Workspace {to_workspace!r} not found") - - return await self.move(id, from_workspace_id, to_ws.id) - async def move(self, id: int, from_workspace_id: int, to_workspace_id: int) -> None: """Move memory to a different workspace. Raises ValueError if not found or duplicate.""" workspace_ids = await self._accessible_workspace_ids(write=True) @@ -255,7 +268,7 @@ async def move(self, id: int, from_workspace_id: int, to_workspace_id: int) -> N if from_workspace_id not in workspace_ids: raise PermissionError(f"No access to workspace {from_workspace_id}") - q = select(Memory.id, Memory.content).where( + q = select(Memory.id, Memory.name, Memory.content).where( Memory.id == id, Memory.workspace_id == from_workspace_id ) @@ -265,15 +278,19 @@ async def move(self, id: int, from_workspace_id: int, to_workspace_id: int) -> N duplicate = await self._s.scalar( select(Memory.id).where( - Memory.content == row["content"], + sa.or_( + Memory.content == row["content"], + Memory.name == row["name"], + ), Memory.workspace_id == to_workspace_id, Memory.id != id, ) ) if duplicate is not None: raise ValueError( - "A memory with the same content already exists in the target workspace" + "A memory with the same content or name already exists in the target workspace" ) + await self._s.execute( update(Memory).where(Memory.id == id).values(workspace_id=to_workspace_id) ) @@ -291,6 +308,10 @@ async def fetch_tags(self, memory_ids: list[int]) -> dict[int, set[str]]: async def fetch_metadata(self, memory_ids: list[int]) -> dict[int, tuple[dict, datetime]]: rows = await self._s.execute( - select(Memory.id, Memory.extra_data, Memory.created_at).where(Memory.id.in_(memory_ids)) + select( + Memory.id, + Memory.extra_data, + Memory.created_at, + ).where(Memory.id.in_(memory_ids)) ) return {row.id: (row.extra_data, row.created_at) for row in rows.fetchall()} diff --git a/src/memlord/db.py b/src/memlord/db.py index 398a3a4..e2a4e6d 100644 --- a/src/memlord/db.py +++ b/src/memlord/db.py @@ -5,9 +5,9 @@ from fastapi import Depends as APIDepends from fastmcp.dependencies import Depends as MCPDepends from sqlalchemy.ext.asyncio import ( - async_sessionmaker, AsyncEngine, AsyncSession, + async_sessionmaker, create_async_engine, ) diff --git a/src/memlord/models/memory.py b/src/memlord/models/memory.py index 99b689c..b150641 100644 --- a/src/memlord/models/memory.py +++ b/src/memlord/models/memory.py @@ -9,6 +9,7 @@ class Memory(Base): __tablename__ = "memories" id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + name = sa.Column(sa.Text, nullable=False) content = sa.Column(sa.Text, nullable=False) created_by = sa.Column(sa.Integer, sa.ForeignKey("users.id"), nullable=False) memory_type = sa.Column(sa.String(50), nullable=False) @@ -30,6 +31,7 @@ class Memory(Base): __table_args__ = ( sa.UniqueConstraint("content", "workspace_id", name="uq_memories_content_workspace"), + sa.UniqueConstraint("name", "workspace_id", name="uq_memories_name_workspace"), sa.Index("ix_memories_search_vector", "search_vector", postgresql_using="gin"), sa.Index( "ix_memories_embedding", diff --git a/src/memlord/models/workspace.py b/src/memlord/models/workspace.py index f004bb6..aba2479 100644 --- a/src/memlord/models/workspace.py +++ b/src/memlord/models/workspace.py @@ -1,7 +1,7 @@ import sqlalchemy as sa -from .base import Base from ..schemas.workspace import WorkspaceRole +from .base import Base class Workspace(Base): diff --git a/src/memlord/oauth.py b/src/memlord/oauth.py index 4b523c0..7b7906f 100644 --- a/src/memlord/oauth.py +++ b/src/memlord/oauth.py @@ -10,22 +10,22 @@ from authlib.jose.errors import JoseError from fastmcp.server.auth import OAuthProvider from fastmcp.server.auth.auth import AccessToken -from starlette.middleware import Middleware -from fastmcp.server.auth.jwt_issuer import derive_jwt_key, JWTIssuer +from fastmcp.server.auth.jwt_issuer import JWTIssuer, derive_jwt_key from fastmcp.server.auth.redirect_validation import matches_allowed_pattern from mcp.server.auth.provider import ( AuthorizationCode, AuthorizationParams, AuthorizeError, - construct_redirect_uri, RefreshToken, TokenError, + construct_redirect_uri, ) from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.shared.auth import OAuthClientInformationFull, OAuthToken from pydantic import AnyUrl, BaseModel from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession +from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import HTMLResponse, RedirectResponse, Response from starlette.routing import Route diff --git a/src/memlord/schemas/__init__.py b/src/memlord/schemas/__init__.py index 9502360..a472e3e 100644 --- a/src/memlord/schemas/__init__.py +++ b/src/memlord/schemas/__init__.py @@ -1,23 +1,10 @@ -from .delete import DeleteResult -from .list_memories import MemoryListItem, MemoryPage -from .memory_response import ( - MemoriesFilter, - MemoriesResponse, - MemoryDetail, - MemoryItem, - MoveRequest, - WorkspaceSimple, -) +from .memory_list_item import MemoryListItem from .memory_type import MemoryType -from .recall import RecallPage, RecallResult -from .search import MemoryResult, SearchItem, SearchResponse, SearchResult -from .store import ImportItem, StoreResult -from .update import UpdateMemoryRequest +from .search import SearchResult from .user import UserInfo from .workspace import ( CreateWorkspaceRequest, DescriptionRequest, - ImportResult, InviteRequest, InviteResponse, RenameRequest, diff --git a/src/memlord/schemas/api/__init__.py b/src/memlord/schemas/api/__init__.py new file mode 100644 index 0000000..1d53af3 --- /dev/null +++ b/src/memlord/schemas/api/__init__.py @@ -0,0 +1,11 @@ +from .import_item import ImportItem, ImportResult +from .memory_response import ( + MemoriesFilter, + MemoriesResponse, + MemoryDetail, + MemoryItem, + MoveRequest, + WorkspaceSimple, +) +from .search import SearchItem, SearchResponse +from .update import UpdateMemoryRequest diff --git a/src/memlord/schemas/api/import_item.py b/src/memlord/schemas/api/import_item.py new file mode 100644 index 0000000..67cf4d6 --- /dev/null +++ b/src/memlord/schemas/api/import_item.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, model_validator + +from memlord.utils.dt import utcnow + +from ..memory_type import MemoryType + + +class ImportItem(BaseModel): + content: str + memory_type: MemoryType + name: str + tags: set[str] = Field(default_factory=set) + metadata: dict = Field(default_factory=dict) + created_at: datetime = Field(default_factory=utcnow) + + @model_validator(mode="before") + @classmethod + def fill_name(cls, data: dict) -> dict: + if not data.get("name"): + data["name"] = (data.get("content") or "")[:60].strip() + return data + + +class ImportResult(BaseModel): + imported: int + skipped: int diff --git a/src/memlord/schemas/memory_response.py b/src/memlord/schemas/api/memory_response.py similarity index 85% rename from src/memlord/schemas/memory_response.py rename to src/memlord/schemas/api/memory_response.py index 184c2e2..577da1f 100644 --- a/src/memlord/schemas/memory_response.py +++ b/src/memlord/schemas/api/memory_response.py @@ -1,5 +1,7 @@ from pydantic import BaseModel +from ..pagination import Paginated + class MemoriesFilter(BaseModel): page: int = 1 @@ -17,6 +19,7 @@ class WorkspaceSimple(BaseModel): class MemoryItem(BaseModel): id: int + name: str content: str memory_type: str | None created_at: str @@ -27,6 +30,7 @@ class MemoryItem(BaseModel): class MemoryDetail(BaseModel): id: int + name: str content: str memory_type: str | None created_at: str @@ -37,12 +41,7 @@ class MemoryDetail(BaseModel): writable_workspaces: list[WorkspaceSimple] -class MemoriesResponse(BaseModel): - memories: list[MemoryItem] - total: int - page: int - page_size: int - total_pages: int +class MemoriesResponse(Paginated[MemoryItem]): ... class MoveRequest(BaseModel): diff --git a/src/memlord/schemas/api/search.py b/src/memlord/schemas/api/search.py new file mode 100644 index 0000000..4496470 --- /dev/null +++ b/src/memlord/schemas/api/search.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class SearchItem(BaseModel): + id: int + content: str + memory_type: str | None + created_at: str + workspace_id: int | None + workspace_name: str | None + tags: list[str] + rrf_score: float + + +class SearchResponse(BaseModel): + results: list[SearchItem] + query: str diff --git a/src/memlord/schemas/update.py b/src/memlord/schemas/api/update.py similarity index 76% rename from src/memlord/schemas/update.py rename to src/memlord/schemas/api/update.py index 40f20fc..f67d3a2 100644 --- a/src/memlord/schemas/update.py +++ b/src/memlord/schemas/api/update.py @@ -1,10 +1,11 @@ from pydantic import BaseModel -from .memory_type import MemoryType +from ..memory_type import MemoryType class UpdateMemoryRequest(BaseModel): content: str | None = None + name: str | None = None memory_type: MemoryType | None = None tags: set[str] | None = None metadata: dict | None = None diff --git a/src/memlord/schemas/list_memories.py b/src/memlord/schemas/memory_list_item.py similarity index 53% rename from src/memlord/schemas/list_memories.py rename to src/memlord/schemas/memory_list_item.py index c82b655..808de03 100644 --- a/src/memlord/schemas/list_memories.py +++ b/src/memlord/schemas/memory_list_item.py @@ -1,27 +1,20 @@ -from datetime import datetime, UTC +from datetime import UTC, datetime -from pydantic import BaseModel, Field, field_serializer, NaiveDatetime +from pydantic import BaseModel, Field, NaiveDatetime, field_serializer from .memory_type import MemoryType class MemoryListItem(BaseModel): id: int + name: str content: str memory_type: MemoryType metadata: dict = Field(default_factory=dict) tags: set[str] created_at: NaiveDatetime - workspace_id: int | None = None + workspace_id: int @field_serializer("created_at") def serialize_created_at(self, v: datetime) -> str: return v.replace(tzinfo=UTC).isoformat() - - -class MemoryPage(BaseModel): - items: list[MemoryListItem] = Field(default_factory=list) - total: int = 0 - page: int = 1 - page_size: int = 0 - total_pages: int = 0 diff --git a/src/memlord/schemas/pagination.py b/src/memlord/schemas/pagination.py new file mode 100644 index 0000000..1e1edbb --- /dev/null +++ b/src/memlord/schemas/pagination.py @@ -0,0 +1,17 @@ +import math + +from pydantic import BaseModel, Field, computed_field + + +class Paginated[T: BaseModel](BaseModel): + items: list[T] = Field(default_factory=list) + total: int = 0 + page: int = 1 + page_size: int = 0 + + @computed_field + @property + def total_pages(self) -> int: + if self.page_size: + return math.ceil(self.total / self.page_size) + return 0 diff --git a/src/memlord/schemas/search.py b/src/memlord/schemas/search.py index 80270c3..15b8a1e 100644 --- a/src/memlord/schemas/search.py +++ b/src/memlord/schemas/search.py @@ -1,45 +1,13 @@ -from datetime import datetime, UTC - -from pydantic import BaseModel, NaiveDatetime, field_serializer +from pydantic import BaseModel from .memory_type import MemoryType class SearchResult(BaseModel): id: int + name: str content: str memory_type: MemoryType rrf_score: float vec_similarity: float | None - workspace_id: int | None = None - - -class MemoryResult(BaseModel): - id: int - content: str - memory_type: MemoryType - tags: set[str] - metadata: dict - created_at: NaiveDatetime - rrf_score: float - workspace_id: int | None = None - - @field_serializer("created_at") - def serialize_created_at(self, v: datetime) -> str: - return v.replace(tzinfo=UTC).isoformat() - - -class SearchItem(BaseModel): - id: int - content: str - memory_type: str | None - created_at: str - workspace_id: int | None - workspace_name: str | None - tags: list[str] - rrf_score: float - - -class SearchResponse(BaseModel): - results: list[SearchItem] - query: str + workspace: str | None = None diff --git a/src/memlord/schemas/store.py b/src/memlord/schemas/store.py deleted file mode 100644 index 06ba4f8..0000000 --- a/src/memlord/schemas/store.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from .memory_type import MemoryType -from ..utils.dt import utcnow - - -class ImportItem(BaseModel): - content: str - memory_type: MemoryType - tags: set[str] = Field(default_factory=list) - metadata: dict = Field(default_factory=dict) - created_at: datetime = Field(default_factory=utcnow) - - -class StoreResult(BaseModel): - id: int - created: bool diff --git a/src/memlord/schemas/tools/__init__.py b/src/memlord/schemas/tools/__init__.py new file mode 100644 index 0000000..50825dd --- /dev/null +++ b/src/memlord/schemas/tools/__init__.py @@ -0,0 +1,5 @@ +from .delete import DeleteResult +from .list_memories import MemoryDetail, MemoryItem, MemoryPage +from .recall import RecallPage, RecallResult +from .retrieve import MemoryResult +from .store import StoreResult diff --git a/src/memlord/schemas/delete.py b/src/memlord/schemas/tools/delete.py similarity index 85% rename from src/memlord/schemas/delete.py rename to src/memlord/schemas/tools/delete.py index 300948c..2e0e492 100644 --- a/src/memlord/schemas/delete.py +++ b/src/memlord/schemas/tools/delete.py @@ -3,4 +3,4 @@ class DeleteResult(BaseModel): success: bool - id: int + name: str diff --git a/src/memlord/schemas/tools/list_memories.py b/src/memlord/schemas/tools/list_memories.py new file mode 100644 index 0000000..7d5b934 --- /dev/null +++ b/src/memlord/schemas/tools/list_memories.py @@ -0,0 +1,42 @@ +from datetime import UTC, datetime + +from pydantic import BaseModel, ConfigDict, Field, NaiveDatetime, field_serializer + +from ..memory_type import MemoryType +from ..pagination import Paginated + + +class MemoryItem(BaseModel): + """Slim memory record returned by MCP list/search tools (no id, no content).""" + + model_config = ConfigDict(extra="ignore") + + name: str + memory_type: MemoryType + metadata: dict = Field(default_factory=dict) + tags: set[str] + created_at: NaiveDatetime + workspace: str | None = None + + @field_serializer("created_at") + def serialize_created_at(self, v: datetime) -> str: + return v.replace(tzinfo=UTC).isoformat() + + +class MemoryDetail(BaseModel): + """Full memory record returned by get_memory MCP tool.""" + + name: str + content: str + memory_type: MemoryType + metadata: dict = Field(default_factory=dict) + tags: set[str] + created_at: NaiveDatetime + workspace: str | None = None + + @field_serializer("created_at") + def serialize_created_at(self, v: datetime) -> str: + return v.replace(tzinfo=UTC).isoformat() + + +class MemoryPage(Paginated[MemoryItem]): ... diff --git a/src/memlord/schemas/recall.py b/src/memlord/schemas/tools/recall.py similarity index 62% rename from src/memlord/schemas/recall.py rename to src/memlord/schemas/tools/recall.py index e768475..c0226fe 100644 --- a/src/memlord/schemas/recall.py +++ b/src/memlord/schemas/tools/recall.py @@ -1,17 +1,16 @@ -from datetime import datetime, UTC +from datetime import UTC, datetime -from pydantic import BaseModel, Field, field_serializer, NaiveDatetime +from pydantic import BaseModel, Field, NaiveDatetime, field_serializer -from .memory_type import MemoryType +from ..memory_type import MemoryType class RecallResult(BaseModel): - id: int - content: str + name: str memory_type: MemoryType | None tags: set[str] created_at: NaiveDatetime - workspace_id: int | None = None + workspace: str | None = None @field_serializer("created_at") def serialize_created_at(self, v: datetime) -> str: diff --git a/src/memlord/schemas/tools/retrieve.py b/src/memlord/schemas/tools/retrieve.py new file mode 100644 index 0000000..9389c6a --- /dev/null +++ b/src/memlord/schemas/tools/retrieve.py @@ -0,0 +1,19 @@ +from datetime import UTC, datetime + +from pydantic import BaseModel, NaiveDatetime, field_serializer + +from ..memory_type import MemoryType + + +class MemoryResult(BaseModel): + name: str + memory_type: MemoryType + tags: set[str] + metadata: dict + created_at: NaiveDatetime + rrf_score: float + workspace: str | None = None + + @field_serializer("created_at") + def serialize_created_at(self, v: datetime) -> str: + return v.replace(tzinfo=UTC).isoformat() diff --git a/src/memlord/schemas/tools/store.py b/src/memlord/schemas/tools/store.py new file mode 100644 index 0000000..83db5df --- /dev/null +++ b/src/memlord/schemas/tools/store.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class StoreResult(BaseModel): + name: str + created: bool diff --git a/src/memlord/schemas/workspace.py b/src/memlord/schemas/workspace.py index a9f001d..dfbbca6 100644 --- a/src/memlord/schemas/workspace.py +++ b/src/memlord/schemas/workspace.py @@ -54,8 +54,3 @@ class InviteResponse(BaseModel): invite_url: str expires_in_hours: int role: str - - -class ImportResult(BaseModel): - imported: int - skipped: int diff --git a/src/memlord/search.py b/src/memlord/search.py index 8321afa..932061d 100644 --- a/src/memlord/search.py +++ b/src/memlord/search.py @@ -7,7 +7,8 @@ from memlord.config import settings from memlord.embeddings import embed from memlord.models import Memory, MemoryTag, Tag -from memlord.schemas import SearchResult +from memlord.models.workspace import Workspace +from memlord.schemas import MemoryType, SearchResult async def hybrid_search( @@ -53,11 +54,13 @@ async def hybrid_search( bm25_q = ( select( Memory.id, + Memory.name, Memory.content, Memory.memory_type, - Memory.workspace_id, + Workspace.name.label("workspace"), bm25_rank, ) + .join(Workspace, Memory.workspace_id == Workspace.id) .where( (Memory.search_vector.op("@@")(tsquery)) | tag_match, *conditions, @@ -76,12 +79,14 @@ async def hybrid_search( vec_q = ( select( Memory.id, + Memory.name, Memory.content, Memory.memory_type, - Memory.workspace_id, + Workspace.name.label("workspace"), distance, vec_rank, ) + .join(Workspace, Memory.workspace_id == Workspace.id) .where(Memory.embedding.isnot(None), *conditions) .order_by(distance) .limit(n) @@ -92,10 +97,9 @@ async def hybrid_search( bm25_ranks: dict[int, int] = {row.id: row.bm25_rank for row in bm25_rows} vec_ranks: dict[int, int] = {row.id: row.vec_rank for row in vec_rows} vec_distances: dict[int, float] = {row.id: row.distance for row in vec_rows} - contents: dict[int, tuple] = { - row.id: (row.content, row.memory_type, row.workspace_id) for row in bm25_rows - } - contents.update({row.id: (row.content, row.memory_type, row.workspace_id) for row in vec_rows}) + contents: dict[int, tuple[str, str, MemoryType, str]] = { + row.id: (row.name, row.content, row.memory_type, row.workspace) for row in bm25_rows + } | {row.id: (row.name, row.content, row.memory_type, row.workspace) for row in vec_rows} # RRF fusion all_ids = set(bm25_ranks) | set(vec_ranks) @@ -116,13 +120,14 @@ async def hybrid_search( if doc_id not in bm25_ranks and similarity is not None and similarity < threshold: continue - content, memory_type, workspace_id = contents[doc_id] + name, content, memory_type, workspace = contents[doc_id] scored.append( SearchResult( id=doc_id, + name=name, content=content, memory_type=memory_type, # type: ignore[arg-type] - workspace_id=workspace_id, + workspace=workspace, rrf_score=rrf, vec_similarity=similarity, ) diff --git a/src/memlord/templates/index.html b/src/memlord/templates/index.html index 88d842e..db7577c 100644 --- a/src/memlord/templates/index.html +++ b/src/memlord/templates/index.html @@ -136,6 +136,7 @@