diff --git a/surfsense_backend/app/routes/memory_routes.py b/surfsense_backend/app/routes/memory_routes.py index 7b674a584..8e73a277c 100644 --- a/surfsense_backend/app/routes/memory_routes.py +++ b/surfsense_backend/app/routes/memory_routes.py @@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import User, get_async_session from app.services.memory import ( + MemoryRead, MemoryScope, + memory_limits, read_memory, reset_memory, save_memory, @@ -18,10 +20,6 @@ from app.users import current_active_user router = APIRouter() -class MemoryRead(BaseModel): - memory_md: str - - class MemoryUpdate(BaseModel): memory_md: str @@ -36,7 +34,7 @@ async def get_user_memory( target_id=user.id, session=session, ) - return MemoryRead(memory_md=memory_md) + return MemoryRead(memory_md=memory_md, limits=memory_limits()) @router.put("/users/me/memory", response_model=MemoryRead) @@ -53,7 +51,7 @@ async def update_user_memory( ) if result.status == "error": raise HTTPException(status_code=400, detail=result.message) - return MemoryRead(memory_md=result.memory_md) + return MemoryRead(memory_md=result.memory_md, limits=memory_limits()) @router.post("/users/me/memory/reset", response_model=MemoryRead) @@ -68,4 +66,4 @@ async def reset_user_memory( ) if result.status == "error": raise HTTPException(status_code=400, detail=result.message) - return MemoryRead(memory_md=result.memory_md) + return MemoryRead(memory_md=result.memory_md, limits=memory_limits()) diff --git a/surfsense_backend/app/routes/team_memory_routes.py b/surfsense_backend/app/routes/team_memory_routes.py index 3e552ce32..b37a99b03 100644 --- a/surfsense_backend/app/routes/team_memory_routes.py +++ b/surfsense_backend/app/routes/team_memory_routes.py @@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import User, get_async_session from app.services.memory import ( + MemoryRead, MemoryScope, + memory_limits, read_memory, reset_memory, save_memory, @@ -19,15 +21,11 @@ from app.utils.rbac import check_search_space_access router = APIRouter() -class TeamMemoryRead(BaseModel): - memory_md: str - - class TeamMemoryUpdate(BaseModel): memory_md: str -@router.get("/searchspaces/{search_space_id}/memory", response_model=TeamMemoryRead) +@router.get("/searchspaces/{search_space_id}/memory", response_model=MemoryRead) async def get_team_memory( search_space_id: int, session: AsyncSession = Depends(get_async_session), @@ -39,10 +37,10 @@ async def get_team_memory( target_id=search_space_id, session=session, ) - return TeamMemoryRead(memory_md=memory_md) + return MemoryRead(memory_md=memory_md, limits=memory_limits()) -@router.put("/searchspaces/{search_space_id}/memory", response_model=TeamMemoryRead) +@router.put("/searchspaces/{search_space_id}/memory", response_model=MemoryRead) async def update_team_memory( search_space_id: int, body: TeamMemoryUpdate, @@ -58,10 +56,10 @@ async def update_team_memory( ) if result.status == "error": raise HTTPException(status_code=400, detail=result.message) - return TeamMemoryRead(memory_md=result.memory_md) + return MemoryRead(memory_md=result.memory_md, limits=memory_limits()) -@router.post("/searchspaces/{search_space_id}/memory/reset", response_model=TeamMemoryRead) +@router.post("/searchspaces/{search_space_id}/memory/reset", response_model=MemoryRead) async def reset_team_memory( search_space_id: int, session: AsyncSession = Depends(get_async_session), @@ -75,4 +73,4 @@ async def reset_team_memory( ) if result.status == "error": raise HTTPException(status_code=400, detail=result.message) - return TeamMemoryRead(memory_md=result.memory_md) + return MemoryRead(memory_md=result.memory_md, limits=memory_limits()) diff --git a/surfsense_backend/app/services/memory/__init__.py b/surfsense_backend/app/services/memory/__init__.py index d72f45e1f..27d0592fd 100644 --- a/surfsense_backend/app/services/memory/__init__.py +++ b/surfsense_backend/app/services/memory/__init__.py @@ -1,9 +1,11 @@ """First-class memory service for user and team markdown memory.""" +from .schemas import MemoryLimits, MemoryRead from .service import ( MemoryScope, SaveResult, extract_and_save, + memory_limits, read_memory, reset_memory, save_memory, @@ -18,9 +20,12 @@ from .validation import ( __all__ = [ "MEMORY_HARD_LIMIT", "MEMORY_SOFT_LIMIT", + "MemoryLimits", + "MemoryRead", "MemoryScope", "SaveResult", "extract_and_save", + "memory_limits", "read_memory", "reset_memory", "save_memory", diff --git a/surfsense_backend/app/services/memory/schemas.py b/surfsense_backend/app/services/memory/schemas.py index 9b40ee5b1..623e4aa93 100644 --- a/surfsense_backend/app/services/memory/schemas.py +++ b/surfsense_backend/app/services/memory/schemas.py @@ -1,4 +1,4 @@ -"""Structured output schemas for memory extraction.""" +"""Schemas for memory API responses and structured extraction.""" from __future__ import annotations @@ -7,6 +7,20 @@ from typing import Literal from pydantic import BaseModel, Field +class MemoryLimits(BaseModel): + """Canonical memory size limits exposed to clients.""" + + soft: int + hard: int + + +class MemoryRead(BaseModel): + """Memory document payload returned by user and team memory APIs.""" + + memory_md: str + limits: MemoryLimits + + class MemoryExtractionDecision(BaseModel): """Structured extraction result; avoids string sentinel parsing.""" diff --git a/surfsense_backend/app/services/memory/service.py b/surfsense_backend/app/services/memory/service.py index 85459c28c..8159977a7 100644 --- a/surfsense_backend/app/services/memory/service.py +++ b/surfsense_backend/app/services/memory/service.py @@ -9,7 +9,6 @@ from typing import Any, Literal from uuid import UUID from langchain_core.messages import HumanMessage -from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -19,9 +18,10 @@ from app.services.memory.prompts import ( USER_MEMORY_EXTRACT_PROMPT, ) from app.services.memory.rewrite import forced_rewrite -from app.services.memory.schemas import MemoryExtractionDecision +from app.services.memory.schemas import MemoryExtractionDecision, MemoryLimits from app.services.memory.validation import ( MEMORY_HARD_LIMIT, + MEMORY_SOFT_LIMIT, soft_limit_warning, strip_preamble_to_first_heading, validate_bullet_format, @@ -68,8 +68,8 @@ class SaveResult: return data -class MemoryRead(BaseModel): - memory_md: str +def memory_limits() -> MemoryLimits: + return MemoryLimits(soft=MEMORY_SOFT_LIMIT, hard=MEMORY_HARD_LIMIT) def _normalize_scope(scope: MemoryScope | str) -> MemoryScope: diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx index dc002244f..c7cb3d1d4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx @@ -14,11 +14,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; -import { MEMORY_HARD_LIMIT, useUserMemory } from "@/hooks/use-memory"; +import { getMemoryLimitState, useUserMemory } from "@/hooks/use-memory"; export function MemoryContent() { const activeSearchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { memory, displayMemory, loading, saving, reset } = useUserMemory( + const { memory, displayMemory, limits, loading, saving, reset } = useUserMemory( Number(activeSearchSpaceId) ); @@ -59,11 +59,11 @@ export function MemoryContent() { }; const charCount = memory.length; + const limitState = getMemoryLimitState(charCount, limits); const getCounterColor = () => { - if (charCount > MEMORY_HARD_LIMIT) return "text-red-500"; - if (charCount > 15_000) return "text-orange-500"; - if (charCount > 10_000) return "text-yellow-500"; + if (limitState.level === "error") return "text-red-500"; + if (limitState.level === "warning") return "text-orange-500"; return "text-muted-foreground"; }; @@ -112,13 +112,7 @@ export function MemoryContent() {