/** * `EpisodeManager` — the write-path to `episodes` + `sessions`, wrapped * around per-episode in-memory state. * * Not visible to adapters directly — `SessionManager` wraps it and owns * the lifecycle. Exposed separately so Phase 15 (pipeline orchestrator) * can inject a custom one in tests. * * Persistence strategy: * - `start` → INSERT episodes row (status='open'), INSERT/UPSERT sessions. * - `addTurn` → in-memory only (turns persist via `traces` in Phase 6). * - `finalize` → UPDATE episodes.status='closed', endedAt, rTask. * - `abandon` → same UPDATE but tagged via meta.closeReason='abandoned'. * * The in-memory `EpisodeSnapshot` is what subscribers (orchestrator, * viewer SSE) receive on every event. We avoid re-reading from SQLite * on each turn — hot path stays in memory. */ import { ERROR_CODES, MemosError } from "../../agent-contract/errors.js"; import type { EpisodeId, SessionId } from "../../agent-contract/dto.js"; import { ids } from "../id.js"; import { rootLogger } from "../logger/index.js"; import type { EpochMs } from "../types.js"; import type { SessionRepo, EpisodesRepo } from "./persistence.js"; import type { EpisodeCloseReason, EpisodeFinalizeInput, EpisodeSnapshot, EpisodeStartInput, EpisodeTurn, EpisodeTurnInput, IntentDecision, SessionEventBus, } from "./types.js"; export interface EpisodeManagerDeps { sessionsRepo: SessionRepo; episodesRepo: EpisodesRepo; now?: () => EpochMs; bus: SessionEventBus; } export interface EpisodeManager { start(input: EpisodeStartInput, intent: IntentDecision): EpisodeSnapshot; addTurn(id: EpisodeId, turn: EpisodeTurnInput): EpisodeTurn; finalize(id: EpisodeId, input?: EpisodeFinalizeInput): EpisodeSnapshot; abandon(id: EpisodeId, reason: string): EpisodeSnapshot; discardEmpty(id: EpisodeId, reason: string): EpisodeSnapshot | null; attachTraceIds(id: EpisodeId, traceIds: string[]): void; hydrate(snapshot: EpisodeSnapshot): EpisodeSnapshot; patchMeta(id: EpisodeId, metaPatch: Record): EpisodeSnapshot; /** * V7 §0.1 "revision" path: reopen a previously-finalized episode so * the new turn appends to the same trace set. The caller is * responsible for having classified the turn relation; this method * performs no heuristics of its own. * * Emits `episode.reopened` with the given `reason`. If the episode * is already open (rare — race), this is a no-op. */ reopen(id: EpisodeId, reason: import("./types.js").TurnRelation): EpisodeSnapshot; get(id: EpisodeId): EpisodeSnapshot | null; listOpen(): EpisodeSnapshot[]; listForSession(sessionId: SessionId): EpisodeSnapshot[]; } export function createEpisodeManager(deps: EpisodeManagerDeps): EpisodeManager { const now = deps.now ?? Date.now; const log = rootLogger.child({ channel: "core.episode" }); // id → snapshot; we keep both open and recently-closed ones for short-term // lookups. The snapshot is evicted after `finalize`/`abandon` unless an // orchestrator is still holding a reference. const byId = new Map(); function get(id: EpisodeId): EpisodeSnapshot | null { return byId.get(id) ?? null; } function assertOpen(snap: EpisodeSnapshot | null, id: EpisodeId): EpisodeSnapshot { if (!snap) { throw new MemosError(ERROR_CODES.EPISODE_NOT_FOUND, `episode ${id} not found`, { episodeId: id, }); } if (snap.status === "closed") { throw new MemosError(ERROR_CODES.CONFLICT, `episode ${id} already closed`, { episodeId: id, status: snap.status, }); } return snap; } return { start(input: EpisodeStartInput, intent: IntentDecision): EpisodeSnapshot { if (!input.initialTurn || !input.initialTurn.content) { throw new MemosError( ERROR_CODES.INVALID_ARGUMENT, "episode.start requires an initial user turn with non-empty content", ); } const startedAt = input.initialTurn.ts ?? now(); const id = (input.id ?? ids.episode()) as EpisodeId; const firstTurn: EpisodeTurn = { ...input.initialTurn, id: ids.span(), ts: startedAt, }; const meta = { ...(input.meta ?? {}), topicState: (input.meta ?? {}).topicState ?? "active", initialUserText: (input.meta ?? {}).initialUserText ?? input.initialTurn.content, pendingUserText: (input.meta ?? {}).pendingUserText ?? input.initialTurn.content, }; const snap: EpisodeSnapshot = { id, sessionId: input.sessionId, startedAt, endedAt: null, status: "open", rTask: null, turnCount: 1, turns: [firstTurn], traceIds: [], meta: { ...meta, intent: { kind: intent.kind, signals: intent.signals } }, intent, }; byId.set(id, snap); deps.episodesRepo.insert({ id, sessionId: input.sessionId, ownerAgentKind: stringMeta(snap.meta, "ownerAgentKind") ?? "unknown", ownerProfileId: stringMeta(snap.meta, "ownerProfileId") ?? "default", ownerWorkspaceId: stringMeta(snap.meta, "ownerWorkspaceId") ?? null, startedAt, endedAt: null, traceIds: [], rTask: null, status: "open", meta: snap.meta, }); // `sessions.touch` so last_seen updates on every new episode. deps.sessionsRepo.touchLastSeen(input.sessionId, startedAt); log.info("episode.started", { episodeId: id, sessionId: input.sessionId, intent: intent.kind, retrieval: intent.retrieval, }); deps.bus.emit({ kind: "episode.started", episode: cloneSnapshot(snap) }); return cloneSnapshot(snap); }, addTurn(id, turn) { const snap = assertOpen(get(id), id); const full: EpisodeTurn = { ...turn, id: ids.span(), ts: turn.ts ?? now() }; snap.turns.push(full); snap.turnCount++; if (turn.role === "user") { snap.meta = { ...snap.meta, topicState: "active", pendingUserText: turn.content, lastUserText: turn.content, }; deps.episodesRepo.updateMeta(id, { topicState: "active", pendingUserText: turn.content, lastUserText: turn.content, }); } else if (turn.role === "assistant") { snap.meta = { ...snap.meta, topicState: "active", pendingUserText: undefined, lastAssistantText: turn.content, }; deps.episodesRepo.updateMeta(id, { topicState: "active", pendingUserText: undefined, lastAssistantText: turn.content, }); } deps.sessionsRepo.touchLastSeen(snap.sessionId, full.ts); log.debug("episode.turn_added", { episodeId: id, role: turn.role, turnCount: snap.turnCount, }); deps.bus.emit({ kind: "episode.turn_added", episodeId: id, turn: full }); return { ...full }; }, attachTraceIds(id, traceIds) { const snap = get(id); if (!snap) { throw new MemosError(ERROR_CODES.EPISODE_NOT_FOUND, `episode ${id} not found`, { episodeId: id, }); } if (traceIds.length === 0) return; snap.traceIds = [...snap.traceIds, ...traceIds]; deps.episodesRepo.updateTraceIds(id, snap.traceIds); }, hydrate(snapshot) { const existing = byId.get(snapshot.id); if (existing) return cloneSnapshot(existing); const snap: EpisodeSnapshot = { ...snapshot, turns: snapshot.turns.map((t) => ({ ...t })), traceIds: [...snapshot.traceIds], meta: { ...snapshot.meta }, }; byId.set(snapshot.id, snap); log.info("episode.hydrated", { episodeId: snap.id, sessionId: snap.sessionId, status: snap.status, turnCount: snap.turnCount, }); return cloneSnapshot(snap); }, patchMeta(id, metaPatch) { const snap = get(id); if (!snap) { throw new MemosError(ERROR_CODES.EPISODE_NOT_FOUND, `episode ${id} not found`, { episodeId: id, }); } snap.meta = { ...snap.meta, ...metaPatch }; deps.episodesRepo.updateMeta(id, metaPatch); return cloneSnapshot(snap); }, finalize(id, input) { const snap = assertOpen(get(id), id); const endedAt = now(); snap.status = "closed"; snap.endedAt = endedAt; if (input?.rTask !== undefined) snap.rTask = input.rTask; if (input?.patchMeta) snap.meta = { ...snap.meta, ...input.patchMeta }; snap.meta = { ...snap.meta, topicState: "ended", closeReason: "finalized" }; deps.episodesRepo.close(id, endedAt, snap.rTask ?? undefined, snap.meta); log.info("episode.finalized", { episodeId: id, sessionId: snap.sessionId, turnCount: snap.turnCount, durationMs: endedAt - snap.startedAt, rTask: snap.rTask, }); deps.bus.emit({ kind: "episode.finalized", episode: cloneSnapshot(snap), closedBy: "finalized" }); return cloneSnapshot(snap); }, abandon(id, reason) { const snap = get(id); if (!snap) { throw new MemosError(ERROR_CODES.EPISODE_NOT_FOUND, `episode ${id} not found`, { episodeId: id, }); } if (snap.status === "closed") { return cloneSnapshot(snap); } const endedAt = now(); snap.status = "closed"; snap.endedAt = endedAt; snap.meta = { ...snap.meta, topicState: "ended", closeReason: "abandoned", abandonReason: reason, }; deps.episodesRepo.close(id, endedAt, snap.rTask ?? undefined, snap.meta); log.warn("episode.abandoned", { episodeId: id, sessionId: snap.sessionId, turnCount: snap.turnCount, reason, }); deps.bus.emit({ kind: "episode.finalized", episode: cloneSnapshot(snap), closedBy: "abandoned" }); deps.bus.emit({ kind: "episode.abandoned", episodeId: id, reason }); return cloneSnapshot(snap); }, discardEmpty(id, reason) { const snap = get(id); if (!snap) return null; if (snap.traceIds.length > 0 || snap.turns.some((t) => t.role === "assistant" && t.content.trim())) { throw new MemosError(ERROR_CODES.CONFLICT, `episode ${id} is not empty`, { episodeId: id, status: snap.status, }); } byId.delete(id); deps.episodesRepo.deleteById(id); log.info("episode.discarded_empty", { episodeId: id, sessionId: snap.sessionId, reason, }); return cloneSnapshot(snap); }, reopen(id, reason) { const snap = get(id); if (!snap) { throw new MemosError(ERROR_CODES.EPISODE_NOT_FOUND, `episode ${id} not found`, { episodeId: id, }); } if (snap.status === "open") { // Already open — no-op; still surface the intent for audit. log.debug("episode.reopen_skipped", { episodeId: id, reason }); return cloneSnapshot(snap); } snap.status = "open"; snap.endedAt = null; const previousReward = snap.rTask != null ? { previousRTask: snap.rTask, previousScoredAt: rewardScoredAt(snap.meta), } : {}; snap.meta = { ...snap.meta, closeReason: undefined, topicState: "active", reopenedAt: now(), reopenReason: reason, ...(snap.rTask != null || snap.meta.reward ? { rewardDirty: { reason: "episode_reopened", reopenedFor: reason, at: now(), ...previousReward, }, } : {}), }; deps.episodesRepo.reopen(id, snap.meta); log.info("episode.reopened", { episodeId: id, sessionId: snap.sessionId, reason, turnCount: snap.turnCount, }); deps.bus.emit({ kind: "episode.reopened", episode: cloneSnapshot(snap), reason }); return cloneSnapshot(snap); }, get(id) { const s = get(id); return s ? cloneSnapshot(s) : null; }, listOpen() { const out: EpisodeSnapshot[] = []; for (const s of byId.values()) if (s.status === "open") out.push(cloneSnapshot(s)); return out; }, listForSession(sessionId) { const out: EpisodeSnapshot[] = []; for (const s of byId.values()) if (s.sessionId === sessionId) out.push(cloneSnapshot(s)); return out; }, }; } function stringMeta(meta: Record | undefined, key: string): string | undefined { const value = meta?.[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; } function rewardScoredAt(meta: Record): unknown { const reward = meta.reward; if (!reward || typeof reward !== "object") return undefined; return (reward as { scoredAt?: unknown }).scoredAt; } function cloneSnapshot(s: EpisodeSnapshot): EpisodeSnapshot { return { ...s, turns: s.turns.map((t) => ({ ...t })), traceIds: [...s.traceIds], meta: { ...s.meta }, }; }