/** * Hindsight memory backend. * * Wires the per-session lifecycle (recall on first turn, retain every Nth * agent_end, etc.) on top of the AgentSession event stream. Hindsight runtime * state is owned by the AgentSession so lifetime follows the actual domain * owner instead of a parallel session-id registry. */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { Settings } from "../config/settings"; import { memoryToolRefs } from "../memory-backend/tool-names"; import type { MemoryBackend, MemoryBackendStartOptions, MemoryPromptPreparation } from "../memory-backend/types"; import hindsightInstructions from "../prompts/system/hindsight-instructions.md" with { type: "text" }; import type { AgentSession } from "../session/agent-session"; import { type BankScope, computeBankScope, type HindsightBankSet } from "./bank"; import { createHindsightClient } from "./client"; import { type HindsightConfig, isHindsightConfigured, loadHindsightConfig } from "./config"; import { type HindsightMessage, hasSubstantiveContent } from "./content"; import { HindsightSessionState } from "./state"; import { cfgMemoryBackend } from "../memory-backend/settings"; /** Reload the active session's mental-model cache and prompt. */ export async function reloadMentalModelsForSession(session: AgentSession): Promise { const state = session.getHindsightSessionState(); if (!state) return false; return await state.reloadMentalModels(); } export const hindsightBackend: MemoryBackend = { id: "hindsight", async start(options: MemoryBackendStartOptions): Promise { const { session, settings } = options; const sessionId = session.sessionId; if (!sessionId) return; // Subagents alias the parent's state so recall/retain/reflect tool calls // persist to the same Hindsight bank. Auto-recall and auto-retain stay // with the parent — running them per subagent would double-recall and // pollute the bank with internal exploration transcripts. if (options.taskDepth > 0) { const parent = options.parentHindsightSessionState; if (!parent) return; const previous = session.setHindsightSessionState( new HindsightSessionState({ agentDir: options.agentDir, sessionId, client: parent.client, bankId: parent.bankId, retainTags: parent.retainTags, recallTags: parent.recallTags, recallTagsMatch: parent.recallTagsMatch, config: parent.config, session, banksSet: parent.banksSet, lastRetainedTurn: 0, hasRecalledForFirstTurn: true, aliasOf: parent, }), ); // Aliases don't run auto-recall/auto-retain, so any pending retain // queue belongs to the previous alias and is safe to drop after a // best-effort flush (`flushRetainQueue` is no-op when empty). await previous?.flushRetainQueue(); previous?.dispose(); return; } const config = loadHindsightConfig(settings); if (!isHindsightConfigured(config)) { logger.warn("Hindsight: memory.backend=hindsight but hindsight.apiUrl is unset; backend inert."); return; } await installPrimaryState(session, settings, new Set()); }, async buildDeveloperInstructions(_agentDir, _cwd, settings, session): Promise { const config = loadHindsightConfig(settings); if (!isHindsightConfigured(config)) return undefined; const state = session?.getHindsightSessionState(); const primary = state?.aliasOf ?? state; const recallSnippet = primary?.lastRecallSnippet; const mentalModelsSnippet = primary?.mentalModelsSnippet; // Order: static instructions → mental models (stable, curated) → recall // (volatile per turn). Stable context first so the LLM's prior is // anchored on curated knowledge. const parts = [prompt.render(hindsightInstructions, { toolRefs: memoryToolRefs(session?.getXdevToolEntries()) })]; if (mentalModelsSnippet) parts.push(mentalModelsSnippet); if (recallSnippet) parts.push(recallSnippet); return parts.join("\n\n"); }, async beforeAgentStartPrompt( session: AgentSession, promptText: string, signal?: AbortSignal, ): Promise { const state = session.getHindsightSessionState(); if (!state) return undefined; const preparation = await state.beforeAgentStartPrompt(promptText, signal); if (!preparation) return undefined; return { context: preparation.context, commit: () => session.getHindsightSessionState() === state && preparation.commit(), }; }, async clear(_agentDir, _cwd, session): Promise { // Hindsight memory is server-side. The local cache is what we can wipe — // operators who want to delete the upstream bank should use the Hindsight // UI / `deleteBank` directly. Drain pending tool-initiated retains first // so we don't lose them. const state = session?.getHindsightSessionState(); if (state) await state.flushRetainQueue(); const previous = session?.setHindsightSessionState(undefined); previous?.dispose(); logger.warn( "Hindsight memory is server-side; only the local recall cache was cleared. " + "Delete the Hindsight bank from the UI to wipe upstream state.", ); }, async enqueue(_agentDir, _cwd, session): Promise { const state = session?.getHindsightSessionState(); const primary = state?.aliasOf ? undefined : state; if (!primary) return; await primary.flushRetainQueue(); await primary.forceRetainCurrentSession(); }, async preCompactionContext( messages: AgentMessage[], settings: Settings, session?: AgentSession, signal?: AbortSignal, ): Promise { const config = loadHindsightConfig(settings); if (!isHindsightConfigured(config)) return undefined; const state = session?.getHindsightSessionState(); if (!state) return undefined; const flat = flattenMessagesForRecall(messages); return await state.recallForCompaction(flat, signal); }, async applySettings(session: AgentSession): Promise { await awaitPrimaryStateRebuild(session); }, }; interface PrimaryRebuildTask { /** A rebuild was requested and the loop has not consumed it yet. */ pending: boolean; /** Last failed transition; only a completed transition, never a no-op, clears it. */ error?: unknown; /** Settles once the loop has drained every request queued so far. */ completion: Promise; } const primaryRebuildTasks = new WeakMap(); /** * Coalesce and serialize live rebuilds for one session. A cwd reload changes * several `hindsight.*` settings at once and the cwd rebind requests its own * pass; running every request immediately would let multiple rebuilds capture * the same old state and leak the fresh states installed by earlier continuations. * * Returns the task that owns the request so a caller that must not continue * until the rebuild landed can await it (see {@link awaitPrimaryStateRebuild}). */ function schedulePrimaryStateRebuild(session: AgentSession): PrimaryRebuildTask { const task = primaryRebuildTasks.get(session); if (task) { task.pending = true; return task; } const nextTask: PrimaryRebuildTask = { pending: true, completion: Promise.resolve() }; primaryRebuildTasks.set(session, nextTask); nextTask.completion = Promise.resolve().then(async () => { try { while (nextTask.pending) { nextTask.pending = false; try { if (await reconcilePrimaryState(session)) nextTask.error = undefined; } catch (err) { nextTask.error = err; logger.warn("Hindsight: state rebuild failed", { error: String(err) }); } } } finally { // Only loops that can consume requests remain registered; retire before yielding. primaryRebuildTasks.delete(session); } }); return nextTask; } /** * Request a primary-state rebuild and wait until every rebuild queued meanwhile * has settled, re-raising the last failure. Serves live `hindsight.*` edits * (via `hindsightBackend.applySettings`) and the cwd rebind. */ async function awaitPrimaryStateRebuild(session: AgentSession): Promise { let task: PrimaryRebuildTask | undefined = schedulePrimaryStateRebuild(session); while (task) { await task.completion; if (task.error !== undefined) throw task.error; // A settings edit that landed while we waited installs a fresh task; the // rebuild is not done until the last one has settled. task = primaryRebuildTasks.get(session); } } /** * Finish the memory rebind that a cwd move started, before the move reports * success. The settings reload may already have started a backend switch or a * rebuild for changed `hindsight.*` values: let it settle first, so the rebind * never judges a half-switched runtime, then redo the destination's own * transition and re-raise its failure instead of letting a half-rebound * session look like a completed move. * * The rebuild is also requested here rather than only awaited, because the * bank scope derives from the cwd itself: a move between projects with * identical settings changes no setting, yet must still re-route the bank. */ export async function rebindMemoryBackendForCwd(session: AgentSession): Promise { if (!session.memoryEnabled) return; await session.settleMemoryBackend(); // Other backends have no Hindsight rebuild path. Reapply them on an // explicit cwd move, but let an in-flight Hindsight transition finish (or // fail) rather than retrying a partially torn-down backend outside its task. if (!session.getHindsightSessionState() && !primaryRebuildTasks.has(session)) { // The manager already has the new cwd, and this may also be rollback // from a destination that never committed. Drain existing writes without // capturing the transcript under either transient scope. await session.applyMemoryBackend({ retainMnemopi: false }); } await awaitPrimaryStateRebuild(session); // Startup is best-effort, but a move must not commit an unusable memory backend. if (cfgMemoryBackend.get(session.settings) === "mnemopi" && !session.getMnemopiSessionState()) { throw new Error("Mnemopi backend failed to initialise for the destination cwd."); } } /** * Build (or rebuild) the primary `HindsightSessionState` for `session` from * the current settings and install it. Disposes any previous primary state * after flushing its retain queue so in-flight tool-initiated retains land in * the bank that was selected when they were enqueued, not in the new bank. * * Later `hindsight.*` edits re-enter through `hindsightBackend.applySettings`. */ async function installPrimaryState( session: AgentSession, settings: Settings, banksSet: HindsightBankSet, ): Promise { const sessionId = session.sessionId; if (!sessionId) return undefined; const config = loadHindsightConfig(settings); if (!isHindsightConfigured(config)) return undefined; const client = createHindsightClient(config); const scope = computeBankScope(config, session.sessionManager.getCwd()); // Cleanup any stale state for this session (defensive — prevents leaks // when a session is reused without going through dispose). Flush the // previous state's retain queue BEFORE clearing it, otherwise // `HindsightRetainQueue.#doFlush` sees `session.getHindsightSessionState() // !== state` and drops the batch. Re-read after the await so a concurrent // owner cannot leave the actual current state undisposed. let previous = session.getHindsightSessionState(); if (previous) { await previous.flushRetainQueue(); } const latest = session.getHindsightSessionState(); if (latest && latest !== previous) { previous?.dispose(); previous = latest; await previous.flushRetainQueue(); } const state = new HindsightSessionState({ agentDir: settings.getAgentDir(), sessionId, client, bankId: scope.bankId, retainTags: scope.retainTags, recallTags: scope.recallTags, recallTagsMatch: scope.recallTagsMatch, config, session, banksSet, lastRetainedTurn: 0, hasRecalledForFirstTurn: false, }); const displaced = session.setHindsightSessionState(state); if (displaced && displaced !== previous) { await displaced.flushRetainQueue(); displaced.dispose(); } previous?.dispose(); state.attachSessionListeners(); // Deliver what earlier processes left in this destination's outbox; stored tags route each event. void state.retainQueue.flush(); // Kick off mental-model bootstrap. Resolves asynchronously; the first // turn races and is covered in `beforeAgentStartPrompt` via // `mentalModelsLoadPromise`. Subsequent turns see the populated cache // because `runMentalModelLoad` calls `refreshBaseSystemPrompt`. if (config.mentalModelsEnabled) { state.mentalModelsLoadPromise = state.runMentalModelLoad(scope).catch(err => { logger.debug("Hindsight: mental-model bootstrap failed", { bankId: state.bankId, error: String(err) }); }); } return state; } /** * Live-settings and cwd-rebind body: re-derive what the current settings * select and make the runtime match it. Any resolved-config change (endpoint, * token, missions, recall/retain tuning, timeouts, mental models) or bank-scope * move installs a fresh primary state. No-op when nothing moved, when this * session hosts a subagent alias (the parent owns the route), or when Hindsight * is neither live nor selected. * * Resolves true only when the runtime actually moved — the backend owner * re-applied the selection, or a fresh primary state was installed — so the * scheduler can tell a completed transition from a no-op. */ async function reconcilePrimaryState(session: AgentSession): Promise { const current = session.getHindsightSessionState(); if (current?.aliasOf) return false; const settings = session.settings; const config = loadHindsightConfig(settings); const selected = cfgMemoryBackend.get(settings) === "hindsight" && isHindsightConfigured(config); // The selection itself moved — a project layer switched `memory.backend`, // or left `hindsight.apiUrl` unset. Only the session's backend owner can // install or retire a backend's runtime state, memory tools, and prompt, // and it flushes the outgoing state's queued retains on the way out. if (selected !== (current !== undefined)) { await session.applyMemoryBackend(); return true; } if (!current) return false; const next = computeBankScope(config, session.sessionManager.getCwd()); if (bankScopesEqual(next, current) && hindsightConfigsEqual(current.config, config)) return false; // A confirmed bank includes its mission metadata, not just its server/id. // Reuse confirmations only while the effective PUT payload is unchanged. const sameBankConfig = current.config.hindsightApiUrl === config.hindsightApiUrl && current.config.bankMission.trim() === config.bankMission.trim() && (current.config.retainMission?.trim() || "") === (config.retainMission?.trim() || ""); const state = await installPrimaryState(session, settings, sameBankConfig ? current.banksSet : new Set()); if (!state) return false; // A destination with no recall injection must not reuse the source bank's prompt. await session.refreshBaseSystemPrompt(); return true; } /** * Structural compare of two resolved Hindsight configs. Both sides come from * `loadHindsightConfig`, so iterating one side's keys covers the whole shape * and a newly added config field is picked up without touching this compare. */ function hindsightConfigsEqual(a: HindsightConfig, b: HindsightConfig): boolean { for (const key of Object.keys(a) as (keyof HindsightConfig)[]) { const left = a[key]; const right = b[key]; if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right) || !stringArraysEqual(left, right)) return false; continue; } if (left !== right) return false; } return true; } /** Tag-array equality: order matters because we never reorder on the way in. */ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): boolean { if (a === b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } /** * Structural compare of a freshly resolved `BankScope` against a live state's * bank routing. Used by the scope-change handler to skip rebuilds that don't * actually move the bank or its tag filters. */ function bankScopesEqual( scope: BankScope, state: Pick, ): boolean { return ( scope.bankId === state.bankId && stringArraysEqual(scope.retainTags, state.retainTags) && stringArraysEqual(scope.recallTags, state.recallTags) && scope.recallTagsMatch === state.recallTagsMatch ); } /** Reduce arbitrary AgentMessages into the Hindsight flat-text shape. */ function flattenMessagesForRecall(messages: AgentMessage[]): HindsightMessage[] { const out: HindsightMessage[] = []; for (const msg of messages) { if (msg.role === "user") { const content = msg.content; if (typeof content === "string") { if (hasSubstantiveContent(content)) out.push({ role: "user", content }); continue; } if (Array.isArray(content)) { const text = content .filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text") .map(b => b.text) .join("\n"); if (hasSubstantiveContent(text)) out.push({ role: "user", content: text }); } continue; } if (msg.role === "assistant") { const text = msg.content .filter((b): b is { type: "text"; text: string } => b.type === "text") .map(b => b.text) .join("\n"); if (hasSubstantiveContent(text)) out.push({ role: "assistant", content: text }); } } return out; }