/** * turn-log — IDB-persisted continuous conversation transcript. * * Inspired by devduck's `get_last_messages()` which injects the last ~200 * turns into the system prompt as a timestamped flow. This gives the agent * continuous context awareness across reloads, thread switches, and even * context-window trims (since the transcript survives message drops). * * Storage model: * - Single IDB key `careless-turn-log` holds a global ring (last 200 turns * across ALL threads, tagged with threadId). * - Each turn: { id, ts, threadId, role, text, toolSummary? } * - Ring is capped; oldest entries evicted on append. * * Why global not per-thread? The agent benefits from seeing past thread * activity ("I asked the same thing yesterday in another thread"). Per-thread * filtering is done at render time if desired. */ import { get, set } from 'idb-keyval' const TURN_LOG_KEY = 'careless-turn-log' const MAX_TURNS = 200 const MAX_TEXT_LEN = 500 // truncate long turns to keep prompt size bounded export interface TurnEntry { id: string ts: number threadId: string | null role: 'user' | 'assistant' text: string /** Short summary of tools fired during this turn (assistant only) */ tools?: string } let cache: TurnEntry[] | null = null let writeLock: Promise = Promise.resolve() async function load(): Promise { if (cache) return cache try { const raw = await get(TURN_LOG_KEY) cache = Array.isArray(raw) ? raw : [] } catch { cache = [] } return cache } /** Serialize writes to avoid lost-update race under parallel streams. */ function serialize(fn: () => Promise): Promise { const next = writeLock.then(fn, fn) writeLock = next.then(() => undefined, () => undefined) return next } /** Append a turn to the log. Fire-and-forget safe; idempotent on duplicate id. */ export async function appendTurn(entry: Omit & Partial>): Promise { return serialize(async () => { const log = await load() const full: TurnEntry = { id: entry.id || `t-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, ts: entry.ts ?? Date.now(), threadId: entry.threadId, role: entry.role, text: (entry.text || '').slice(0, MAX_TEXT_LEN), ...(entry.tools ? { tools: entry.tools.slice(0, 200) } : {}), } // Dedupe on id (in case of retries / regenerate) if (log.some(t => t.id === full.id)) return log.push(full) // Evict oldest const trimmed = log.length > MAX_TURNS ? log.slice(-MAX_TURNS) : log cache = trimmed try { await set(TURN_LOG_KEY, trimmed) } catch (e) { console.warn('[turn-log] persist failed', e) } }) } /** Read recent turns, optionally filtered by thread or limit. */ export async function readTurns(opts: { limit?: number; threadId?: string | null; since?: number } = {}): Promise { const log = await load() let result = log if (opts.threadId !== undefined) result = result.filter(t => t.threadId === opts.threadId) if (opts.since) result = result.filter(t => t.ts >= opts.since!) if (opts.limit) result = result.slice(-opts.limit) return result } /** Synchronous read from cache (for system-prompt hot path). Returns [] if not yet loaded. */ export function readTurnsSync(limit = MAX_TURNS): TurnEntry[] { if (!cache) return [] return cache.slice(-limit) } /** Force-load cache (call once on app boot so readTurnsSync works on first render). */ export async function primeTurnLog(): Promise { const log = await load() return log.length } /** Format turns as a continuous flow for system-prompt injection. */ export function formatTurnsForPrompt(turns: TurnEntry[], opts: { maxChars?: number; activeThreadId?: string | null } = {}): string { if (!turns.length) return '' const maxChars = opts.maxChars ?? 12000 const lines: string[] = [] let chars = 0 // Walk newest-to-oldest so if we truncate, we keep the freshest const reversed = [...turns].reverse() const kept: string[] = [] for (const t of reversed) { const time = new Date(t.ts).toLocaleTimeString('en-US', { hour12: false }) const date = new Date(t.ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) const threadTag = t.threadId && t.threadId !== opts.activeThreadId ? ` {${t.threadId.slice(-4)}}` : '' const roleIcon = t.role === 'user' ? '👤' : '🤖' const toolSuffix = t.tools ? ` ◦ ${t.tools}` : '' const line = `[${date} ${time}]${threadTag} ${roleIcon} ${t.role}: ${t.text}${toolSuffix}` if (chars + line.length > maxChars) break kept.push(line) chars += line.length + 1 } kept.reverse() // back to chronological return `## 📜 Recent Turns (last ${kept.length}, across threads)\n${kept.join('\n')}` } /** Clear the log (exposed for /clear slash-command integration). */ export async function clearTurnLog(): Promise { return serialize(async () => { cache = [] try { await set(TURN_LOG_KEY, []) } catch {} }) } /** Stats for debugging. */ export async function turnLogStats(): Promise<{ count: number; oldestTs: number | null; newestTs: number | null; threads: string[] }> { const log = await load() if (!log.length) return { count: 0, oldestTs: null, newestTs: null, threads: [] } return { count: log.length, oldestTs: log[0].ts, newestTs: log[log.length - 1].ts, threads: Array.from(new Set(log.map(t => t.threadId).filter(Boolean) as string[])), } }