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 */ }
};