/** * Owliver's conversation history. * * The panel already persisted the *current* thread per page, in sessionStorage, * so returning to Candidates found the conversation you left there. What it had * no notion of was a conversation you had *finished*: starting a new one * overwrote the old one, and there was no way back to anything. * * This is that archive, and it is deliberately the same artefact — a list of * threads in the shape the panel already stores. Two differences, both * intentional: * * - **localStorage, not session.** A history that empties when the tab closes * is not a history. Threads outlive the session, which is what makes * "Yesterday" and "Older" mean anything. * - **Written continuously, not on reset.** The archive is updated as the * conversation grows rather than when it ends, so a thread is recoverable * even if the tab is closed mid-answer. * * Nothing here is a second conversation store: the live thread is still the one * the panel renders from. This holds copies, keyed by conversation, for the one * question the panel could not previously answer — "what did I ask before?" */ const KEY = 'krow_assistant:history'; /** Threads older than this stop being useful and start being clutter. */ const MAX_AGE_DAYS = 60; /** A hard cap, so a heavy user cannot fill the origin's storage quota. */ const MAX_RECORDS = 100; const DAY = 24 * 60 * 60 * 1000; /** * The shape of a stored record. * * Bumped when a record gains fields, so a reader can tell an old record from a * new one instead of guessing from which keys happen to be present. */ const SCHEMA = 2; /** * An older record, brought up to date on the way out. * * A conversation held before agents existed is still a conversation somebody * had. It gets the new fields at their empty values and keeps everything it * already had — in particular `messages` is passed through untouched, so * nothing a reader wrote is rewritten by a schema change. * * Migrating on read rather than rewriting the store means a browser that never * opens History again still loses nothing, and there is no migration pass that * can fail halfway. */ function migrate(record) { if (record.schema === SCHEMA) return record; return { ...record, schema: SCHEMA, /* Absent, not unknown: these conversations genuinely had no agent, no recorded skills and no feedback. */ agentId: record.agentId ?? null, pageContext: record.pageContext ?? null, skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [], toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [], knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [], feedback: record.feedback ?? null, }; } /** Every archived conversation, newest first. Never throws. */ export function readHistory() { try { const raw = localStorage.getItem(KEY); const list = raw ? JSON.parse(raw) : []; if (!Array.isArray(list)) return []; const cutoff = Date.now() - MAX_AGE_DAYS * DAY; return list .filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length) .filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff) .map(migrate) .sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()); } catch { /* Corrupt or unavailable storage is an empty history, not an error the reader has to deal with. */ return []; } } /** The first thing the person asked, which is what the thread was about. */ function titleFor(messages) { const first = messages.find((m) => m.role === 'user' && m.text); const text = String(first?.text || '').trim(); if (!text) return 'Conversation'; return text.length > 60 ? `${text.slice(0, 57)}…` : text; } /** * Records this conversation, replacing its own earlier entry. * * Matched on `id`, so a thread being added to updates in place rather than * appearing once per turn. */ export function saveConversation({ id, contextId, page, messages, /** * Who answered, where, and what it actually used. * * Recorded from what ran rather than from what was available: `skillsUsed` * is the skill that answered a turn, not every skill the page offered. A * record of what *could* have happened would make the insight figures * describe the registry instead of the conversation. */ agentId = null, pageContext = null, skillsUsed = [], toolsUsed = [], knowledgeUsed = [], }) { if (!id || !Array.isArray(messages) || !messages.length) return readHistory(); /* Feedback belongs to the conversation, not to the turn that triggered a save, so an existing rating survives the thread growing. */ const existing = readHistory().find((r) => r.id === id) || null; const record = { schema: SCHEMA, id, contextId, page: page || '', agentId, /* The *reduced* envelope — where the question was asked, never a copy of what was on screen. Storing selections and computed figures would write the dataset into localStorage a turn at a time. */ pageContext, skillsUsed: [...new Set(skillsUsed.filter(Boolean))], toolsUsed: [...new Set(toolsUsed.filter(Boolean))], knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))], feedback: existing?.feedback ?? null, title: titleFor(messages), turns: messages.filter((m) => m.role === 'user').length, updatedAt: new Date().toISOString(), messages, }; const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS); try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* Quota or private mode: the live thread is unaffected, only the archive is. Dropping the oldest half is a better outcome than losing every future write. */ try { localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4)))); } catch { /* Storage is unusable. History is in-memory for this session only. */ } } return next; } /** * Records how a conversation was rated. * * Updates in place and never appends: rating a thread twice is a correction, * not two opinions. Returns the refreshed list so a caller re-renders from one * read rather than two. */ export function recordFeedback(id, feedback) { const list = readHistory(); const at = list.findIndex((r) => r.id === id); if (at === -1) return list; const next = [...list]; next[at] = { ...next[at], feedback: feedback ? { rating: feedback.rating === 'up' ? 'up' : 'down', note: String(feedback.note || '').trim() || null, at: new Date().toISOString(), } /* Clearing is a real action — someone un-rating a thread should leave no rating behind rather than a neutral one. */ : null, }; try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* The rating stays in memory for this session. */ } return next; } /** Forgets one conversation. */ export function removeConversation(id) { const next = readHistory().filter((r) => r.id !== id); try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* Ignore — the caller re-reads either way. */ } return next; } /** Forgets everything. */ export function clearHistory() { try { localStorage.removeItem(KEY); } catch { /* Ignore. */ } return []; } /** * Conversations under a heading, in the order a person thinks about time. * * Empty groups are dropped rather than rendered as a heading with nothing * under it. */ export function groupByRecency(records, now = new Date()) { const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const startOfYesterday = new Date(startOfToday.getTime() - DAY); const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY); const groups = [ { id: 'today', label: 'Today', items: [] }, { id: 'yesterday', label: 'Yesterday', items: [] }, { id: 'week', label: 'Earlier this week', items: [] }, { id: 'older', label: 'Older', items: [] }, ]; for (const record of records) { const at = new Date(record.updatedAt || 0).getTime(); if (at >= startOfToday.getTime()) groups[0].items.push(record); else if (at >= startOfYesterday.getTime()) groups[1].items.push(record); else if (at >= startOfWeek.getTime()) groups[2].items.push(record); else groups[3].items.push(record); } return groups.filter((g) => g.items.length); } /** A new conversation id. Time-ordered, so ids sort the way threads do. */ export function newConversationId() { return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; }