import { type Message, type Provider, type Usage } from "@kenkaiiii/gg-ai"; import { type AgentTurnTiming } from "@kenkaiiii/gg-agent"; import type { CompletedItem } from "../ui/app-items.js"; import { type StorageNormalizationMetrics } from "./session-storage.js"; interface BaseEntry { id: string; parentId: string | null; timestamp: string; } export interface MessageEntry extends BaseEntry { type: "message"; message: Message; } export interface ModelChangeEntry extends BaseEntry { type: "model_change"; provider: Provider; model: string; } export interface ThinkingLevelChangeEntry extends BaseEntry { type: "thinking_level_change"; level: string; } export interface CompactionEntry extends BaseEntry { type: "compaction"; originalCount: number; newCount: number; summary: string; } export interface LabelEntry extends BaseEntry { type: "label"; label: string; } export interface CustomEntry extends BaseEntry { type: "custom"; kind: string; data: unknown; } export declare const DISPLAY_ITEM_CUSTOM_KIND = "display_item"; export declare const TURN_METRIC_CUSTOM_KIND = "turn_metric"; export type TurnMetricCost = { status: "known"; usd: number; source: string; effectiveAt: string; } | { status: "unavailable"; reason: string; }; export interface TurnMetricPayload { version: 1; turn: number; provider: Provider; model: string; stopReason: string; usage: Usage; timing: AgentTurnTiming; cost: TurnMetricCost; } /** Custom-entry kind for a Ken Kai (mentor agent) turn. Ken's advisory * conversation is NOT part of the LLM message history (GG Coder never sees it), * but it's persisted alongside the build session so it survives resume. Stored * as a `custom` entry with `parentId: null` so it is NEVER on the message DAG * branch — this keeps it out of `getMessages()` AND avoids racing the build * session's leaf pointer (Ken runs concurrently). `afterMessageCount` is the * number of non-system messages that existed when the turn was recorded, used * to interleave Ken turns back into the transcript chronologically. */ export declare const KEN_TURN_CUSTOM_KIND = "ken_turn"; export interface KenTurnPayload { version: 1; question: string; reply: string; afterMessageCount: number; /** Read-only: branch messages that preceded this entry in FILE order. Never * persisted — see {@link RecordedPosition}. */ recordedAfterMessageCount?: number; } /** * Read-time position rescue for transcript markers. * * `afterMessageCount` is authoritative, but historical sessions were rewritten * by compaction without rebasing it (markers were re-persisted carrying indices * from the much longer pre-compaction transcript). Those anchors then replay far * too late or past the end — the "everything bunched at the bottom" symptom. * * File order gives an independent, always-in-range estimate: the number of * branch messages already written when the marker line was appended. It's only * consulted when the stored anchor is out of range, so healthy sessions are * untouched. */ export interface RecordedPosition { recordedAfterMessageCount?: number; } /** Custom-entry kind for an autopilot verdict marker. Mirrors `ken_turn`: * persisted as a `custom` entry with `parentId: null` so it's never on the * message DAG (GG Coder never sees it) but survives resume/compaction and * interleaves back into the transcript via `afterMessageCount`. Covers all * four terminal/near-terminal autopilot markers so a resumed session renders * the exact same Ken bubble the live run showed — never the raw verdict * keyword (e.g. `ALL_CLEAR`) the model actually replied with. */ export declare const AUTOPILOT_MARKER_CUSTOM_KIND = "autopilot_marker"; export interface AutopilotMarkerPayload extends RecordedPosition { version: 1; phase: "prompted" | "done" | "human" | "capped" | "plan_approved"; reason?: string; body?: string; afterMessageCount: number; } /** Custom-entry kind for a generic app transcript marker (plan-mode banner, * task header, error row, user-bubble display hint). Same not-on-the-DAG * treatment as Ken turns / autopilot markers: persisted with `parentId: null` * so the LLM never sees it, anchored by `afterMessageCount` so the host can * interleave it back into the transcript on resume. */ export declare const APP_MARKER_CUSTOM_KIND = "app_transcript_marker"; export interface AppMarkerPayload extends RecordedPosition { version: 1; kind: "plan" | "task" | "error" | "user_hint" | "compaction" | "agent_handoff" /** Mid-session model/provider change; `data` carries { from, to, provider }. */ | "model_switch" /** Transcript imported from another agent; `data` carries * { source, sourcePath, messageCount, dropped }. Import is lossy, so this * marker is the record of what the imported thread is missing. */ | "import" /** A run that opened the journal and never closed it — the process died * mid-run. `data` carries { generation, startedAt }. Surfaced so the user * can review what the run's tools already changed; never auto-resumed. */ | "interrupted_run"; afterMessageCount: number; /** Kind-specific display fields (reason/title/headline/kenSent/counts/…). */ data: Record; } /** * Run journal — a matched pair of custom entries bracketing every provider run. * * Same not-on-the-DAG treatment as Ken turns (`parentId: null`): the model never * sees them, and they can't race the message branch's leaf pointer. A * `run_started` with no matching `run_finished` is the on-disk signature of a * run that died mid-flight — the host surfaces it on load instead of silently * resuming work whose tools already half-mutated the repo. * * `generation` is the `RunLifecycle` generation, so the pairing inherits that * class's generation-safety: a stale run cannot close a newer one's journal. */ export declare const RUN_STARTED_CUSTOM_KIND = "run_started"; export declare const RUN_FINISHED_CUSTOM_KIND = "run_finished"; export type RunOutcome = "completed" | "failed" | "aborted"; export interface RunStartedPayload { version: 1; generation: number; startedAt: string; /** Non-system message count when the run began, for locating it in the transcript. */ afterMessageCount: number; } export interface RunFinishedPayload { version: 1; generation: number; outcome: RunOutcome; } /** One run as reconstructed from the journal. `outcome` is undefined if unfinished. */ export interface RunJournalEntry { generation: number; startedAt: string; afterMessageCount: number; outcome?: RunOutcome; } export type SessionEntry = MessageEntry | ModelChangeEntry | ThinkingLevelChangeEntry | CompactionEntry | LabelEntry | CustomEntry; export interface SessionHeader { type: "session"; version: 2; id: string; /** Stable identity shared by checkpoint files created during compaction. */ conversationId?: string; /** Monotonic physical checkpoint number; legacy and ordinary sessions are 0. */ generation?: number; /** Physical checkpoint compacted to create this file. */ parentSessionId?: string; /** SHA-256 of the non-system source messages compacted into this checkpoint. */ sourceFingerprint?: string; /** Visible retained-tail size at checkpoint creation; later appends are outside this boundary. */ retainedMessageCount?: number; /** Stable display fallback retained when checkpoint messages contain only internal summaries. */ preview?: string; timestamp: string; cwd: string; provider: Provider; model: string; leafId: string | null; } export interface SessionInfo { id: string; path: string; timestamp: string; /** Timestamp of the most recent message (falls back to creation timestamp). */ lastActivity: string; cwd: string; messageCount: number; /** * First user-authored prompt, for use as a human title. * * Filled during the single pass `list()` already makes over each file, so a * caller that needs titles — a session browser, a phone — does not have to * reopen all of them. Undefined when the session has no user prompt of its * own (empty, or only compaction/autopilot injections). */ preview?: string; } /** * Everything a session browser needs, at a fraction of the cost of {@link SessionInfo}. * * {@link SessionManager.list} parses every line of every session file to count * messages — ~450 MB of JSON (some gzipped) on a well-used machine, several * seconds per call. A summary reads only the header and the first user prompt, * and takes `lastActivity` from the file's mtime (session files are * append-only, so mtime IS the last activity). That is the difference between * a phone waiting seconds for its session list and not noticing the wait. * * The trade: no exact `messageCount`, only `hasMessages`. Callers that need * counts keep using {@link SessionManager.list}. */ export interface SessionSummary { id: string; path: string; timestamp: string; /** File mtime — the last append, i.e. the last activity. */ lastActivity: string; cwd: string; hasMessages: boolean; /** Same sourcing rules as {@link SessionInfo.preview}. */ preview?: string; } export interface SessionMaintenanceMetrics extends StorageNormalizationMetrics { deletedFiles: number; deletedBytes: number; archivedFiles: number; archivedSourceBytes: number; archivedBytes: number; bytesSaved: number; failures: number; } export interface CompactionAttemptState { fingerprint: string; policyKey: string; outcome: "success" | "failed" | "noop"; checkpointId?: string; updatedAt: string; expiresAt?: string; } export interface BranchInfo { /** The entry ID where this branch diverges from its parent branch */ branchPointId: string; /** The leaf (tip) entry ID of this branch */ leafId: string; /** Number of entries in this branch after the branch point */ entryCount: number; /** Timestamp of the first entry in the branch */ timestamp: string; } export declare class SessionManager { private static activePathsByRoot; private static maintenanceByRoot; private sessionsDir; private warnedPersistCodes; /** Session files whose tail this process already checked (see {@link sealTornTail}). */ private sealedTails; /** Called once per error code when session persistence fails (e.g. ENOSPC). */ onPersistError?: (error: NodeJS.ErrnoException) => void; constructor(sessionsDir: string); private coordinationKey; private coordinationRoot; private leaseOwner; private processIsAlive; private waitForLease; /** Serialize compaction work across processes for one logical conversation. */ withCompactionLease(conversationId: string, signal: AbortSignal | undefined, work: () => Promise): Promise; readCompactionAttemptState(conversationId: string): Promise; writeCompactionAttemptState(conversationId: string, state: CompactionAttemptState): Promise; /** * Terminate a half-written last line before appending after it. * * Entries are one JSON object per line, appended with a trailing newline. A * process killed mid-append leaves that final line without its newline — and * the next append then fuses onto it, so ONE torn write destroys TWO records: * the incomplete one and the first record of the resumed turn. Both are then * silently skipped as malformed at load, so the user loses a message from * their own history with no error shown. Writing the missing newline first * confines the loss to the record that was actually torn. * * Once per file per process: the fuse can only happen on the first append * after opening a file that someone else left torn. */ private sealTornTail; /** * Session persistence must never crash a live session. Disk-full (ENOSPC), * permission, or quota errors during transcript writes are reported once * per error code and otherwise swallowed — the in-memory session keeps going. */ private handlePersistError; private dirForCwd; create(cwd: string, provider: Provider, model: string, options?: { conversationId?: string; preview?: string; generation?: number; parentSessionId?: string; sourceFingerprint?: string; retainedMessageCount?: number; }): Promise<{ id: string; path: string; header: SessionHeader; }>; registerActivePath(sessionPath: string): void; unregisterActivePath(sessionPath: string): void; private protectedSessionBases; load(sessionPath: string): Promise<{ header: SessionHeader; entries: SessionEntry[]; path: string; }>; /** * Load the contiguous checkpoint ancestry ending at the canonical newest file. * * The returned order is oldest → newest. A missing, corrupt, cyclic, or * cross-conversation parent stops traversal at the oldest readable checkpoint, * allowing display callers to retain that checkpoint's compaction summary as a * fallback. Parent archives are read in place rather than thawed because this * API is for history reconstruction, not resuming writes. */ loadCheckpointChain(sessionPath: string): Promise>; private loadPhysicalCheckpoint; private readSessionInfo; private sessionCandidates; /** * Read just enough of a session file to summarize it. * * Stops at the first user prompt (or the first message, when the header * already carries a preview) instead of parsing the whole transcript — this * is what makes listing every session on the machine cheap. Files whose * first user message is far down (long tool runs before the user speaks) * are capped rather than allowed to stall the list. */ private readSessionSummary; /** * Keep the newest file per conversation and sort newest first. * * A conversation can span several files (compaction forks a fresh one), so * without this collapse a resumed thread appears once per checkpoint. */ private static dedupeByConversation; private static compareCheckpoints; list(cwd: string): Promise; /** * One project's sessions, newest first, using the early-exit summary read. * * For callers that render a list rather than exact message counts — on a * project with hundreds of sessions this is the difference between instant * and a noticeable stall. */ listSummaries(cwd: string): Promise; /** * Every session on this machine, across every project, newest first. * * A remote client is not browsing one checkout the way the TUI is — it is * asking "what have I been working on?", and the answer spans projects. Each * entry carries its own `cwd`, so the caller can group by project. Uses the * early-exit summary read, because "every session on the machine" is exactly * where a full parse of each file becomes a multi-second stall. */ listAllSummaries(): Promise; /** * Summarize session files, keeping the newest file per conversation. * * A conversation can span several files (compaction forks a fresh one), so * without this collapse a resumed thread appears once per checkpoint. */ private summarize; getMostRecent(cwd: string): Promise; /** Resolve an id, conversation id, or stale physical path to the newest checkpoint. */ resolveCanonicalSession(requested: string, cwd?: string): Promise; findById(cwd: string, sessionId: string): Promise; /** Locate and canonicalize a session identity across every project directory. */ findAnyById(sessionId: string, cwd?: string): Promise; private storageDirectories; private logicalSessionBases; private removeStoragePath; pruneOldSessions(options: { maxAgeDays: number; keepPaths?: string[]; }): Promise<{ deletedFiles: number; freedBytes: number; }>; runMaintenance(options: { retentionDays: number; keepPaths?: string[]; now?: number; }): Promise; private runMaintenanceUnsafe; appendEntry(sessionPath: string, entry: SessionEntry): Promise; appendTurnMetric(sessionPath: string, payload: TurnMetricPayload): Promise; /** Open the run journal for one `RunLifecycle` generation. */ appendRunStarted(sessionPath: string, payload: RunStartedPayload): Promise; /** Close the run journal for one generation. Its absence marks a crashed run. */ appendRunFinished(sessionPath: string, payload: RunFinishedPayload): Promise; /** * Reconstruct the run journal in file order, pairing each `run_started` with * the `run_finished` carrying the same generation. * * Generations are NOT unique across a session file. `RunLifecycle` counts * from zero per instance, and a resumed session builds a fresh one — so the * first run after every app restart is generation 1 again. Pairing therefore * tracks only the runs still OPEN: a `run_finished` closes its generation and * releases the number, and a later `run_started` reusing it opens a new run. * * Without that release, a crash in a resumed session was invisible: the * reused `run_started` looked like a duplicate and was dropped, which is * exactly the case this journal exists to catch. * * A repeat `run_started` for a generation that is still open IS ignored, so a * truncated or replayed log reports one unfinished run — never a phantom pile. */ getRunJournal(entries: SessionEntry[]): RunJournalEntry[]; /** Runs that opened the journal but never closed it — i.e. crashed mid-flight. */ getUnfinishedRuns(entries: SessionEntry[]): RunJournalEntry[]; updateLeaf(sessionPath: string, leafId: string): Promise; private updateLeafUnsafe; /** * Get messages for the current branch. If leafId is set, walks the * DAG from leaf to root. Otherwise returns all entries linearly. */ getMessages(entries: SessionEntry[], leafId?: string | null): Message[]; getDisplayItems(entries: SessionEntry[], _leafId?: string | null): CompletedItem[]; /** * Walk entries in file order, tracking how many branch (non-system) messages * have been written so far, and hand each custom entry that count. This is * the independent position estimate behind {@link RecordedPosition}. */ private mapCustomEntriesInFileOrder; /** Read all persisted Ken turns in file order. Returns them regardless of * branch (Ken turns are not chained into the DAG), validated + normalized. */ getKenTurns(entries: SessionEntry[], leafId?: string | null): KenTurnPayload[]; /** Read all persisted app transcript markers in file order, validated + * normalized (same not-on-the-DAG treatment as Ken turns). */ getAppMarkers(entries: SessionEntry[], leafId?: string | null): AppMarkerPayload[]; /** Read validated per-turn usage and timing records in file order. */ getTurnMetrics(entries: SessionEntry[]): TurnMetricPayload[]; /** Read all persisted autopilot markers in file order, validated + normalized * (same not-on-the-DAG treatment as Ken turns). */ getAutopilotMarkers(entries: SessionEntry[], leafId?: string | null): AutopilotMarkerPayload[]; /** * Ensure every assistant message with tool_use blocks is followed by a tool * message containing matching tool_result entries. Inserts synthetic * tool_result messages where needed to prevent Anthropic API 400 errors. */ static repairToolPairs(messages: Message[]): Message[]; /** * Build a lookup Map from entry id → entry. Reusable across multiple * getBranch / listBranches calls on the same entry set. */ private buildIndex; /** * Walk the DAG from a leaf entry back to the root, returning entries * in chronological order (root → leaf). This is the "branch" — the * path through the conversation tree that leads to the given leaf. * * Accepts an optional pre-built index to avoid redundant Map allocations * when called in a loop. */ getBranch(entries: SessionEntry[], leafId: string | null, byId?: Map): SessionEntry[]; /** * List all branches (leaf nodes) in a session's entry DAG. * A leaf is any entry whose id is not referenced as a parentId by any other entry. */ listBranches(entries: SessionEntry[]): BranchInfo[]; } export {}; //# sourceMappingURL=session-manager.d.ts.map