/**
 * Event-sourced session service: append-only session log, in-memory store, and
 * the derived LLM message history. Persistence is a plugin concern (subscribe
 * to `session/event`, drain on `session/flush`).
 *
 * @module @deepseek-ai/dsh-session
 */
import { Service } from '@deepseek-ai/cordis';
import { isAbsolute } from 'node:path';
import { deepFreeze } from '@deepseek-ai/dsh-llm';
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope';
import { SESSION_FORMAT_VERSION, SessionId } from "./types.js";
import { snapshotJsonValue } from "./json.js";
import { deriveEventMessage, SurfaceManager } from "./surface.js";
import { foldRequestHeader } from "./request-header.js";
export * from "./types.js";
export { SessionPreparation } from "./preparation.js";
export { isJsonValue, snapshotJsonValue } from "./json.js";
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from "./repair.js";
export { decodeStorageRecord, packChunkRuns } from "./chunk-rows.js";
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from "./surface.js";
export { canonicalHeader, foldRequestHeader, headerEquals } from "./request-header.js";
export { KNOWN_SESSION_EVENT_TYPES } from "./known-event-types.js";
/** Validate and freeze one detached creation header in place. */
function validateSessionHeader(id, input) {
    if (input === null || typeof input !== 'object' || Array.isArray(input)) {
        throw new Error('session header is not a plain JSON record');
    }
    const record = input;
    if (record.version !== SESSION_FORMAT_VERSION) {
        throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`);
    }
    if (record.id !== id) {
        throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`);
    }
    if (typeof record.createdAt !== 'number'
        || !Number.isSafeInteger(record.createdAt)
        || record.createdAt < 0) {
        throw new Error('session header createdAt must be a non-negative safe integer');
    }
    if (record.cwd !== undefined) {
        if (typeof record.cwd !== 'string')
            throw new Error('session header cwd must be a string');
        if (!isAbsolute(record.cwd)) {
            throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`);
        }
    }
    if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
        throw new Error('session header parentSession must be a string');
    }
    if (record.seedLength !== undefined
        && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
        throw new Error('session header seedLength must be a non-negative safe integer');
    }
    if (record.origin !== undefined && record.origin !== 'subagent') {
        throw new Error('session header origin must be "subagent"');
    }
    if (record.delegationDepth !== undefined
        && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
        throw new Error('session header delegationDepth must be a non-negative safe integer');
    }
    if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') {
        throw new Error('session header agentPreset must be a string');
    }
    return deepFreeze(record);
}
/** Validate and freeze one exclusively owned persistence header in place. */
function validateRestoredSessionHeader(id, input) {
    if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
        const prototype = Reflect.getPrototypeOf(input);
        if (prototype !== Object.prototype && prototype !== null) {
            throw new Error('session header is not a plain JSON record');
        }
    }
    return validateSessionHeader(id, input);
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id, source) {
    const input = source === undefined
        ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
        : source;
    const snapshot = snapshotJsonValue(input);
    if (snapshot === undefined)
        throw new Error('session header is not losslessly JSON-serializable');
    return validateSessionHeader(id, snapshot);
}
/**
 * Validate an exclusively owned event and deeply freeze its identified message
 * without copying the event. The caller transfers an object graph that no
 * producer retains and that shares no mutable children with another event.
 * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
 * @param event - exclusively owned event imported across a trusted boundary.
 * @returns the same event object with a validated, deeply frozen message.
 */
export function adoptSessionEvent(event) {
    assertMessageEventShape(event, `session event at seq ${event.seq}`);
    switch (event.type) {
        case 'user/message':
            deepFreeze(event.data);
            break;
        case 'assistant/message':
        case 'tool/result':
            deepFreeze(event.data.message);
            break;
        default:
            // SessionEventMap is merge-extensible; plugin-owned events carry no core message.
            break;
    }
    return event;
}
/**
 * Detach one event while preserving deep immutability for its identified message.
 * @param event - event imported across a query or persistence boundary.
 * @returns a detached event snapshot with a validated, deeply frozen message.
 */
export function snapshotSessionEvent(event) {
    return adoptSessionEvent(structuredClone(event));
}
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
function freezeRestoredObject(value) {
    const pending = [value];
    while (pending.length > 0) {
        // The non-empty check proves an object remains to visit.
        // oxlint-disable-next-line typescript/no-non-null-assertion
        const current = pending.pop();
        Object.freeze(current);
        for (const key in current) {
            const child = current[key];
            if (child !== null && typeof child === 'object')
                pending.push(child);
        }
    }
    return value;
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value, index) {
    const event = value;
    if (event['type'] === 'request/header-delta') {
        throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`);
    }
    for (const key in event) {
        switch (key) {
            case 'type':
            case 'seq':
            case 'time':
            case 'data':
            case 'surfaceOp':
            case 'sourceEventSeqs':
            case 'ignorable':
                break;
            default:
                throw new Error(`seed event at index ${index} has an invalid event envelope`);
        }
    }
    const type = event['type'];
    const seq = event['seq'];
    const time = event['time'];
    if (typeof type !== 'string'
        || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
        || typeof time !== 'number' || !Number.isSafeInteger(time)
        || event['data'] === undefined
        || (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
        throw new Error(`seed event at index ${index} has an invalid event envelope`);
    }
    switch (type) {
        case 'request/header':
        case 'user/message':
        case 'assistant/message':
        case 'tool/result':
            assertCurrentLlmShape(event, index);
            break;
    }
}
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
function assertCurrentLlmShape(event, index) {
    const data = event['data'];
    const record = typeof data === 'object' && data !== null
        ? data
        : undefined;
    if (event['type'] === 'request/header') {
        const header = record?.['header'];
        const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
            ? header
            : undefined;
        const config = headerRecord?.['config'];
        if (!hasProviderModel(config))
            throw new Error(`seed request/header at index ${index} lacks provider/model`);
        const configRecord = config;
        const reasoningEffort = configRecord['reasoningEffort'];
        if (reasoningEffort !== undefined
            && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
            throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`);
        }
        assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index);
    }
    const type = event['type'];
    if (type !== 'user/message' && type !== 'assistant/message'
        && type !== 'tool/result')
        return;
    assertMessageEventShape(event, `seed ${type} at index ${index}`);
}
const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens']);
/** Validate adapter-default markers imported from a durable request header. */
function assertAdapterDefaults(value, config, index) {
    if (value === undefined)
        return;
    if (typeof value !== 'object' || value === null || Array.isArray(value)) {
        throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
    }
    const defaults = value;
    if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
        || Object.values(defaults).some(marker => marker !== true)
        || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
        || defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
        throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`);
    }
}
/** Validate only the event-specific invariants needed to safely replay a message. */
function assertMessageEventShape(event, subject) {
    const type = event['type'];
    if (type !== 'user/message' && type !== 'assistant/message'
        && type !== 'tool/result')
        return;
    const data = event['data'];
    const record = typeof data === 'object' && data !== null
        ? data
        : undefined;
    const message = type === 'user/message' ? record : record?.['message'];
    if (typeof message !== 'object' || message === null
        || typeof message['id'] !== 'string'
        || message['id'] === '') {
        throw new Error(`${subject} lacks an identified message`);
    }
    const messageRecord = message;
    const expectedRole = type === 'assistant/message' ? 'assistant' : 'user';
    if (messageRecord['role'] !== expectedRole) {
        throw new Error(`${subject} message must have role "${expectedRole}"`);
    }
    const source = messageRecord['source'];
    if (typeof source !== 'object' || source === null
        || typeof source['kind'] !== 'string'
        || source['kind'] === '') {
        throw new Error(`${subject} message has invalid source`);
    }
    if (!Array.isArray(messageRecord['content'])) {
        throw new Error(`${subject} message has invalid content`);
    }
    const sourceRecord = source;
    if (type === 'assistant/message') {
        if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) {
            throw new Error(`${subject} message must have model source`);
        }
        return;
    }
    if (type !== 'tool/result')
        return;
    if (sourceRecord['kind'] !== 'tool'
        || typeof sourceRecord['callId'] !== 'string'
        || sourceRecord['callId'] === '') {
        throw new Error(`${subject} message must have tool source`);
    }
    const content = messageRecord['content'];
    const block = content[0];
    if (content.length !== 1 || typeof block !== 'object' || block === null
        || block['type'] !== 'tool-result'
        || !Array.isArray(block['content'])) {
        throw new Error(`${subject} message must contain one tool-result block`);
    }
    if (block['toolCallId'] !== sourceRecord['callId']) {
        throw new Error(`${subject} message has mismatched tool call ids`);
    }
}
/** Whether an unknown value carries the current provider/model pair. */
function hasProviderModel(value) {
    if (typeof value !== 'object' || value === null)
        return false;
    const pair = value;
    return typeof pair['provider'] === 'string' && pair['provider'].length > 0
        && typeof pair['model'] === 'string' && pair['model'].length > 0;
}
/** Reject request-header vocabulary removed with the legacy delta codec. */
function assertSupportedRequestHeader(type, data, location) {
    if (type === 'request/header-delta') {
        throw new Error(`${location} uses unsupported legacy request/header-delta format`);
    }
    if (type === 'request/header'
        && data !== null && typeof data === 'object' && !Array.isArray(data)
        && data['reason'] === 'fallback') {
        throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`);
    }
}
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
function collectSessionCallbacks(ctx, args) {
    return [...ctx.events.dispatch('emit', args)];
}
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
function invokeContainedSessionObservers(ctx, name, id, args, callbacks) {
    for (const callback of callbacks) {
        try {
            const returned = callback(...args);
            void Promise.resolve(returned).catch((error) => {
                ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`);
            });
        }
        catch (error) {
            ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`);
        }
    }
}
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
const attachments = new WeakMap();
/**
 * An event-sourced session: an append-only log of {@link SessionEvent}s.
 *
 * Plain class (not a Service) — create live instances via
 * `ctx.sessions.create()` and detached instances via {@link create}.
 * Seeding with an existing event log replays/forks a session.
 * @typert object
 */
export class Session {
    log = [];
    /** Single incremental owner of surface acceptance and projection state. */
    surfaceManager = new SurfaceManager(this.log);
    /** The ordered surface over this session's event log. */
    get surface() {
        return this.surfaceManager;
    }
    /**
     * Detached, deep-frozen creation metadata (format version, cwd, lineage,
     * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
     * `Session` is created without a store-owned header, a minimal header is
     * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
     * `session.header` is always present. Kept out of the event log — it is a
     * storage concern, not replayable conversation state.
     */
    header;
    /** The session identity, derived from its durable header's single copy. */
    get id() {
        return this.header.id;
    }
    /**
     * The first seq appended IN THIS PROCESS: the length of the constructor
     * seed (0 without one). Events with smaller seq values entered through
     * construction — replay, fork, or resume — and were never published on the
     * `session/event` firehose (constructor seeds do not emit), so consumers
     * that replay the log as a publication substitute (telemetry adoption)
     * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
     * boundary: a resumed session's constructor seed is its full stored log,
     * while its header keeps the original fork value — this field is the
     * in-process construction fact.
     *
     * Not persisted itself: a seeded session projects it into the log as the
     * `session/end-seed` event, which is what a consumer reading STORED history
     * reads. Locate the LAST such event, not necessarily one at this seq — a
     * seed already ending in one is not re-marked, so reopening an untouched
     * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
     * this field in-process: it is exact before the marker reaches storage.
     *
     * When this lifecycle appends the marker, it occupies this seq before the
     * store attaches and therefore does not publish either. Otherwise this seq
     * holds an ordinary published write.
     */
    firstLiveSeq;
    /**
     * Create a detached session by validating and snapshotting borrowed seed
     * events and storage metadata.
     * @param id - session identity.
     * @param seed - optional borrowed replay or fork events.
     * @param header - optional borrowed storage metadata.
     * @returns a detached session.
     */
    static create(id, seed, header) {
        return new Session(id, seed, header);
    }
    /**
     * Restore a detached session by taking ownership of fresh persistence values.
     * The storage format, event envelopes, sequence continuity, surface transitions,
     * and header fields are validated before the restored objects are frozen.
     * @param id - restored session identity.
     * @param seed - fresh detached events whose ownership is transferred.
     * @param header - fresh detached metadata whose ownership is transferred.
     * @returns a restored detached session.
     */
    static fromRestore(id, seed, header) {
        return new Session(id, seed, header, 'restore');
    }
    constructor(id, seed, header, mode = 'snapshot') {
        const restoredHeader = mode === 'restore'
            ? validateRestoredSessionHeader(id, header)
            : undefined;
        if (seed !== undefined) {
            // Validate the seed to the SAME invariants `append` enforces, so a
            // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
            // live log that no persistence backend could store: each event's `data`
            // must be JSON-serializable, and `seq` must be contiguous from 0 (the
            // `seq = log.length` contract the whole system relies on). Without this,
            // a bad seed would surface only later as a backend rejection or a silent
            // divergence between the live log and disk.
            for (const [index, source] of seed.entries()) {
                // The seed is a persistence/replay boundary: validate and detach the
                // complete event in one lossless-JSON pass.
                const snapshot = mode === 'restore' ? source : snapshotJsonValue(source);
                if (snapshot === undefined) {
                    throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`);
                }
                assertSessionEventEnvelope(snapshot, index);
                assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`);
                if (snapshot.seq !== index) {
                    throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`);
                }
                // A seed is accepted incrementally through the same transition as a
                // live append and a full-log fold. The candidate is planned before it
                // enters `log`, so a failure cannot partially mutate the surface.
                try {
                    this.surfaceManager.validateNext(snapshot);
                }
                catch (error) {
                    throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`);
                }
                this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot));
            }
        }
        this.firstLiveSeq = this.log.length;
        this.header = restoredHeader ?? snapshotSessionHeader(id, header);
        // Appended here so the marker is already in `events` when a backend
        // captures the creation seed: no load-time write. Re-marking is skipped
        // because a cold session is resumed on first touch, so repeatedly opening
        // one must not grow its log per open.
        if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
            this.append('session/end-seed', {});
        }
    }
    /** Cached immutable public snapshot of the private append-only log. */
    eventsSnapshot;
    /**
     * An immutable snapshot of the append-only event log. The snapshot is reused
     * until the next append; a previously returned array does not grow later.
     * Events and their nested data are deep-frozen at acceptance, so neither a
     * cast nor ordinary JavaScript can rewrite durable history.
     */
    get events() {
        this.eventsSnapshot ??= Object.freeze([...this.log]);
        return this.eventsSnapshot;
    }
    /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
    get seq() {
        return this.log.length;
    }
    /**
     * Append one typed event to the log and synchronously notify observers via
     * the store-owned, module-private publication hooks. The hot path never blocks
     * on I/O — persistence plugins buffer asynchronously. Once the event enters
     * the log, the append is committed: observer failures are logged and
     * contained per listener, so they do not change the return value or prevent
     * later listeners from observing the same accepted event.
     *
     * @param type - The event type (key of {@link SessionEventMap}).
     * @param data - The event payload; must be JSON-serializable.
     * @param opts - Surface metadata: `surfaceOp` controls how the event enters
     *   the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier
     *   events this one derives from. REQUIRED for
     *   {@link SurfaceEventType} events (every message-producing event must
     *   declare how it joins the surface, the sole source of derived model
     *   history) and
     *   rejected by the compiler for non-surface types like `turn/start` or
     *   `assistant/chunk`.
     * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
     *   `data` that entered the log, so reading `event.data` back sees the logged
     *   value, never the caller's still-mutable input.
     * @throws if `data` or surface metadata is not losslessly JSON-serializable
     *   (BigInt, function, symbol, undefined, negative zero, non-finite number,
     *   circular reference, sparse array, or an exotic object such as
     *   Map/Set/Date/class instance), or when the candidate violates the
     *   canonical surface contract (marker shape and eligibility, unique
     *   earlier source-event references, positional replacement validity, and complete
     *   shadowed-node coverage). One recursive pass reads, validates, and
     *   copies each nested value once, so a stateful getter cannot supply one value
     *   to validation and another to storage. The event log is the durable source
     *   of truth, so a bad event fails at the append site rather than later during
     *   a backend flush. A synchronous internal dispatch validation failure or an
     *   append reentered while this acceptance/publication boundary is open also
     *   rejects before the log changes.
     */
    append(type, data, ...opts) {
        const surfaceOpts = opts[0];
        const surfaceMetadata = {
            ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
            ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
        };
        const dataSnapshot = snapshotJsonValue(data);
        if (dataSnapshot === undefined) {
            throw new Error(`session event "${type}" carries non-JSON-serializable data`);
        }
        assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`);
        const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata);
        if (surfaceMetadataSnapshot === undefined) {
            throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`);
        }
        const entry = attachments.get(this);
        if (entry?.appending) {
            throw new Error('session append cannot reenter while another append is being published');
        }
        const event = deepFreeze({
            type,
            seq: this.log.length,
            time: Date.now(),
            data: dataSnapshot,
            ...surfaceMetadataSnapshot,
        });
        this.surfaceManager.validateNext(event);
        if (entry !== undefined)
            entry.appending = true;
        try {
            let callbacks;
            const callbackArgs = [this, event];
            if (entry !== undefined) {
                callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs]);
            }
            this.log.push(event);
            this.eventsSnapshot = undefined;
            if (callbacks !== undefined && entry !== undefined) {
                invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks);
            }
            return event;
        }
        finally {
            if (entry !== undefined) {
                entry.appending = false;
                if (entry.detachRequested && !entry.announcing)
                    entry.detach();
            }
        }
    }
    /** Cached fold of the request-header events — see {@link requestHeader}. */
    headerFold;
    /** Log position (events consumed) the header fold has reached. */
    headerFoldSeq = 0;
    /**
     * The {@link EpochHeader} in force after the log's last header event — the
     * header the NEXT request will be compared against — or undefined before
     * the first `request/header` snapshot. The live, incrementally-maintained
     * form of `foldRequestHeader(session.events)`: each header event is folded
     * once, when first seen, so a per-step read costs O(new events).
     * @returns the folded header, or undefined when no header event exists yet.
     */
    requestHeader() {
        if (this.headerFoldSeq < this.log.length) {
            // Frozen on update: the fold is session state exposed by reference — a
            // consumer mutating it in place (instead of building a replacement)
            // would desync every later comparison against the log, so mutation
            // throws instead.
            this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold));
            this.headerFoldSeq = this.log.length;
        }
        return this.headerFold;
    }
    /** Cached fold of `request/context` events. */
    contextFold;
    contextFoldSeq = 0;
    /**
     * Return the latest resolved route metadata, or `undefined` before the first
     * `request/context` event. Each event is folded once.
     * @returns the latest immutable route metadata.
     */
    requestContext() {
        if (this.contextFoldSeq < this.log.length) {
            for (const event of this.log.slice(this.contextFoldSeq)) {
                if (event.type === 'request/context')
                    this.contextFold = deepFreeze({ ...event.data });
            }
            this.contextFoldSeq = this.log.length;
        }
        return this.contextFold;
    }
    /** The derived-message cache: frozen projections, extended per unseen node. */
    derived = [];
    /** Surface position (nodes projected) the cache has reached. */
    derivedNodes = 0;
    /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
    derivedGeneration = 0;
    /**
     * Derive the LLM message history by walking the ordered sequences of
     * message-producing events maintained by `surfaceOp` markers. The
     * surface is the single source of derived history: every message-producing
     * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
     * turn boundary) is correctly absent, and a compaction `replace` deletes the
     * shadowed nodes from the derivation. The projection rules are
     * {@link deriveEventMessage}, folded per node.
     *
     * CACHED: each surface node is projected exactly once, when first seen — a
     * call costs O(new nodes), and a surface rewrite (a `replace`;
     * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
     * a fresh snapshot per call (later appends never grow an array a caller
     * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
     * Their content reuses the already frozen durable event data, so the cache
     * needs no second deep clone and consumers still cannot mutate the log.
     * @returns a fresh array of the shared, frozen derived history.
     */
    deriveMessages() {
        const surface = this.surface;
        const nodes = surface.nodes;
        const generation = surface.replaceGeneration;
        if (generation !== this.derivedGeneration) {
            this.derived = [];
            this.derivedNodes = 0;
            this.derivedGeneration = generation;
        }
        for (const seq of nodes.slice(this.derivedNodes)) {
            // Surface sequences are built from this.log — seq is always a valid
            // index by construction. The non-null assertion expresses that invariant.
            // oxlint-disable-next-line typescript/no-non-null-assertion
            const msg = this.deriveEventMessage(this.log[seq]);
            // A surface node is one of the five message-producing types, but an
            // empty-content assistant/message (a max-tokens step that hosts only
            // usage) derives to null and must not enter the transcript.
            if (msg)
                this.derived.push(msg);
        }
        this.derivedNodes = nodes.length;
        return [...this.derived];
    }
    /**
     * Instance face of the pure per-node `deriveEventMessage` export from
     * `surface.ts`.
     * @param event - the event to project.
     * @returns the derived message, or null when the event produces none.
     */
    deriveEventMessage(event) {
        return deriveEventMessage(event);
    }
}
/** Typed error for session fork rejections. */
export class SessionForkError extends Error {
    code;
    constructor(message, code) {
        super(message);
        this.code = code;
        this.name = 'SessionForkError';
    }
}
/**
 * In-memory session store (`ctx.sessions`).
 *
 * Persistence is intentionally not implemented here — persistence plugins
 * subscribe to `session/event` and flush on `session/flush` / dispose.
 */
export class SessionStore extends Service {
    store = new Map();
    counter = 0;
    constructor(ctx) {
        super(ctx, 'sessions');
        ctx.inject(['typert'], (typeCtx) => {
            typeCtx.typert.lookups.register('session', {
                parameter: 'session',
                wire: 'sessionId',
                hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
                wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
                resolve: sessionId => this.get(sessionId),
            });
        });
    }
    /**
     * Create a session owned by the calling fiber: disposing that fiber stops
     * event notification and removes the session from the store. `options.seed`
     * populates the session with a copy of those events (replay/fork);
     * `options.meta` attaches creation metadata (validated absolute `cwd`, seed
     * and parent lineage, and delegation depth) as the immutable
     * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
     *
     * For an agent whose session must be torn down IN ORDER with its loop (so the
     * loop's final events are published before the store attachment ends), do NOT use this
     * — fold the session lifecycle into the agent's own effect via
     * {@link prepare} + {@link enter} + {@link announce} (see
     * `dsh-agent-loop`'s creation transaction).
     *
     * @param id - the session id; omitted, the store mints `session-<n>`.
     * @param options - seed events and/or creation metadata for the header.
     * @returns the live session, already entered and announced.
     * @throws if a session with `id` already exists, metadata is not a plain
     *   lossless-JSON record with valid scalar fields, or `meta.cwd` is a
     *   non-absolute path (storage backends key directories off it).
     */
    create(id, options) {
        const session = this.prepare(id, options);
        // Single effect owned by the calling fiber. Yield the detach BEFORE
        // announcing so a throwing `session/created` listener rolls the attach back
        // (the generator effect disposes already-yielded disposers on a throw)
        // instead of leaking the store entry and its publication hooks.
        this.ctx.effect(function* () {
            yield this.enter(session);
            this.announce(session);
        }.bind(this), 'sessions.create()');
        return session;
    }
    /**
     * Build a session WITHOUT entering it into the store — validate the id/cwd and
     * construct the {@link Session} (with its immutable {@link SessionHeader}).
     * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
     * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
     * effect so a fiber unload tears the session + agent down as a single ORDERED
     * chain rather than as racing sibling effects — which would remove the publication hooks
     * before the driver's closing events commit, dropping them.
     *
     * @param id - the session id; omitted, the store mints `session-<n>`.
     * @param options - seed events and/or creation metadata for the header. With
     *   `seedSource: 'persistence'`, metadata and events must be fresh detached
     *   graphs whose ownership transfers to this call: they are validated and
     *   frozen in place through {@link Session.fromRestore}, so the caller must
     *   retain no mutable aliases.
     * @returns the constructed session, NOT yet in the store.
     * @throws if a session with `id` already exists, metadata is not a plain
     *   lossless-JSON record with valid scalar fields, or `meta.cwd` is a
     *   non-absolute path.
     */
    prepare(id, options) {
        let sessionId;
        if (id === undefined) {
            do
                sessionId = SessionId(`session-${++this.counter}`);
            while (this.store.has(sessionId));
        }
        else {
            sessionId = SessionId(id);
        }
        if (this.store.has(sessionId))
            throw new Error(`session "${sessionId}" already exists`);
        if (options?.seedSource === 'persistence') {
            return Session.fromRestore(sessionId, options.seed, options.meta);
        }
        const seed = options?.seed;
        const meta = options?.meta;
        const header = {
            version: SESSION_FORMAT_VERSION,
            id: sessionId,
            createdAt: meta?.createdAt ?? Date.now(),
            ...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
            ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
            ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
            ...meta?.origin === undefined ? {} : { origin: meta.origin },
            ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
            ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset },
        };
        return Session.create(sessionId, seed, header);
    }
    /**
     * Enter a {@link prepare}d session into the store: install the module-private
     * append publication hooks and add it to the store. Returns the DETACH
     * disposer (hooks + store removal). Does NOT emit `session/created` —
     * the caller yields this disposer inside its effect and THEN calls
     * {@link announce}, so a throwing `session/created` listener rolls the attach
     * back instead of leaking it.
     *
     * Re-checks the id for a duplicate: `prepare` and `enter` are public
     * cross-package primitives and a caller may interleave arbitrary work (or
     * another create) between them, so a stale prepared session must NOT overwrite
     * a live store entry of the same id — its detach disposer would later delete
     * the REAL session. The {@link create} convenience and the agent factory call
     * the two back-to-back so they never trip this, but the public API cannot
     * assume that.
     *
     * @param session - a {@link prepare}d session not yet in the store.
     * @returns the detach disposer (publication hooks + store removal). When called from
     *   a synchronous `session/created` listener, removal and disposal wait until
     *   that creation dispatch unwinds.
     * @throws if a session with this id is already in the store.
     */
    enter(session) {
        const id = session.id;
        const carrier = scopeTarget(session, scopeOf(this.ctx));
        // This is the authoritative collision boundary after arbitrary unpublished
        // preparation. Only one exact same-id transaction can publish.
        if (this.store.has(id))
            throw new Error(`session "${id}" already exists`);
        if (attachments.has(session))
            throw new Error(`session "${id}" is already attached to a store`);
        const entry = {
            id,
            session,
            carrier,
            emitCtx: this.ctx,
            announced: false,
            announcing: false,
            appending: false,
            detachRequested: false,
            detach: () => { this.detachEntered(entry); },
        };
        this.store.set(id, entry);
        attachments.set(session, entry);
        let entered = true;
        const detach = () => {
            if (!entered)
                return;
            entered = false;
            // A lifecycle listener may own the advanced detach capability. Keep the
            // entry and its publication hooks live until synchronous creation or append
            // publication unwinds, then publish the paired disposal edge.
            if (entry.announcing || entry.appending) {
                entry.detachRequested = true;
                return;
            }
            entry.detach();
        };
        return detach;
    }
    /** Remove one exact entered session and emit its paired disposal when announced. */
    detachEntered(entry) {
        entry.detachRequested = false;
        // A stale capability cannot remove observers or storage belonging to a
        // later same-id lifecycle.
        /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
        if (this.store.get(entry.id) !== entry)
            return;
        this.store.delete(entry.id);
        attachments.delete(entry.session);
        if (entry.announced)
            this.emitDisposed(entry);
    }
    /** Emit `session/created` exactly once for an {@link enter}ed session (with
     * the carrier {@link enter} captured). Separate from {@link enter} so the
     * caller can yield the detach disposer first (rollback safety — see
     * {@link enter}).
     * @param session - the entered session to announce to listeners.
     * @throws if the session is not live or its announcement already began,
     *   including a reentrant call from a creation listener. */
    announce(session) {
        const entry = this.liveEntryFor(session);
        if (entry.announced || entry.announcing) {
            throw new Error(`session "${entry.id}" was already announced`);
        }
        // Mark before emit: Cordis emit may deliver to earlier listeners and then
        // throw. Rollback must still pair that partial creation with disposal, and
        // a listener cannot recursively create a second lifecycle edge.
        entry.announced = true;
        const callbackArgs = [session];
        entry.announcing = true;
        try {
            const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session]);
            for (const callback of callbacks) {
                // Synchronous throws intentionally propagate and veto publication; the
                // yielded detach then emits the paired disposal edge. An async function
                // is nevertheless assignable to a void listener, so observe its returned
                // promise: rejection is too late to roll back and must be logged instead
                // of becoming unhandled.
                const returned = callback(...callbackArgs);
                void Promise.resolve(returned).catch((error) => {
                    this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`);
                });
            }
        }
        finally {
            entry.announcing = false;
            if (entry.detachRequested && !entry.appending)
                entry.detach();
        }
    }
    /** Emit the paired teardown notification with per-listener containment. */
    emitDisposed(entry) {
        const callbackArgs = [entry.session];
        try {
            const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session]);
            invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks);
        }
        catch (error) {
            this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`);
        }
    }
    /**
     * Dispatch the awaited `session/flush` durability checkpoint for `session`,
     * with the carrier captured at {@link enter}. THE flush entry point: the
     * store owns the carrier, so callers (the checkpoint policy's per-request
     * barrier, goal-round-driver's idle checkpoint, teardown drains, and consumers
     * that flush themselves before reading storage) must come through here
     * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
     * one spelling, and the scoped-dispatch invariant can pin it.
     * @param session - the session whose buffered events must reach durable storage.
     * @returns whether at least one durability listener participated, after every
     *   listener has settled successfully.
     * @throws the first registered listener failure after every listener settles.
     */
    async flush(session) {
        const { carrier } = this.liveEntryFor(session);
        const callbackArgs = [session];
        const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]);
        const results = await Promise.allSettled(callbacks.map((callback) => {
            try {
                return callback(...callbackArgs);
            }
            catch (error) {
                // Preserve the listener's exact rejection value; flush is a caller-owned
                // failure boundary, and Cordis listeners may throw arbitrary values.
                // oxlint-disable-next-line typescript/prefer-promise-reject-errors
                return Promise.reject(error);
            }
        }));
        const failure = results.find((result) => result.status === 'rejected');
        if (failure !== undefined)
            throw failure.reason;
        return callbacks.length > 0;
    }
    /** Return the exact live entry; detached/prepared objects reject. */
    liveEntryFor(session) {
        const entry = attachments.get(session);
        if (entry === undefined || this.store.get(entry.id) !== entry) {
            throw new Error(`session "${session.id}" is not live in this store`);
        }
        return entry;
    }
    /**
     * Look up a live session.
     * @param id - the session id to look up.
     * @returns the session, or undefined when no live session has that id.
     */
    get(id) {
        return this.store.get(id)?.session;
    }
    /**
     * All live sessions, in creation order.
     * @returns a fresh array; mutating it does not affect the store.
     */
    list() {
        return [...this.store.values()].map(entry => entry.session);
    }
    /**
     * Create a live child session from a stable prefix of a live source.
     * `boundary` is an inclusive source event seq; omitted means the source's
     * current last event. The selected slice may end with a between-turn event
     * but must not end inside an open turn.
     *
     * @param source - Live source session object or id.
     * @param boundary - Inclusive source event seq to fork through; omitted means
     *   the source's current last event, and omitted on an empty source forks an
     *   empty child.
     * @param childSessionId - Optional child session id; omitted delegates to
     *   `SessionStore`'s id policy.
     * @returns The created live child session.
     */
    fork(source, boundary, childSessionId) {
        if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
            throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS');
        }
        const liveSource = this._resolveForkSource(source);
        const seed = this._forkSeed(liveSource, boundary);
        return this.create(childSessionId, {
            seed,
            meta: {
                ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
                parentSession: liveSource.id,
                seedLength: seed.length,
            },
        });
    }
    _forkSeed(session, requestedBoundary) {
        const events = session.events;
        const lastEvent = events.at(-1);
        let boundary;
        if (requestedBoundary !== undefined) {
            boundary = requestedBoundary;
        }
        else {
            if (lastEvent === undefined)
                return [];
            boundary = lastEvent.seq;
        }
        if (!Number.isSafeInteger(boundary) || boundary < 0) {
            throw new SessionForkError(`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`, 'INVALID_BOUNDARY');
        }
        if (boundary >= events.length) {
            const lastSeq = events.at(-1)?.seq;
            throw new SessionForkError(`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY');
        }
        const boundaryEvent = events[boundary];
        if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
            throw new SessionForkError(`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, 'INVALID_BOUNDARY');
        }
        const lastTurnBoundary = events.slice(0, boundary + 1)
            .findLast(event => event.type === 'turn/start' || event.type === 'turn/end');
        if (lastTurnBoundary?.type === 'turn/start') {
            throw new SessionForkError(`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, 'OPEN_TURN');
        }
        return events.slice(0, boundary + 1);
    }
    _resolveForkSource(source) {
        if (typeof source === 'string') {
            const session = this.get(source);
            if (session === undefined)
                throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND');
            return session;
        }
        const live = this.get(source.id);
        if (live === undefined) {
            throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND');
        }
        if (live !== source)
            throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE');
        return source;
    }
}
export default SessionStore;
//# sourceMappingURL=index.js.map