diff --git a/backend/apps/memory/distill.py b/backend/apps/memory/distill.py new file mode 100644 index 00000000..e4f46e2f --- /dev/null +++ b/backend/apps/memory/distill.py @@ -0,0 +1,91 @@ +"""Post-conversation fact distillation: pull at most two durable USER facts from a session tail +and reconcile them into the memory store. Cost-gated (first at two user messages, then every six +more, once each), provider-agnostic cheap tier, fail-open to an empty list.""" + +import logging +from typing import Dict, List + +from typeguard import typechecked + +from backend.apps.agents.core.aux_llm import aux_max_tokens_for +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager.predict_followups import conversation_tail +from backend.apps.agents.manager.session.history_compaction import get_branch_messages +from backend.apps.memory.store import add_fact + +logger = logging.getLogger(__name__) + +MAX_FACTS_PER_DISTILL = 2 +P_FIRST_AT = 2 +P_EVERY = 6 +# Session id -> user-message count at the last distill, so each threshold fires exactly once. +p_last_distilled: Dict[str, int] = {} + + +@typechecked +def p_user_message_count(session: AgentSession) -> int: + return sum(1 for m in get_branch_messages(session) if m.role == "user" and not getattr(m, "hidden", False)) + + +@typechecked +def distill_eligible(session: AgentSession) -> bool: + users = p_user_message_count(session) + if users < P_FIRST_AT: + return False + last = p_last_distilled.get(session.id, 0) + return users >= (P_FIRST_AT if last == 0 else last + P_EVERY) + + +@typechecked +async def distill_session_memory(session: AgentSession) -> List[str]: + """Facts added or updated this pass ([] on any miss). The store's reconcile dedupes repeats.""" + try: + if not distill_eligible(session): + return [] + from backend.apps.settings.credentials import get_anthropic_client_for_model + from backend.apps.agents.providers.registry import resolve_aux_model + from backend.apps.settings.settings import load_settings + + global_settings = load_settings() + if not getattr(global_settings, "memory_enabled", True): + return [] + tail = conversation_tail(session) + if not tail: + return [] + p_last_distilled[session.id] = p_user_message_count(session) + aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0] + client = get_anthropic_client_for_model(global_settings, aux_model) + system_prompt = ( + "You extract durable facts about the USER from a conversation with their AI agent: " + "who they are, what they work on, standing preferences, constraints they stated. " + "Facts must be about the user themselves and still true next month; never task details, " + "never one-off requests, never anything the ASSISTANT said, never secrets, keys, or " + "passwords. Write each fact self-contained in third person, under 200 characters " + '(e.g. "Prefers concise answers with real measured numbers").\n\n' + f"Return at most {MAX_FACTS_PER_DISTILL} facts, one per line, no numbering, no quotes. " + "If the conversation reveals nothing durable, return the single word NOTHING." + ) + user_turn = "Conversation:\n\n" + tail + "\n\n\nExtract the facts." + chunks: List[str] = [] + async with client.messages.stream( + model=aux_model, + max_tokens=aux_max_tokens_for(aux_model, base=200), + system=system_prompt, + messages=[{"role": "user", "content": user_turn}], + ) as stream: + async for text in stream.text_stream: + chunks.append(text) + added: List[str] = [] + for line in "".join(chunks).splitlines(): + line = line.strip().strip("-*• ").strip() + if not line or len(line) < 8 or line.upper() == "NOTHING": + continue + fact = add_fact(line, source="distilled") + if fact is not None: + added.append(fact.text) + if len(added) >= MAX_FACTS_PER_DISTILL: + break + return added + except Exception as e: + logger.info(f"[memory-distill] fail-open ([]): {e}") + return [] diff --git a/backend/apps/memory/router.py b/backend/apps/memory/router.py index b1c8b443..2bdf099d 100644 --- a/backend/apps/memory/router.py +++ b/backend/apps/memory/router.py @@ -47,6 +47,20 @@ async def edit_fact(fact_id: str, body: FactBody) -> MemoryFact: return fact +@memory.router.post("/distill/{session_id}") +@typechecked +async def distill(session_id: str) -> Dict[str, List[str]]: + from backend.apps.agents.agents import agent_manager + from backend.apps.memory.distill import distill_session_memory + session = agent_manager.sessions.get(session_id) + if not session: + try: + session = await agent_manager.resume_session(session_id) + except ValueError: + return {"added": []} + return {"added": await distill_session_memory(session)} + + @memory.router.delete("/{fact_id}") @typechecked async def remove_fact(fact_id: str) -> Dict[str, bool]: diff --git a/frontend/src/app/pages/AgentChat/FollowupChips.tsx b/frontend/src/app/pages/AgentChat/FollowupChips.tsx index dbe1c53c..db1f84a7 100644 --- a/frontend/src/app/pages/AgentChat/FollowupChips.tsx +++ b/frontend/src/app/pages/AgentChat/FollowupChips.tsx @@ -33,6 +33,8 @@ const FollowupChips: React.FC = ({ sessionId, busy, messageC const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); const headers: Record = {}; if (tok) headers['Authorization'] = `Bearer ${tok}`; + // Same after-turn beat also feeds the memory distiller; fire-and-forget, backend gates cost. + void fetch(`${API_BASE}/memory/distill/${sessionId}`, { method: 'POST', headers }).catch(() => {}); const resp = await fetch(`${API_BASE}/agents/sessions/${sessionId}/followups?count=3`, { headers }); if (!resp.ok || seq !== fetchSeqRef.current) return; const data = await resp.json(); diff --git a/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx b/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx index 7fd59c11..edb29184 100644 --- a/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx +++ b/frontend/src/app/pages/Settings/sections/general/MemorySettings.tsx @@ -32,7 +32,8 @@ const MemorySettings: React.FC<{ const refresh = async (): Promise => { try { - const res = await fetch(`${API_BASE}/memory`); + // no-store: Chromium happily serves a cached list, which hides facts the distiller just added. + const res = await fetch(`${API_BASE}/memory`, { cache: 'no-store' }); if (res.ok) setFacts(((await res.json()) as { facts: MemoryFact[] }).facts); } catch { /* backend down reads as an empty list, never a crash */ } };