Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
Create Date: 2026-03-19 17:54:13.555231

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Create Date: 2026-03-20 00:01:00.000000

"""

from typing import Sequence, Union

from alembic import op
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ###
4 changes: 2 additions & 2 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/memlord/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 14 additions & 12 deletions src/memlord/api/memories.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import math

import sqlalchemy as sa
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
Expand All @@ -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,
Expand All @@ -25,6 +23,7 @@

_COLS = (
Memory.id,
Memory.name,
Memory.content,
Memory.memory_type,
Memory.created_at,
Expand Down Expand Up @@ -78,14 +77,14 @@ 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}

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"),
Expand All @@ -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,
)


Expand All @@ -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"),
Expand All @@ -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()
Expand All @@ -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")

Expand All @@ -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]
Expand Down Expand Up @@ -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")

Expand All @@ -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()
Expand Down
11 changes: 4 additions & 7 deletions src/memlord/api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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),
)
Expand Down
5 changes: 3 additions & 2 deletions src/memlord/api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,6 +181,7 @@ async def export_memories(
await s.execute(
select(
Memory.id,
Memory.name,
Memory.content,
Memory.memory_type,
Memory.created_at,
Expand Down Expand Up @@ -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,
)
Expand Down
3 changes: 1 addition & 2 deletions src/memlord/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading