From 20494d1620ef110babf46156e11c386be62a3474 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 20:04:55 +1000 Subject: [PATCH] ai: persist agent-history + per-message attribution to localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reloading the page mid-conversation used to keep the messages (via LIVE_KEY) but lose the two agent-tracking maps, so: - row signatures snapped back to whoever was currently active - the next turn after reload didn't include the PRIOR HAND-OFF block, even though the transcript clearly had multiple personas Both maps are now stored alongside the live snapshot: AGENTS_KEY crema.ai.agent-history set of Agent MSG_AGENTS_KEY crema.ai.message-agents index -> Agent Stored as JSON arrays since Maps don't serialize. Hydrated on mount via useState lazy initializer, persisted on every change via two useEffects, cleared in lockstep with LIVE_KEY when the operator hits Clear conversation. Reload mid-thread now reads identically to the pre-reload state: - atlas» turns 1-3 stay attributed to atlas - pythia» turn 4 stays attributed to pythia - next turn after reload still carries the hand-off note Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routes/ai.tsx | 78 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 402e15e..f1db217 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -115,6 +115,65 @@ function saveLive(msgs: LLMMessage[]) { // Quota exceeded or similar — silently drop persistence. } } +/* Per-message agent attribution + the set of agents that have produced + * turns in the current conversation. Persisted alongside LIVE_KEY so a + * reload mid-thread preserves both who-said-what and the hand-off note + * the next turn carries. + * + * Stored as plain JSON shapes (Maps don't serialize): + * AGENTS_KEY: Array<[agentId, Agent]> ← agentHistory + * MSG_AGENTS_KEY: Array<[index, Agent]> ← messageAgents + */ +const AGENTS_KEY = "crema.ai.agent-history" +const MSG_AGENTS_KEY = "crema.ai.message-agents" + +function loadAgentHistory(): Map { + if (typeof window === "undefined") return new Map() + try { + const raw = localStorage.getItem(AGENTS_KEY) + if (!raw) return new Map() + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][]) + } catch {} + return new Map() +} +function saveAgentHistory(m: Map) { + if (typeof window === "undefined") return + if (m.size === 0) { + localStorage.removeItem(AGENTS_KEY) + return + } + try { + localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()])) + } catch {} +} + +function loadMessageAgents(): Map { + if (typeof window === "undefined") return new Map() + try { + const raw = localStorage.getItem(MSG_AGENTS_KEY) + if (!raw) return new Map() + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][]) + } catch {} + return new Map() +} +function saveMessageAgents(m: Map) { + if (typeof window === "undefined") return + if (m.size === 0) { + localStorage.removeItem(MSG_AGENTS_KEY) + return + } + try { + localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()])) + } catch {} +} +function clearAgentMaps() { + if (typeof window === "undefined") return + localStorage.removeItem(AGENTS_KEY) + localStorage.removeItem(MSG_AGENTS_KEY) +} + function clearLive() { if (typeof window === "undefined") return localStorage.removeItem(LIVE_KEY) @@ -369,19 +428,29 @@ function ChatSurface({ // the new agent knows it's stepping into a transcript started by another // persona — without that note it answers as if it produced every prior // turn itself, which is jarring. + // Both maps are seeded from localStorage so a reload mid-thread keeps + // attribution + hand-off context intact. Persisted on every change via + // the effect lower down, cleared together with the live snapshot. const [agentHistory, setAgentHistory] = useState>( - () => new Map(), + () => loadAgentHistory(), ) const prevAgentRef = useRef(activeAgent) // Per-message agent attribution: which agent produced the assistant // message at each index. Populated when a turn finishes streaming, used - // in MessageRow to show the right name in the signature line. Survives - // until the conversation is cleared. + // in MessageRow to show the right name in the signature line. const [messageAgents, setMessageAgents] = useState>( - () => new Map(), + () => loadMessageAgents(), ) + // Persist whenever either map changes. + useEffect(() => { + saveAgentHistory(agentHistory) + }, [agentHistory]) + useEffect(() => { + saveMessageAgents(messageAgents) + }, [messageAgents]) + // Hand-off prompt — only emitted when this conversation has been touched // by more than one agent. Lists prior personas the new agent might see in // the transcript. @@ -439,6 +508,7 @@ function ChatSurface({ const resetAndClear = useCallback(() => { initialLive.current = [] clearLive() + clearAgentMaps() setMessages([]) setAgentHistory(new Map()) setMessageAgents(new Map())