fix: multi-user distillation + datetime in context + session log labels
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from persona import persona_path
|
from persona import persona_path
|
||||||
@@ -36,6 +37,10 @@ def load_context(
|
|||||||
inara_dir = persona_path()
|
inara_dir = persona_path()
|
||||||
parts = []
|
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) ──────────────────────────────────
|
# ── 1. Core identity (always) ──────────────────────────────────
|
||||||
for filename in _CORE:
|
for filename in _CORE:
|
||||||
path = inara_dir / filename
|
path = inara_dir / filename
|
||||||
|
|||||||
@@ -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.
|
Ask the LLM to summarize MEMORY_SHORT.md → MEMORY_MID.md.
|
||||||
Uses DISTILL_BACKEND_MID if set (e.g. "local"), otherwise primary_backend.
|
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 llm_client import complete
|
||||||
from persona import set_context
|
from persona import set_context
|
||||||
|
|
||||||
u = username or settings.user_name.lower()
|
u, p = username, persona
|
||||||
p = persona or settings.agent_name.lower()
|
|
||||||
set_context(u, p)
|
set_context(u, p)
|
||||||
|
|
||||||
inara_dir = _persona_path(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"}
|
return {"error": "MEMORY_SHORT.md is empty — run distill/short first"}
|
||||||
|
|
||||||
budget_tokens = settings.memory_budget_mid
|
budget_tokens = settings.memory_budget_mid
|
||||||
|
persona_name = p.title()
|
||||||
|
user_name = u.title()
|
||||||
system_prompt = (
|
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. "
|
"Summarize the following recent session logs into a concise mid-term memory digest. "
|
||||||
f"Target length: under {budget_tokens} tokens. "
|
f"Target length: under {budget_tokens} tokens. "
|
||||||
"Focus on: recurring themes, important decisions made, ongoing projects, "
|
"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"{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"Write in first person as {persona_name} (e.g. '{user_name} and I worked on...'). "
|
||||||
"Use markdown headings. Be specific and concrete — no filler."
|
"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.
|
Ask the LLM to integrate MEMORY_MID.md into MEMORY_LONG.md.
|
||||||
Uses DISTILL_BACKEND_LONG if set, otherwise primary_backend.
|
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 llm_client import complete
|
||||||
from persona import set_context
|
from persona import set_context
|
||||||
|
|
||||||
u = username or settings.user_name.lower()
|
u, p = username, persona
|
||||||
p = persona or settings.agent_name.lower()
|
|
||||||
set_context(u, p)
|
set_context(u, p)
|
||||||
|
|
||||||
inara_dir = _persona_path(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"}
|
return {"error": "MEMORY_MID.md is empty — run distill/mid first"}
|
||||||
|
|
||||||
budget_tokens = settings.memory_budget_long
|
budget_tokens = settings.memory_budget_long
|
||||||
|
persona_name = p.title()
|
||||||
system_prompt = (
|
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. "
|
"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. "
|
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; "
|
"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")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
if not response_text.lstrip().startswith("# MEMORY_LONG"):
|
if not response_text.lstrip().startswith("# MEMORY_LONG"):
|
||||||
response_text = (
|
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"
|
f"*Last distilled: {now} via {backend}.*\n\n---\n\n"
|
||||||
+ response_text
|
+ response_text
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,14 +5,29 @@ Manual memory distillation endpoints.
|
|||||||
POST /distill/mid — summarize short → MEMORY_MID.md (LLM)
|
POST /distill/mid — summarize short → MEMORY_MID.md (LLM)
|
||||||
POST /distill/long — integrate mid → MEMORY_LONG.md (LLM)
|
POST /distill/long — integrate mid → MEMORY_LONG.md (LLM)
|
||||||
POST /distill/all — run all three in sequence
|
POST /distill/all — run all three in sequence
|
||||||
|
|
||||||
|
All endpoints require ?user=<username>&persona=<name> 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 memory_distiller import distill_short, distill_mid, distill_long
|
||||||
|
from persona import validate as validate_persona, set_context
|
||||||
import scheduler
|
import scheduler
|
||||||
|
|
||||||
router = APIRouter(prefix="/distill")
|
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")
|
@router.get("/status")
|
||||||
async def distill_status() -> dict:
|
async def distill_status() -> dict:
|
||||||
"""Show auto-distillation schedule and next run times."""
|
"""Show auto-distillation schedule and next run times."""
|
||||||
@@ -29,29 +44,45 @@ async def distill_status() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/short")
|
@router.post("/short")
|
||||||
async def do_distill_short() -> dict:
|
async def do_distill_short(
|
||||||
return {"ok": True, **distill_short()}
|
user: str = Query(...),
|
||||||
|
persona: str = Query(...),
|
||||||
|
) -> dict:
|
||||||
|
u, p = _resolve(user, persona)
|
||||||
|
return {"ok": True, **distill_short(u, p)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mid")
|
@router.post("/mid")
|
||||||
async def do_distill_mid() -> dict:
|
async def do_distill_mid(
|
||||||
result = await 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}
|
return {"ok": "error" not in result, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/long")
|
@router.post("/long")
|
||||||
async def do_distill_long() -> dict:
|
async def do_distill_long(
|
||||||
result = await 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}
|
return {"ok": "error" not in result, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/all")
|
@router.post("/all")
|
||||||
async def do_distill_all() -> dict:
|
async def do_distill_all(
|
||||||
short_result = distill_short()
|
user: str = Query(...),
|
||||||
mid_result = await distill_mid()
|
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:
|
if "error" in mid_result:
|
||||||
return {"ok": False, "short": short_result, "mid": mid_result}
|
return {"ok": False, "short": short_result, "mid": mid_result}
|
||||||
long_result = await distill_long()
|
long_result = await distill_long(u, p)
|
||||||
return {
|
return {
|
||||||
"ok": "error" not in long_result,
|
"ok": "error" not in long_result,
|
||||||
"short": short_result,
|
"short": short_result,
|
||||||
|
|||||||
@@ -19,41 +19,54 @@ logger = logging.getLogger(__name__)
|
|||||||
_scheduler: AsyncIOScheduler | None = None
|
_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:
|
async def _run_short() -> None:
|
||||||
from memory_distiller import distill_short
|
from memory_distiller import distill_short
|
||||||
|
for u, p in _all_personas():
|
||||||
try:
|
try:
|
||||||
result = distill_short()
|
result = distill_short(u, p)
|
||||||
logger.info("auto distill short: %d files, %d chars", result["files_included"], result["chars_written"])
|
logger.info("auto distill short [%s/%s]: %d files, %d chars", u, p, result["files_included"], result["chars_written"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("auto distill short failed: %s", e)
|
logger.error("auto distill short [%s/%s] failed: %s", u, p, e)
|
||||||
|
|
||||||
|
|
||||||
async def _run_mid() -> None:
|
async def _run_mid() -> None:
|
||||||
from memory_distiller import distill_mid
|
from memory_distiller import distill_mid
|
||||||
from notification import notify
|
from notification import notify
|
||||||
|
for u, p in _all_personas():
|
||||||
try:
|
try:
|
||||||
result = await distill_mid()
|
result = await distill_mid(u, p)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
logger.warning("auto distill mid skipped: %s", result["error"])
|
logger.warning("auto distill mid [%s/%s] skipped: %s", u, p, result["error"])
|
||||||
else:
|
else:
|
||||||
logger.info("auto distill mid: %d chars via %s", result["chars_written"], result["backend"])
|
logger.info("auto distill mid [%s/%s]: %d chars via %s", u, p, result["chars_written"], result["backend"])
|
||||||
await notify(result["username"], f"📝 Weekly memory digest complete ({result['chars_written']} chars via {result['backend']}).")
|
await notify(u, f"📝 Weekly memory digest complete ({result['chars_written']} chars via {result['backend']}).")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("auto distill mid failed: %s", e)
|
logger.error("auto distill mid [%s/%s] failed: %s", u, p, e)
|
||||||
|
|
||||||
|
|
||||||
async def _run_long() -> None:
|
async def _run_long() -> None:
|
||||||
from memory_distiller import distill_long
|
from memory_distiller import distill_long
|
||||||
from notification import notify
|
from notification import notify
|
||||||
|
for u, p in _all_personas():
|
||||||
try:
|
try:
|
||||||
result = await distill_long()
|
result = await distill_long(u, p)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
logger.warning("auto distill long skipped: %s", result["error"])
|
logger.warning("auto distill long [%s/%s] skipped: %s", u, p, result["error"])
|
||||||
else:
|
else:
|
||||||
logger.info("auto distill long: %d chars via %s", result["chars_written"], result["backend"])
|
logger.info("auto distill long [%s/%s]: %d chars via %s", u, p, 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.")
|
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:
|
except Exception as e:
|
||||||
logger.error("auto distill long failed: %s", e)
|
logger.error("auto distill long [%s/%s] failed: %s", u, p, e)
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler() -> AsyncIOScheduler | None:
|
def get_scheduler() -> AsyncIOScheduler | None:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from config import settings
|
from persona import persona_path, get_user, get_persona
|
||||||
from persona import persona_path
|
|
||||||
|
|
||||||
|
|
||||||
def log_turn(
|
def log_turn(
|
||||||
@@ -21,11 +20,15 @@ def log_turn(
|
|||||||
meta_parts = [p for p in [backend_label, host] if p]
|
meta_parts = [p for p in [backend_label, host] if p]
|
||||||
meta = f" · {' / '.join(meta_parts)}" if meta_parts else ""
|
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:
|
with open(log_file, "a") as f:
|
||||||
if is_new:
|
if is_new:
|
||||||
f.write(f"# Session Log — {today}\n")
|
f.write(f"# Session Log — {today}\n")
|
||||||
f.write(
|
f.write(
|
||||||
f"\n### [{timestamp}] `{session_id}`{meta}\n"
|
f"\n### [{timestamp}] `{session_id}`{meta}\n"
|
||||||
f"**{settings.user_name}:** {user_msg}\n\n"
|
f"**{user_label}:** {user_msg}\n\n"
|
||||||
f"**{settings.agent_name}:** {assistant_msg}\n"
|
f"**{persona_label}:** {assistant_msg}\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1724,7 +1724,7 @@
|
|||||||
async function runDistill(endpoint) {
|
async function runDistill(endpoint) {
|
||||||
showDistillStatus('distilling…', false);
|
showDistillStatus('distilling…', false);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/distill/${endpoint}`, { method: 'POST' });
|
const res = await fetch(`/distill/${endpoint}?${_fileParams}`, { method: 'POST' });
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (!res.ok || d.ok === false) {
|
if (!res.ok || d.ok === false) {
|
||||||
const err = d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`;
|
const err = d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user