/** * companion-chat-manager.ts * * Disk-backed manager for companion-app chat-mode sessions. * * Design: * - Each session owns a ConversationManager (isolated message history). * - Sessions survive daemon restart via CompanionChatPersistence (atomic JSON files). * - Inbound messages are rate-limited per session and per client via * CompanionChatRateLimiter (token-bucket, 30 msgs/min per client, * 10 msgs/min per session by default, configurable). * - When a user message is posted, the manager appends it to the conversation * and runs a lightweight LLM turn using the provider registry. * - Tool calls emitted by the LLM are executed via the injected ToolRegistry * (if provided); results are fed back into the stream and published as * turn.tool_result events. * - Streaming chunks are fanned out via ControlPlaneGateway.publishEvent * with a per-session clientId filter, so they only reach the subscriber * for that specific session, never the global TUI event feed. * - A GC sweep closes sessions that have been idle beyond the TTL. */ import type { CompanionPostMessageOptions } from './companion-chat-turn-control.js'; import type { CancelCompanionChatTurnInput, CancelCompanionChatTurnOutput, SteerCompanionChatMessageOutput, CompanionChatMessageAttachmentInput, CompanionChatMessage, CompanionChatSession, CompanionChatTurnEvent, CreateCompanionChatSessionInput, EditCompanionChatMessageInput, EditCompanionChatMessageOutput, RegenerateCompanionChatMessageInput, RegenerateCompanionChatMessageOutput, UpdateCompanionChatSessionInput } from './companion-chat-types.js'; import { type CompanionChatArtifactStore } from './companion-chat-attachments.js'; import type { CompanionSessionBrokerBridge } from './companion-chat-broker-bridge.js'; import type { CompanionChatRateLimiterOptions } from './companion-chat-rate-limiter.js'; import type { ToolRegistry } from '../tools/registry.js'; import type { PermissionManager } from '../permissions/manager.js'; import type { RuntimeEventBus } from '../runtime/events/index.js'; import type { HookEvent, HookResult } from '../hooks/types.js'; export type { CompanionLLMProvider, CompanionProviderChunk, CompanionProviderMessage, } from './companion-chat-turn-control.js'; import type { CompanionLLMProvider } from './companion-chat-turn-control.js'; type HookDispatcherLike = { fire(event: HookEvent): Promise; }; export type { CompanionChatArtifactStore } from './companion-chat-attachments.js'; export interface CompanionChatEventPublisher { publishEvent(event: string, payload: unknown, filter?: { clientId?: string; }): void; } export interface CompanionChatReplyResult { readonly messageId: string; readonly assistantMessageId?: string | undefined; readonly response?: string | undefined; readonly error?: string | undefined; } export interface CompanionChatManagerConfig { readonly provider: CompanionLLMProvider; readonly eventPublisher: CompanionChatEventPublisher; /** * ToolRegistry to use for executing tool calls emitted by the LLM. * When omitted, tool_call chunks are published as events but not executed; * the LLM receives no tool result and must degrade gracefully. */ readonly toolRegistry?: ToolRegistry | undefined; /** * Permission boundary used when executing model-originated tool calls. * Tool calls are denied when a registry is present without this manager. */ readonly permissionManager?: PermissionManager | null | undefined; /** Optional hook dispatcher for Pre/Post/Fail tool hooks. */ readonly hookDispatcher?: HookDispatcherLike | null | undefined; /** Optional runtime event bus for typed tool telemetry. */ readonly runtimeBus?: RuntimeEventBus | null | undefined; /** Optional artifact store used to resolve and inline chat attachments. */ readonly artifactStore?: CompanionChatArtifactStore | null | undefined; /** Directory for session JSON files. Default `/.goodvibes/companion-chat/sessions/` * (else OS home). Prefer passing this (or `homeDirectory`) so an isolated-home daemon stays in. */ readonly sessionsDir?: string | undefined; /** Injected home dir; when `sessionsDir` is omitted, the persistence root is * derived from THIS home (not the OS home) so an isolated-home daemon stays in. */ readonly homeDirectory?: string | undefined; /** * Optional bridge to the shared session broker. When supplied, companion * sessions register INTO the broker at write time (create/close), so * `/api/sessions` reflects companion activity immediately (same-process, no * restart). The boot-time importer fold remains the reconciliation path. */ readonly sessionBroker?: CompanionSessionBrokerBridge | null | undefined; /** Age (ms past closedAt) at which a CLOSED session's heavy in-memory handles * are evicted while its meta stays listable (bounds resident memory). Default 5 min. */ readonly closedSessionMemoryGraceMs?: number | undefined; /** Age (ms past closedAt) at which a CLOSED session's persisted file is PERMANENTLY * deleted. Closed sessions are HISTORY: default `undefined` = retain indefinitely. */ readonly closedSessionRetentionMs?: number | undefined; /** Pass `false` to disable disk persistence entirely (useful in tests). Default: true */ readonly persist?: boolean | undefined; /** Rate-limiting options. Defaults: 30 msgs/min per client, 10/min per session. */ readonly rateLimiter?: CompanionChatRateLimiterOptions | false | undefined; /** Override for tests */ readonly idleActiveMs?: number | undefined; /** Override for tests */ readonly idleEmptyMs?: number | undefined; /** Override for tests */ readonly gcIntervalMs?: number | undefined; } export declare class CompanionChatManager { private readonly sessions; private readonly provider; private readonly eventPublisher; private readonly toolRegistry; private readonly permissionManager; private readonly hookDispatcher; private readonly runtimeBus; private readonly artifactStore; private readonly persistence; private readonly rateLimiter; private readonly idleActiveMs; private readonly idleEmptyMs; private readonly closedMemoryGraceMs; private readonly closedRetentionMs; /** Live mirror of sessions into the shared broker store (S1 item D). */ private readonly _brokerSync; private gcTimer; /** Tracks whether the async init() has completed. */ private initCompleted; private readonly pendingReplies; /** True once dispose() ran, lets a cancelled turn report stoppedBy 'shutdown' honestly. */ private disposed; /** * Serializes persistence writes per session to prevent write-after-write * races where two concurrent saves could result in an older snapshot * overwriting a newer one. */ private readonly _pendingSaves; constructor(config: CompanionChatManagerConfig); /** * Load sessions persisted from a previous daemon run. * Should be called once after construction before accepting requests. * Safe to call multiple times (idempotent after first call). */ init(): Promise; createSession(input?: CreateCompanionChatSessionInput): CompanionChatSession; getSession(sessionId: string): CompanionChatSession | null; listSessions(input?: { readonly includeClosed?: boolean | undefined; readonly limit?: number | undefined; }): { readonly sessions: readonly CompanionChatSession[]; readonly totals: { readonly sessions: number; readonly active: number; readonly closed: number; }; }; getMessages(sessionId: string): CompanionChatMessage[]; private normalizeMessage; updateSession(sessionId: string, input: UpdateCompanionChatSessionInput): CompanionChatSession; /** * Register the SSE clientId for this session so events are routed only to * the correct subscriber. Replaces any previous registration (single subscriber * per session in v1, the last SSE connection wins). */ registerSubscriber(sessionId: string, clientId: string): void; /** * Close a session cleanly. Aborts any in-flight turn. Returns the session * snapshot, or null if not found. */ closeSession(sessionId: string): CompanionChatSession | null; /** * Cancel the in-flight turn (`companion.chat.turns.cancel`), a per-turn * stop that never touches the session controller. Refusal semantics and the * bounded finalization wait live in companion-chat-turn-control.ts. */ cancelTurn(sessionId: string, input?: CancelCompanionChatTurnInput): Promise; /** * Permanently delete a session (see CHANGELOG 1.0.0: `delete` is now a genuine removal, * distinct from `closeSession` above). Aborts any in-flight turn, removes * the on-disk record file, and drops the in-memory entry, reusing * {@link _hardRemove}, the SAME primitive the GC 'delete-persistent' sweep * action uses, so there is exactly one removal code path. * * Requires the session to already be closed: deleting a still-active * session throws `{ code: 'SESSION_ACTIVE', status: 409 }` (the caller must * close it first, mirroring the SESSION_CLOSED-throw convention elsewhere * in this file). An unknown OR already-deleted id throws * `{ code: 'SESSION_NOT_FOUND', status: 404 }`, delete is not a 200-noop. */ deleteSession(sessionId: string): Promise<{ readonly sessionId: string; readonly deleted: true; }>; /** * Post a user message and start an async LLM turn. Returns the messageId. * * Rate-limited per session and per client (throws GoodVibesSdkError{kind:'rate-limit'} * if limits are exceeded). * * Throws if the session is closed or not found. * * @param sessionId - The session to post to. * @param content - The message text. * @param clientId - The SSE/HTTP client identity for per-client rate limiting. * Pass '' to skip client-level rate limiting. */ postMessage(sessionId: string, content: string, clientId?: string, options?: CompanionPostMessageOptions): Promise; postMessageAndWaitForReply(sessionId: string, content: string, clientId?: string, options?: { readonly timeoutMs?: number | undefined; readonly attachments?: readonly CompanionChatMessageAttachmentInput[] | undefined; /** In-process tap for this turn's incremental events; independent of the gateway SSE fan-out. */ readonly onTurnEvent?: ((event: CompanionChatTurnEvent) => void) | undefined; }): Promise; private _postMessageInternal; dispose(): void; /** * Steer: send a message that runs IMMEDIATELY, cancelling the in-flight * turn if one is running (`companion.chat.messages.steer`). The message * jumps to the FRONT of the pending queue, then the active turn is * cancelled through the same finalization path as an explicit stop (honest * partial persisted, terminal `turn.cancelled` to every subscriber), and * the drain starts the steer's turn. With no turn running this is an * ordinary send. Queued messages keep their places behind the steer. */ steerMessage(sessionId: string, content: string, clientId?: string, options?: CompanionPostMessageOptions): Promise; /** * The single turn-start funnel: runs the next pending turn iff no turn is * active and the session is open. Every turn exit drains through here, so * queued sends and steers can never race into concurrent turns. */ private _startNextTurn; private _runTurn; /** * Regenerate an assistant response: supersede the target assistant message (an * explicit id, or the latest response) and everything after it, retained as * history, never deleted, then re-run a fresh turn from the preceding user * message. Refuses a closed (409 SESSION_CLOSED) or unknown (404 * SESSION_NOT_FOUND) session; honest code when there is nothing to regenerate. */ regenerateMessage(sessionId: string, input?: RegenerateCompanionChatMessageInput): RegenerateCompanionChatMessageOutput; /** * Edit a user message and branch from it: supersede the target user message and * everything after it (retained history), append a new user message carrying * `revisionOf` back to the original, and run a fresh turn. Same closed/unknown * refusals as {@link regenerateMessage}. */ editMessage(sessionId: string, input: EditCompanionChatMessageInput): EditCompanionChatMessageOutput; /** Look up an active (open) session or throw the closed/not-found machine codes. */ private _requireOpenSession; /** * Shared tail of regenerate/edit: rebuild the LLM-facing conversation from the * ACTIVE (non-superseded) chain (mirroring init()'s replay so the next turn * sees only the live branch), persist, and fire the new turn. */ private _commitBranchAndRun; /** * Periodic GC sweep. Deletion authority is SPLIT (charter: closed sessions are * HISTORY): close-idle closes an idle active session; evict-memory drops a * long-closed session's in-memory handles (meta + on-disk copy kept); * delete-persistent removes the file ONLY under an explicit finite retention * window (default retains indefinitely, see {@link planCompanionSweep}). */ _gcSweep(): void; private _updateMeta; /** * Await all in-flight best-effort broker-sync operations. The daemon's * companion HTTP routes call this before responding so `/api/sessions` * reflects the change synchronously; tests use it to make the mirror * deterministic. A no-op when no broker bridge is configured. */ flushBrokerSync(): Promise; /** * Hard-remove a session's persisted file and in-memory record. The ONE * removal code path, shared by {@link deleteSession} and the GC * 'delete-persistent' action. Never fork this. Order matters: * drop the map entry, drain any in-flight {@link _persist} save for this * id, THEN unlink, else a save mid-write from {@link closeSession} can * resurrect the file post-unlink. */ private _hardRemove; /** * Schedule a persistence save for the given session. * Saves are serialized per-session: each new save waits for the prior one to * complete before writing. The save always reads the CURRENT session state, * so rapid create→update→close sequences correctly persist the final state. */ private _persist; private _doSave; private resolvePendingReply; } //# sourceMappingURL=companion-chat-manager.d.ts.map