From 0ffcd57c9506ca3a28be7fb77b409fdc697c1ab9 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 5 May 2026 18:44:51 -0400 Subject: [PATCH] fix: multi-user distillation + datetime in context + session log labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distillation was silently operating on scott/inara for all users due to ContextVar defaults. All three distill endpoints now require ?user=&persona= query params and validate them via persona.validate(). Memory distiller signatures changed from Optional to required positional args — no more global settings fallback. Scheduler now iterates all users/personas instead of hardcoding the primary user. - context_loader: inject current date/time as first system prompt section - session_logger: use get_user()/get_persona() from context instead of settings globals so Holly/Brian sessions show correct speaker labels - memory_distiller: system prompts now reference u.title()/p.title() instead of settings.user_name/settings.agent_name - distill router: Query(...) enforces params; _resolve() validates persona - scheduler: _all_personas() helper iterates every user/persona for distill - app.js: runDistill() now appends ?user=&persona= via _fileParams Co-Authored-By: Claude Sonnet 4.6 --- cortex/context_loader.py | 5 ++++ cortex/memory_distiller.py | 23 ++++++++------- cortex/routers/distill.py | 53 +++++++++++++++++++++++++++------- cortex/scheduler.py | 59 +++++++++++++++++++++++--------------- cortex/session_logger.py | 11 ++++--- cortex/static/app.js | 2 +- 6 files changed, 103 insertions(+), 50 deletions(-) diff --git a/cortex/context_loader.py b/cortex/context_loader.py index 9808e53..2e18237 100644 --- a/cortex/context_loader.py +++ b/cortex/context_loader.py @@ -1,3 +1,4 @@ +from datetime import datetime from pathlib import Path from persona import persona_path @@ -36,6 +37,10 @@ def load_context( inara_dir = persona_path() parts = [] + # ── 0. Current date and time (always — injected first so it's prominent) ── + now = datetime.now().astimezone() + parts.append(f"--- System ---\nCurrent date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}") + # ── 1. Core identity (always) ────────────────────────────────── for filename in _CORE: path = inara_dir / filename diff --git a/cortex/memory_distiller.py b/cortex/memory_distiller.py index a68fca0..8808b30 100644 --- a/cortex/memory_distiller.py +++ b/cortex/memory_distiller.py @@ -74,7 +74,7 @@ def distill_short(username: str | None = None, persona: str | None = None) -> di } -async def distill_mid(username: str | None = None, persona: str | None = None) -> dict: +async def distill_mid(username: str, persona: str) -> dict: """ Ask the LLM to summarize MEMORY_SHORT.md → MEMORY_MID.md. Uses DISTILL_BACKEND_MID if set (e.g. "local"), otherwise primary_backend. @@ -82,8 +82,7 @@ async def distill_mid(username: str | None = None, persona: str | None = None) - from llm_client import complete from persona import set_context - u = username or settings.user_name.lower() - p = persona or settings.agent_name.lower() + u, p = username, persona set_context(u, p) inara_dir = _persona_path(u, p) @@ -93,13 +92,15 @@ async def distill_mid(username: str | None = None, persona: str | None = None) - return {"error": "MEMORY_SHORT.md is empty — run distill/short first"} budget_tokens = settings.memory_budget_mid + persona_name = p.title() + user_name = u.title() system_prompt = ( - f"You are {settings.agent_name}'s memory distillation system. " + f"You are {persona_name}'s memory distillation system. " "Summarize the following recent session logs into a concise mid-term memory digest. " f"Target length: under {budget_tokens} tokens. " "Focus on: recurring themes, important decisions made, ongoing projects, " - f"{settings.user_name}'s current state and priorities, and anything that should persist into future sessions. " - f"Write in first person as {settings.agent_name} (e.g. '{settings.user_name} and I worked on...'). " + f"{user_name}'s current state and priorities, and anything that should persist into future sessions. " + f"Write in first person as {persona_name} (e.g. '{user_name} and I worked on...'). " "Use markdown headings. Be specific and concrete — no filler." ) @@ -126,7 +127,7 @@ async def distill_mid(username: str | None = None, persona: str | None = None) - } -async def distill_long(username: str | None = None, persona: str | None = None) -> dict: +async def distill_long(username: str, persona: str) -> dict: """ Ask the LLM to integrate MEMORY_MID.md into MEMORY_LONG.md. Uses DISTILL_BACKEND_LONG if set, otherwise primary_backend. @@ -134,8 +135,7 @@ async def distill_long(username: str | None = None, persona: str | None = None) from llm_client import complete from persona import set_context - u = username or settings.user_name.lower() - p = persona or settings.agent_name.lower() + u, p = username, persona set_context(u, p) inara_dir = _persona_path(u, p) @@ -146,8 +146,9 @@ async def distill_long(username: str | None = None, persona: str | None = None) return {"error": "MEMORY_MID.md is empty — run distill/mid first"} budget_tokens = settings.memory_budget_long + persona_name = p.title() system_prompt = ( - f"You are {settings.agent_name}'s long-term memory curator. " + f"You are {persona_name}'s long-term memory curator. " "You will receive the current long-term memory and a recent mid-term digest. " f"Integrate the new information into the long-term memory. Target: under {budget_tokens} tokens. " "Rules: preserve important historical facts; update or replace stale information; " @@ -170,7 +171,7 @@ async def distill_long(username: str | None = None, persona: str | None = None) now = datetime.now().strftime("%Y-%m-%d %H:%M") if not response_text.lstrip().startswith("# MEMORY_LONG"): response_text = ( - f"# MEMORY_LONG.md — {settings.agent_name} Long-Term Memory\n\n" + f"# MEMORY_LONG.md — {persona_name} Long-Term Memory\n\n" f"*Last distilled: {now} via {backend}.*\n\n---\n\n" + response_text ) diff --git a/cortex/routers/distill.py b/cortex/routers/distill.py index afb2410..d7fb4eb 100644 --- a/cortex/routers/distill.py +++ b/cortex/routers/distill.py @@ -5,14 +5,29 @@ Manual memory distillation endpoints. POST /distill/mid — summarize short → MEMORY_MID.md (LLM) POST /distill/long — integrate mid → MEMORY_LONG.md (LLM) POST /distill/all — run all three in sequence + +All endpoints require ?user=&persona= query params so distillation +targets the correct persona. Without them, the request is rejected (no silent fallback +to server defaults — that caused wrong-user distillation in a multi-user setup). """ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, Query from memory_distiller import distill_short, distill_mid, distill_long +from persona import validate as validate_persona, set_context import scheduler router = APIRouter(prefix="/distill") +def _resolve(user: str, persona: str) -> tuple[str, str]: + """Validate and set persona context. Raises 404 if the persona doesn't exist.""" + try: + u, p = validate_persona(user, persona) + except Exception: + raise HTTPException(status_code=404, detail=f"Persona not found: {user}/{persona}") + set_context(u, p) + return u, p + + @router.get("/status") async def distill_status() -> dict: """Show auto-distillation schedule and next run times.""" @@ -29,29 +44,45 @@ async def distill_status() -> dict: @router.post("/short") -async def do_distill_short() -> dict: - return {"ok": True, **distill_short()} +async def do_distill_short( + user: str = Query(...), + persona: str = Query(...), +) -> dict: + u, p = _resolve(user, persona) + return {"ok": True, **distill_short(u, p)} @router.post("/mid") -async def do_distill_mid() -> dict: - result = await distill_mid() +async def do_distill_mid( + user: str = Query(...), + persona: str = Query(...), +) -> dict: + u, p = _resolve(user, persona) + result = await distill_mid(u, p) return {"ok": "error" not in result, **result} @router.post("/long") -async def do_distill_long() -> dict: - result = await distill_long() +async def do_distill_long( + user: str = Query(...), + persona: str = Query(...), +) -> dict: + u, p = _resolve(user, persona) + result = await distill_long(u, p) return {"ok": "error" not in result, **result} @router.post("/all") -async def do_distill_all() -> dict: - short_result = distill_short() - mid_result = await distill_mid() +async def do_distill_all( + user: str = Query(...), + persona: str = Query(...), +) -> dict: + u, p = _resolve(user, persona) + short_result = distill_short(u, p) + mid_result = await distill_mid(u, p) if "error" in mid_result: return {"ok": False, "short": short_result, "mid": mid_result} - long_result = await distill_long() + long_result = await distill_long(u, p) return { "ok": "error" not in long_result, "short": short_result, diff --git a/cortex/scheduler.py b/cortex/scheduler.py index 5cac08b..6af9177 100644 --- a/cortex/scheduler.py +++ b/cortex/scheduler.py @@ -19,41 +19,54 @@ logger = logging.getLogger(__name__) _scheduler: AsyncIOScheduler | None = None +def _all_personas() -> list[tuple[str, str]]: + """Return [(username, persona_name)] for every persona on this instance.""" + from persona import list_users, list_user_personas + pairs = [] + for u in list_users(): + for p in list_user_personas(u): + pairs.append((u, p)) + return pairs + + async def _run_short() -> None: from memory_distiller import distill_short - try: - result = distill_short() - logger.info("auto distill short: %d files, %d chars", result["files_included"], result["chars_written"]) - except Exception as e: - logger.error("auto distill short failed: %s", e) + for u, p in _all_personas(): + try: + result = distill_short(u, p) + logger.info("auto distill short [%s/%s]: %d files, %d chars", u, p, result["files_included"], result["chars_written"]) + except Exception as e: + logger.error("auto distill short [%s/%s] failed: %s", u, p, e) async def _run_mid() -> None: from memory_distiller import distill_mid from notification import notify - try: - result = await distill_mid() - if "error" in result: - logger.warning("auto distill mid skipped: %s", result["error"]) - else: - logger.info("auto distill mid: %d chars via %s", result["chars_written"], result["backend"]) - await notify(result["username"], f"📝 Weekly memory digest complete ({result['chars_written']} chars via {result['backend']}).") - except Exception as e: - logger.error("auto distill mid failed: %s", e) + for u, p in _all_personas(): + try: + result = await distill_mid(u, p) + if "error" in result: + logger.warning("auto distill mid [%s/%s] skipped: %s", u, p, result["error"]) + else: + logger.info("auto distill mid [%s/%s]: %d chars via %s", u, p, result["chars_written"], result["backend"]) + await notify(u, f"📝 Weekly memory digest complete ({result['chars_written']} chars via {result['backend']}).") + except Exception as e: + logger.error("auto distill mid [%s/%s] failed: %s", u, p, e) async def _run_long() -> None: from memory_distiller import distill_long from notification import notify - try: - result = await distill_long() - if "error" in result: - logger.warning("auto distill long skipped: %s", result["error"]) - else: - logger.info("auto distill long: %d chars via %s", result["chars_written"], result["backend"]) - await notify(result["username"], f"🧠 Monthly long-term memory integration complete ({result['chars_written']} chars via {result['backend']}). Worth a quick review.") - except Exception as e: - logger.error("auto distill long failed: %s", e) + for u, p in _all_personas(): + try: + result = await distill_long(u, p) + if "error" in result: + logger.warning("auto distill long [%s/%s] skipped: %s", u, p, result["error"]) + else: + logger.info("auto distill long [%s/%s]: %d chars via %s", u, p, result["chars_written"], result["backend"]) + await notify(u, f"🧠 Monthly long-term memory integration complete ({result['chars_written']} chars via {result['backend']}). Worth a quick review.") + except Exception as e: + logger.error("auto distill long [%s/%s] failed: %s", u, p, e) def get_scheduler() -> AsyncIOScheduler | None: diff --git a/cortex/session_logger.py b/cortex/session_logger.py index b258de2..9aa2eff 100644 --- a/cortex/session_logger.py +++ b/cortex/session_logger.py @@ -1,6 +1,5 @@ from datetime import datetime -from config import settings -from persona import persona_path +from persona import persona_path, get_user, get_persona def log_turn( @@ -21,11 +20,15 @@ def log_turn( meta_parts = [p for p in [backend_label, host] if p] meta = f" · {' / '.join(meta_parts)}" if meta_parts else "" + # Use the actual user/persona names from the current request context + user_label = get_user().title() + persona_label = get_persona().title() + with open(log_file, "a") as f: if is_new: f.write(f"# Session Log — {today}\n") f.write( f"\n### [{timestamp}] `{session_id}`{meta}\n" - f"**{settings.user_name}:** {user_msg}\n\n" - f"**{settings.agent_name}:** {assistant_msg}\n" + f"**{user_label}:** {user_msg}\n\n" + f"**{persona_label}:** {assistant_msg}\n" ) diff --git a/cortex/static/app.js b/cortex/static/app.js index 724a110..7c7ff02 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -1724,7 +1724,7 @@ async function runDistill(endpoint) { showDistillStatus('distilling…', false); try { - const res = await fetch(`/distill/${endpoint}`, { method: 'POST' }); + const res = await fetch(`/distill/${endpoint}?${_fileParams}`, { method: 'POST' }); const d = await res.json(); if (!res.ok || d.ok === false) { const err = d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`;