/** * SQLite-backed project/session/message storage for `spectral serve`. * * Two-tier model: * projects ─< sessions ─< messages * * Single-process, single-file. The storage layer goes through the `SqliteAdapter` * abstraction (see `sqlite-adapter.ts`) which prefers Node's built-in * `node:sqlite` on modern Node, `bun:sqlite` under Bun, and only falls back * to `better-sqlite3` on older Node runtimes. The synchronous adapter API is a * perfect fit for the per-WS-event write pattern: no connection pool, no * callback churn, transactions are trivial. * * Schema migrations: * We track schema with the `user_version` PRAGMA. When opening a DB whose * version does not match `SCHEMA_VERSION`, we DROP every known table and * recreate. This is safe pre-1.0 because the agent UI is local-only and * the data is conversation history we explicitly opted not to migrate * for the project-tier rollout. Anything newer (real users with real * data) needs a proper migration table. * * Foreign keys are enabled so DELETE FROM projects cascades to sessions * cascades to messages. The route layer must still tear down any in-flight * agent streams BEFORE deleting — see `SessionStreamManager.disposeProjectStreams`. * * The repo is intentionally a thin wrapper. We do NOT expose `Database` * itself — callers go through the typed methods so we can swap the backend * later (e.g. if we ever need to colocate with a remote service). */ import type { ImageAttachment, WireProject, WireSessionDetail, WireSessionSummary } from "./wire.js"; import type { Entry } from "../memory/branch.js"; import type { StudioBinding } from "../studio-binding.js"; import type { InterAgentMessage } from "./inter-agent-broker.js"; import type { QueueRow } from "./storage/rows.js"; import type { AppendMessageInput, CreateDevProcessDefinitionInput, CreateProjectInput, CreateSessionInput, DevProcessDefinition, SessionMemorySnapshot, StoredMessage, UpdateProjectInput } from "./storage/types.js"; export type { AppendMessageInput, CreateDevProcessDefinitionInput, CreateProjectInput, CreateSessionInput, DevProcessDefinition, SessionMemorySnapshot, StoredMessage, UpdateProjectInput, } from "./storage/types.js"; export { preflightSqlite } from "./storage/preflight.js"; export type { PreflightResult } from "./storage/preflight.js"; export declare class SessionStore { readonly path: string; private db; private closed; private readonly stmtListProjectsSQL; private stmtListProjects; private stmtGetProject; private stmtCountSessionsByProject; private stmtCreateProject; private stmtUpdateProject; private stmtSetProjectStudioBinding; private stmtDeleteProject; private stmtListSessionsByProject; private stmtListSessionIdsByProject; private stmtListProjectSessionIdsOlderThan; private stmtDeleteProjectSessionsOlderThan; private stmtListAllSessionIdsOlderThan; private stmtDeleteAllSessionsOlderThan; private stmtGetSession; private stmtCreateSession; private stmtDeleteSession; private stmtListMessages; private stmtListMessagesTail; private stmtListMessagesBefore; private stmtListMessagesBeforeByCreatedAt; private stmtCountMessagesBySession; private stmtCountUserMessagesBySession; private stmtAppendMessage; private stmtGetMessageSeq; private stmtGetMaxMessageSeq; private stmtUpdateMessageCredits; private stmtUpdateMessageMetrics; private stmtDeleteMessage; private stmtDeleteMessageForSession; private stmtDeleteMessagesBySession; private stmtUpdateLastSystemMessage; private stmtTouchSession; private stmtRenameSessionManual; private stmtRenameSessionAuto; private stmtSessionWithCount; private stmtGetSessionMemorySnapshot; private stmtUpsertSessionMemorySnapshot; private stmtDeleteSessionMemorySnapshot; private stmtGetSessionModel; private stmtSetSessionModel; private stmtGetSessionActiveAgent; private stmtSetSessionActiveAgent; private stmtGetSessionReasoningEffort; private stmtSetSessionReasoningEffort; private stmtGetSessionNativeResponseId; private stmtSetSessionNativeResponseId; private stmtSetForkCompactSource; private stmtGetForkCompactSource; private stmtInsertProjectObs; private stmtGetProjectObsById; private stmtGetProjectByCwd; private stmtListProjectObsByProject; private stmtDeleteProjectObs; private stmtCountProjectObs; private ftsEnabled; private stmtInsertProjectObsFts; private stmtDeleteProjectObsFts; private stmtDeleteProjectObsFtsByProject; private stmtSearchProjectObsFts; private stmtCountProjectObsFts; private stmtUpsertProjectRecallSource; private stmtGetProjectRecallSource; private stmtEnqueuePrompt; private stmtGetPromptQueue; private stmtDequeuePrompt; private stmtDeleteQueueItem; private stmtClearSessionQueue; private stmtShiftPositions; private stmtInsertInterAgentMessage; private stmtPollInterAgentMessages; private stmtMarkInterAgentMessageDelivered; private stmtDeleteExpiredInterAgentMessages; private stmtDeleteOldInterAgentMessages; private stmtListDevProcessDefinitions; private stmtGetDevProcessDefinition; private stmtGetDevProcessDefinitionByCommand; private stmtCreateDevProcessDefinition; constructor(path: string); /** Smoke check: returns the names of the tables in the DB. */ listTables(): string[]; /** Shared row→WireProject mapping for the synchronous and async list paths. */ private mapProjectRowsWithCount; listProjects(): WireProject[]; /** * Async twin of {@link listProjects}. Uses the adapter's `queryAll` * trampoline so the Node adapter yields to the event loop before the * potentially-large aggregate query runs. Wire shape is identical. */ listProjectsAsync(): Promise; createProject(input: CreateProjectInput): WireProject; /** Returns null if not found. */ getProject(id: string): WireProject | null; /** Async twin of {@link getProject}. */ getProjectAsync(id: string): Promise; setProjectStudioBinding(id: string, binding: StudioBinding | null): WireProject | null; /** * Update a project's name and/or path. At least one must be provided. * Returns the updated project, or null if not found. Empty `name` falls * back to "Untitled project" for parity with createProject. */ updateProject(id: string, input: UpdateProjectInput): WireProject | null; /** * Delete a project. Cascades to sessions and messages via FK. * Caller MUST tear down any active SessionStream subscribers / spectral * processes for sessions in this project BEFORE invoking this. * Returns the list of session ids that belonged to the project (so * the caller can include them in the stream-teardown call). */ deleteProject(id: string): { deleted: boolean; sessionIds: string[]; }; /** Sessions belonging to a single project, newest-first. */ listSessionsByProject(projectId: string): WireSessionSummary[]; /** Async twin of {@link listSessionsByProject}. */ listSessionsByProjectAsync(projectId: string): Promise; /** * Batched variant of {@link listSessionsByProject} for many projects at once. * * Issues a SINGLE SQL query (independent of project/session count) with a * LEFT JOIN + GROUP BY aggregation for message counts, instead of the * O(projects) per-project queries (each with a per-session correlated * COUNT(*) subquery) that {@link listSessionsByProject} would require when * called in a loop. * * Per-project ordering is preserved: rows are globally ordered by * `updated_at DESC`, then grouped by `project_id` in JS — each project's * array is therefore a subsequence of the globally-sorted result, retaining * `updated_at DESC` order (matching {@link listSessionsByProject}). * * Every requested project id is present in the returned Map; projects with * no sessions map to an empty array. */ listSessionsByProjectIds(projectIds: string[]): Map; /** * Async twin of {@link listSessionsByProjectIds}. Runs the same single * LEFT JOIN + GROUP BY query through the adapter trampoline so large * aggregate reads yield to the event loop. Returns an identical Map with * every requested project id present (empty arrays for projects with no * sessions) and identical per-project `updated_at DESC` ordering. */ listSessionsByProjectIdsAsync(projectIds: string[]): Promise>; createSession(input: CreateSessionInput): WireSessionSummary; /** Returns null if not found. */ getSession(id: string): WireSessionDetail | null; /** Convenience for routes/managers that just need the projectId. */ getSessionProjectId(id: string): string | null; /** * Lightweight session metadata without loading messages. * Use when only session fields (title, timestamps) are needed — avoids * the O(n) message query that `getSession()` performs via `getMessages()`. */ getSessionMeta(id: string): { id: string; projectId: string; title: string; createdAt: number; updatedAt: number; } | null; /** Async twin of {@link getSessionMeta}. */ getSessionMetaAsync(id: string): Promise<{ id: string; projectId: string; title: string; createdAt: number; updatedAt: number; } | null>; /** Async twin of {@link getMessages} with a caller-supplied bound. */ getMessagesTailAsync(sessionId: string, limit: number): Promise; /** * Non-blocking full-history read used by the WS `attach()` first-attach * path. The Node adapter pages the result set with an event-loop yield * between pages (see `SqliteAdapter.queryAll`), while the Bun adapter * falls back to its synchronous `.all()` path. Row order and wire shape * are identical to the synchronous `getMessages()`. */ getMessagesAsync(sessionId: string): Promise; getMessages(sessionId: string): StoredMessage[]; getMessagesTail(sessionId: string, limit: number): StoredMessage[]; getMessagesBefore(sessionId: string, opts: { beforeCreatedAt: number | null; beforeId: string | null; beforeSeq?: number | null; limit: number; }): StoredMessage[]; /** Async twin of {@link getMessagesBefore}. */ getMessagesBeforeAsync(sessionId: string, opts: { beforeCreatedAt: number | null; beforeId: string | null; beforeSeq?: number | null; limit: number; }): Promise; countMessagesBySession(sessionId: string): number; countUserMessagesBySession(sessionId: string): number; /** Async twin of {@link countMessagesBySession}. */ countMessagesBySessionAsync(sessionId: string): Promise; private mapMessageRows; /** * Append a message and bump the session's updated_at in one transaction. * Throws if the session doesn't exist (FK violation). */ /** Delete a message by id. No-op if the message doesn't exist. */ deleteMessage(id: string): void; /** Delete all persisted messages for a session. */ deleteMessagesBySession(sessionId: string): void; /** Delete specific messages for a session in one transaction. Preserves all other rows and ids. */ deleteMessagesByIdForSession(sessionId: string, messageIds: readonly string[]): number; appendMessage(sessionId: string, msg: AppendMessageInput): StoredMessage; /** * Update the credits_used column for a persisted message. Called when a * `token_usage` event arrives (after the initial message insert) so that * credit consumption survives independently of the events_jsonl blob. */ updateMessageCredits(messageId: string, creditsUsed: number): void; /** * Update the per-round timing metric columns (output_tokens, duration_ms, * tokens_per_second, ttft_ms) for a persisted message. Called when a * `token_usage` event arrives (after the initial message insert) so the * metrics survive independently of the events_jsonl blob — same pattern * as {@link updateMessageCredits}. Null fields store NULL (metric not * computable for the round). */ updateMessageMetrics(messageId: string, metrics: { outputTokens: number | null; durationMs: number | null; tokensPerSecond: number | null; ttftMs: number | null; }): void; /** * Update the content of the most-recent system message for a session. * Used by `compaction_end` to update the initial "compacting…" message in * place rather than appending a second system row, so history replay shows * a single compaction entry instead of two. * * Returns true if a row was updated, false if no system message exists yet. */ updateLastSystemMessage(sessionId: string, content: string): boolean; /** * Rename a session. A manual rename also sets `title_manually_renamed` so * later auto-titling can never overwrite the user's choice. An auto rename * is a no-op (and leaves `updated_at` untouched) when that flag is set. * Returns the current summary, or null if the session doesn't exist. * Title is trimmed; an empty title falls back to "New conversation" for * parity with createSession(). */ renameSession(id: string, title: string, opts?: { manual?: boolean; }): WireSessionSummary | null; private summaryFromRow; /** Returns true if a row was deleted. Cascades to messages. */ deleteSession(id: string): boolean; /** * Delete every session in `projectId` whose `updated_at` is older than * `olderThanDays` full days. Returns the affected session ids so the * caller can tear down in-flight streams and publish deletion events. * Messages and memory snapshots are removed by foreign-key cascades. */ cleanupProjectSessionsOlderThan(projectId: string, olderThanDays: number): { deleted: number; ids: string[]; }; /** * Delete every session across ALL projects whose `updated_at` is older than * `olderThanDays` full days. Same contract as * {@link cleanupProjectSessionsOlderThan} (`deleted` + `ids`), plus a * `projectIds` map captured before deletion so the caller can attribute * `session_deleted` meta events to each session's own project. Messages and * memory snapshots are removed by foreign-key cascades. */ cleanupAllSessionsOlderThan(olderThanDays: number): { deleted: number; ids: string[]; projectIds: Record; }; getSessionMemorySnapshot(sessionId: string): SessionMemorySnapshot | null; upsertSessionMemorySnapshot(sessionId: string, snapshot: Omit): void; deleteSessionMemorySnapshot(sessionId: string): void; /** * Get the persisted modelId for a session, or null if none was ever set or * the session does not exist. The CLI uses this for cross-restart recovery: * when an envelope arrives WITHOUT a `modelId` but SQLite has a value * persisted from an earlier turn, we apply the persisted value before * forwarding to spectral. When neither envelope nor SQLite have a value, we * leave model selection to spectral's own settings file (pre-Phase-3 behaviour). */ getSessionModel(sessionId: string): string | null; /** * Persist the modelId for a session. Pass `null` to clear. Does NOT bump * `updated_at` — model selection is metadata, not user activity, so it * shouldn't promote a session to the top of the sidebar. Silently no-ops * when the session does not exist (the caller has already validated the * sessionId via attach/getSession in the normal flow). */ setSessionModel(sessionId: string, modelId: string | null): void; getSessionActiveAgent(sessionId: string): string | null; /** Async twin of {@link getSessionActiveAgent}. */ getSessionActiveAgentAsync(sessionId: string): Promise; setSessionActiveAgent(sessionId: string, agentName: string | null): void; /** * Get the persisted reasoningEffort for a session, or null if none * was ever set. Returns null for unknown sessions (consistent with * getSessionModel's contract). */ getSessionReasoningEffort(sessionId: string): string | null; /** * Persist the reasoningEffort for a session. Pass `null` to clear. * Same semantics as setSessionModel — best-effort metadata, does * not bump `updated_at`. */ setSessionReasoningEffort(sessionId: string, effort: string | null): void; /** Get the last genuine upstream Responses API response id for a session. */ getSessionNativeResponseId(sessionId: string): string | null; /** Persist a genuine upstream Responses API response id without touching activity timestamps. */ setSessionNativeResponseId(sessionId: string, responseId: string | null): void; /** * Fork a session: create a new session in the same project, copy all * messages from the source, and set the `fork_compact_source_id` flag * so SessionStreamManager compacts the context after the first assistant * turn completes. */ forkSession(sourceId: string, opts?: { title?: string; newSessionId?: string; }): WireSessionSummary; /** * Read the fork-compact source id for a session, or null if the session * was not forked or has already been compacted. */ getForkCompactSource(sessionId: string): string | null; /** Async twin of {@link getForkCompactSource}. */ getForkCompactSourceAsync(sessionId: string): Promise; /** * Clear the fork-compact flag after compaction completes (or fails). * Idempotent-safe. */ clearForkCompactSource(sessionId: string): void; /** * Insert multiple project observations in a single transaction. * Uses INSERT OR REPLACE so re-running after the same compaction is idempotent. * * Write-time near-duplicate detection: an observation whose content is a * near-identical rewording (trigram Jaccard >= NEAR_DUPLICATE_SIMILARITY) * of an existing row replaces it — new wording wins — so refinements of * the same fact don't accumulate as duplicates. The FTS5 trigram index is * synced in the same transaction. */ insertProjectObservations(projectId: string, sessionId: string, observations: Array<{ id: string; content: string; relevance: string; }>, createdAt: number): void; /** * Find an existing project observation whose content is a near-identical * rewording of `content` (trigram Jaccard >= NEAR_DUPLICATE_SIMILARITY). */ private findNearDuplicateObservation; /** * Search project observations using n-gram matching. * * Query tokens are matched via the FTS5 trigram index: each token becomes * a quoted phrase joined with AND, so a multi-word query matches * observations containing ALL tokens anywhere (case-insensitive substring * semantics). Tokens shorter than 3 characters cannot be matched by the * trigram tokenizer; they are applied as a complementary LIKE filter over * the FTS candidates (or as a plain LIKE conjunction when every token is * short, or when the index is unavailable). When no candidate survives, * a typo-tolerant fallback keeps observations whose token-level trigram * similarity to the query is >= TYPO_FALLBACK_MIN_MEAN_SIMILARITY. * * Results are ranked by a blend of text-match quality, relevance tag and * recency. The return shape is unchanged from the previous substring * search: { content, relevance, createdAt, sessionId }. */ searchProjectObservations(projectId: string, query: string, limit?: number): Array<{ content: string; relevance: string; createdAt: number; sessionId: string; }>; /** * Collect candidate observations for a tokenized query, assigning each a * normalized text-match score in (0, 1]. */ private findObservationCandidates; /** * FTS5 trigram MATCH over `longTokens`, then a complementary LIKE filter * for tokens too short for the trigram tokenizer. Returns an empty array * when the index yields nothing usable (the caller then falls back). */ private searchObservationsFts; /** * Rebuild the FTS index from project_observations when it is out of sync * (empty or partial relative to the base table). Handles databases created * before the FTS index existed and any drift (e.g. projects deleted by an * older build without FTS cleanup). No-op when the counts already match. */ private backfillProjectObservationsFts; /** * Whether the FTS5 trigram index is active for project observations. * Exposed for diagnostics and tests; false means search is running in * LIKE-only degradation mode. */ isProjectObsFtsEnabled(): boolean; /** * Get a single project observation by id. Returns null if not found. */ getProjectObservationById(projectId: string, observationId: string): { id: string; content: string; relevance: string; createdAt: number; sessionId: string; } | null; /** * Retrieve the most recent N project observations for a given project. * Returns observations sorted by created_at DESC, newest first. */ getRecentProjectObservations(projectId: string, limit?: number): Array<{ id: string; content: string; relevance: string; createdAt: number; sessionId: string; }>; /** * Look up a project by its absolute filesystem path. * Returns null when no project has been registered for the given path. */ getProjectByCwd(cwd: string): string | null; /** * Persist the source-context archive for a single observational-memory id. * Keyed by project (cwd), not session/branch, so a later session in the * same working directory can recall source evidence from an earlier branch. */ upsertProjectRecallSource(projectId: string, memoryId: string, entries: Entry[]): void; /** * Read the source-context archive for a single observational-memory id. * Returns null when no archive exists for that project/id. */ getProjectRecallSource(projectId: string, memoryId: string): Entry[] | null; insertInterAgentMessage(msg: InterAgentMessage): void; pollInterAgentMessages(input: { projectId: string; channel?: string; recipientSessionId?: string; since?: number; limit: number; markDelivered: boolean; }): InterAgentMessage[]; deleteExpiredInterAgentMessages(now: number): void; deleteInterAgentMessagesOlderThan(before: number): void; /** * Enqueue a prompt for a session. Returns the created queue item. * Position is auto-assigned as (max existing position + 1). */ enqueuePrompt(sessionId: string, content: string, images?: ImageAttachment[]): QueueRow; /** * Dequeue (remove and return) the first prompt for a session. * Returns null if the queue is empty. * Renumbers remaining items so positions stay contiguous from 0. */ dequeuePrompt(sessionId: string): QueueRow | null; /** * Get the full prompt queue for a session, ordered by position. */ getPromptQueue(sessionId: string): QueueRow[]; /** * Remove a specific prompt from the queue by id. * Renumbers remaining items so positions stay contiguous. */ removePrompt(sessionId: string, itemId: string): void; /** * Clear the entire prompt queue for a session. */ clearPromptQueue(sessionId: string): void; private mapDevProcessDefinitionRow; listDevProcessDefinitions(): DevProcessDefinition[]; getDevProcessDefinition(id: string): DevProcessDefinition | null; /** Look up a definition by its command + working directory. */ getDevProcessDefinitionByCommand(cwd: string, command: string): DevProcessDefinition | null; createDevProcessDefinition(input: CreateDevProcessDefinitionInput): DevProcessDefinition; close(): void; } //# sourceMappingURL=storage.d.ts.map