import type { ConversationRef } from '#src/history/conversationRef.js'; import { type IndexRebuildSummary } from '#src/history/historyFiles.js'; export { HISTORY_DB_FILENAME, resolveHistoryDbPath } from '#src/history/historyLayout.js'; export type { IndexRebuildSummary } from '#src/history/historyFiles.js'; /** A single persisted session record (all analytics fields optional; populated when available). */ export interface SessionRecord { /** * GS2-19 — the parent conversation this turn belongs to. When omitted, {@link HistoryStore.record} * opens a fresh single-turn conversation for the row (so a bare single-shot run = a 1-turn * conversation). Interactive sessions {@link HistoryStore.openConversation | open} one conversation * up-front and pass its id here on every turn, grouping the whole chat under one conversation. */ conversationId?: number; /** * GS2-106 — the LangGraph thread a single-shot run checkpointed under. Used ONLY when this record * opens its own fresh conversation (no {@link conversationId}), and written onto that new row, so * the run's durable state is linked to the conversation it belongs to in the same transaction * that creates the row. Ignored when `conversationId` is given: an existing conversation already * carries its own link, and a turn must never move it. */ threadId?: string; /** ISO-8601 timestamp; defaults to now when omitted. */ ts?: string; /** * The PROJECT ROOT the turn was recorded under — every write site fills this from * `getProjectDir()`, which is the discovered config root when discovery matched above the * session, the `--config` override's own directory when one was given, and the working directory * only when neither applied. * * **It is not where the user was**, and the two coincide only in the common case: a session * opened in a subdirectory of a configured project records the PARENT. Nothing may render this * value under a phrase that places the session in it, and the row carries no second path to * recover the working directory from. */ project?: string; /** Originating command (ask/chat/code/exec/…). */ command?: string; /** * GS2-106 — the fan-out surface that started this run (`batch`, `eval`, `gth-batch`, `workflow`), * omitted for a direct run. Like {@link threadId}, written only when this record opens its own * fresh conversation. */ origin?: string; /** Human-readable model/provider label. */ model?: string; /** The user prompt / source that started the run (full-text indexed). */ prompt?: string; /** The final assistant response text (full-text indexed). */ response?: string; /** Prompt/input token count, when known. */ tokensInput?: number; /** Completion/output token count, when known. */ tokensOutput?: number; /** Estimated cost in USD, when known. */ costUsd?: number; /** Names of tools invoked during the run, when known. */ tools?: string[]; /** Wall-clock duration of the run in milliseconds, when known. */ durationMs?: number; } /** A search hit: the stored record plus its id and a highlighted snippet. */ export interface SessionSearchResult extends SessionRecord { id: number; ts: string; /** FTS5 snippet around the match (may be empty). */ snippet: string; } /** * GS2-19 — metadata for a conversation (a group of turns). Passed to * {@link HistoryStore.openConversation} at interactive-session start. */ export interface ConversationMeta { /** ISO-8601 start timestamp; defaults to now when omitted. */ ts?: string; /** * The PROJECT ROOT the conversation was opened under — `getProjectDir()` at the moment the row * was written, on the same terms as {@link SessionRecord.project}: the discovered config root, * the `--config` override's directory, or the working directory only as a fallback. * * **Not where the user was.** This is also the value a resume's workspace check compares against * the resuming session's own `getProjectDir()`, so what goes in here decides which conversations * a later session is allowed to reopen. */ project?: string; /** Originating command (chat/code/ask/exec/…). */ command?: string; /** Human-readable model/provider label. */ model?: string; /** * GS2-20 — the LangGraph thread whose durable checkpoint holds this conversation's graph state. * It is the link a resume travels: `gth history list` prints conversation ids, and this is what * turns one of those back into the thread to re-enter. Omitted by callers that do not checkpoint, * leaving the conversation listable but not resumable. A single-shot run does not come through * here: {@link HistoryStore.record} opens its row and links its thread (see * {@link SessionRecord.threadId}). */ threadId?: string; } /** * GS2-106 — what {@link HistoryStore.recordTurn} wrote: the turn's own row, and the conversation it * was recorded under with that conversation's run id. */ export interface RecordedTurn { /** The `sessions` row id — what {@link HistoryStore.record} has always returned. */ sessionId: number; /** The conversation the turn was recorded under: supplied by the caller, or opened for it. */ conversationId: number; /** * That conversation's run id, or `null` when it has none — a conversation that existed before * run ids did, and was passed in by the caller. */ runId: string | null; } /** * GS2-19 — a conversation-grained listing row: the conversation's metadata plus the aggregate of * its turns (how many, the timespan they cover, and a preview of the last one). This is the * top-level unit `gth history list` shows, in place of isolated per-turn rows. */ export interface ConversationSummary { id: number; /** When the conversation was opened. */ startedTs: string; /** * The conversation's PROJECT ROOT as recorded when it was opened — see * {@link ConversationMeta.project} for what that value is and, just as importantly, what it is * not. A surface rendering it must name it as the project root; it is not where the session was. */ project?: string; command?: string; model?: string; /** Number of turns recorded under this conversation (0 for a conversation with no turns). */ turnCount: number; /** Timestamp of the first / last turn, when any turns exist. */ firstTs?: string; lastTs?: string; /** Prompt / response of the most recent turn (a one-line preview source). */ lastPrompt?: string; lastResponse?: string; /** * GS2-20 — the LangGraph thread this conversation's checkpoint lives under, when it has one. * Absent for a conversation recorded without a checkpointer — any pre-GS2-20 row, a single-shot * run recorded before GS2-106 or whose store would not open — and for one whose link was cut * after a checkpoint write failed. */ threadId?: string; /** * GS2-106 — the conversation's stable run id, minted when the row was created. Absent for a row * written before run ids existed; those stay addressable by {@link id}. */ runId?: string; /** * GS2-106 — the fan-out surface that started the run (`batch`, `eval`, `gth-batch`, `workflow`); * absent for a direct run and for any row written before the column existed. */ origin?: string; } /** Aggregate analytics over the whole store (local only). */ export interface HistoryInsights { sessionCount: number; totalTokensInput: number; totalTokensOutput: number; totalTokens: number; totalCostUsd: number; /** Tool-name → invocation count, most-used first. */ topTools: { tool: string; count: number; }[]; /** Command → run count, most-used first. */ perCommand: { command: string; count: number; }[]; firstTs?: string; lastTs?: string; } /** Options for opening a store. */ export interface OpenHistoryStoreOptions { /** * When false (the default for read-only callers), a missing DB file yields `null` instead of * creating an empty database. Read commands pass `create: false` so `gth insights` never * materialises a DB as a side effect; the recorder passes `create: true`. */ create?: boolean; } /** * Turn arbitrary user text into a safe FTS5 MATCH expression: each whitespace-separated token is * wrapped as a quoted string and AND-ed together. This avoids FTS5 syntax errors from stray * operators (`AND`, `*`, `:`, parentheses, unbalanced quotes) in a user's query while still * matching all of their words. */ export declare function toFtsMatchQuery(query: string): string; /** * A thin, fail-soft wrapper over a `node:sqlite` connection holding the session history. * * Obtain one via {@link openHistoryStore} (which returns `null` if the DB can't be opened). Every * method is defensive: on any SQLite error it returns a safe empty/zero result rather than * throwing, so callers on a run's hot path never have to guard. */ export declare class HistoryStore { private db; /** * The store directory, whose thread files hold each conversation's durable record; `null` for an * in-memory store (`:memory:`), which is an index alone. */ private storePath; private constructor(); /** * Open the store at `storePath`: split a single-file store from an earlier release, open and * migrate its index, and rebuild the index from the thread files when it is missing. Returns * `null` on any failure — nothing there when `create` is false, a split another process is * running, an unopenable/locked/corrupt index — so the caller can simply skip history without a * try/catch. `:memory:` opens an index with no thread files, for specs. */ static open(storePath: string, options?: OpenHistoryStoreOptions): HistoryStore | null; /** * Run `write` with the record file of a conversation ATTACHed as `home`, so the writes to it * and to the index commit together. `write` is told whether `home` is there: an in-memory store, * or a conversation row with no home, writes the index alone. * * The file is created and migrated first, by its own short-lived connection, because an ATTACHed * file is not migrated by the connection that attaches it. */ private withHome; /** The record file id of one conversation, or `null` when it has none or does not exist. */ private homeOf; /** * Whether the file of `threadId` exists — the read-time half of "an index row naming a missing * file is dropped": a conversation whose thread file is gone is listed and cannot be resumed. * Always true for an in-memory store, which has no files to lose. */ private threadFileExists; /** * Create the file of the thread a new conversation names, before the index row that names it is * written — rule 1's "thread file first". Without it a conversation opened before its thread's * first checkpoint would read as having lost its thread, and a session that ends before its first * turn would leave a conversation that can never be resumed. Throws like `ensureThreadFile`; the * callers are fail-soft. */ private ensureNamedThread; /** A stored `thread_id` as the link a reader may follow, or `undefined` when there is none. */ private liveThread; /** * Write one conversation row into the index and its record file, in the transaction the caller * holds. Returns the new id. */ private insertConversation; /** * GS2-19 — open a new conversation and return its id (or `null` on any error). Interactive * sessions call this once at start, then pass the id on every {@link record} so all the session's * turns group under it. A single-shot run does not need this: {@link record} opens a 1-turn * conversation itself when no `conversationId` is supplied. */ openConversation(meta?: ConversationMeta): number | null; /** Run `work` in one transaction, rolled back on any error, which is rethrown. */ private inTransaction; /** * GS2-106 — the conversation a parsed id names, as its integer row id, or `null` when it names * none. Every surface that takes a conversation id resolves it here. * * **Exact match, never a fallback and never a neighbour**, on the same terms as * {@link getConversationThreadId}: an integer must be a row that exists, and a run id must be * exactly the one minted for a row in THIS database. A run id from a database that was since * deleted and recreated therefore names nothing, which is the whole reason run ids exist. */ resolveConversationRef(ref: ConversationRef): number | null; /** * GS2-20 — the LangGraph thread one conversation's state is checkpointed under, or `null` when * that conversation does not exist or was never checkpointed. * * **Exact match on the id, and never a fallback.** A resume that answered a stale or mistyped id * with the newest conversation instead would silently drop the user into somebody else's * transcript, which is the one failure this lookup exists to make impossible; `null` is the * answer, and the caller says so. */ getConversationThreadId(conversationId: number): string | null; /** * GS2-20 — cut a conversation's link to its LangGraph thread, so it can never be resumed again. * * Called when a checkpoint write has failed mid-session. What is on disk at that point is a * TRUNCATED but well-formed chain: the super-steps before the failure committed, the ones after * did not, and nothing about the rows says so. A resume would replay that half-conversation as * though it were the whole thing — a resume that looks right and is not, which is the one outcome * this ticket exists to prevent. Clearing the link is what makes the refusal durable; a flag in * the failing process dies with it and the NEXT process is the one that would be misled. * * `thread_id = NULL` rather than a sentinel: {@link getConversationThreadId} already answers * `null` for it, so every reader refuses for the same reason it refuses an unknown id, with no * new state to special-case. The cost is that "the writes failed" then looks identical to "this * conversation never had a thread" — a listing concern, not this one's, and both are correctly * un-resumable either way. * * Fail-soft like everything else here: a failure to record the failure must not raise a second * one into a session that is already degraded. */ clearConversationThread(conversationId: number): void; /** * GS2-20 — ONE conversation as a listing row, or `null` when there is no such id. * * Unlike {@link listConversations} this keeps a conversation with zero turns: a resume needs to * see the row for a session that opened and exited without an exchange, so it can refuse that id * for the right reason (no state was ever recorded) instead of calling it unknown. Fail-soft. */ getConversation(conversationId: number): ConversationSummary | null; /** * GS2-20 — the stored approval-grants document of one conversation, verbatim, or `null` when the * conversation has none (or does not exist). Opaque here: the approvals layer owns the format. */ getConversationGrants(conversationId: number): string | null; /** * GS2-20 — replace one conversation's stored approval-grants document (`null` clears it). Returns * whether the row was updated; `false` for an unknown id or any SQLite error. Fail-soft. */ setConversationGrants(conversationId: number, grantsJson: string | null): boolean; /** * Persist one session and its full-text index entry. Returns the new `sessions` row id, or `null` * on any error (the run continues regardless). The two inserts run in a transaction so a failure * can't leave the FTS index out of sync with the base table. * * Returns the TURN's row id, which is not a conversation id; a caller that needs the conversation * the turn landed in uses {@link recordTurn}. */ record(rec: SessionRecord): number | null; /** * GS2-106 — {@link record}, returning what was written: the turn's row id, and the conversation * the turn was recorded under together with that conversation's run id. A single-shot run needs * the conversation, which only exists from inside this transaction when it is opened here. * `null` on any error, like {@link record}. */ recordTurn(rec: SessionRecord): RecordedTurn | null; /** * Full-text search over prompt/response/command/project, best match first (FTS5 `rank`). User * text is sanitised via {@link toFtsMatchQuery}; an empty or all-punctuation query returns `[]`. * Any SQLite error yields `[]` (fail-soft). */ search(query: string, limit?: number): SessionSearchResult[]; /** Most recent sessions, newest first. Fail-soft ([] on error). */ listRecent(limit?: number): SessionSearchResult[]; /** * GS2-19 — conversations newest-first, each with its turn count, timespan, and a preview of the * last turn. This is the top-level unit for `gth history list` (the turn-grained {@link listRecent} * remains for callers that want raw turns). Fail-soft ([] on error). * * Empty conversations are **excluded** (`HAVING COUNT(s.id) > 0`): a session opens its conversation * at start (before any turn), so one that exits with zero turns would otherwise show as a * contentless `turnCount: 0` row. The LEFT JOIN still keeps every conversation that has ≥1 turn — * including back-filled 1-turn conversations, whose single turn satisfies the HAVING. */ listConversations(limit?: number): ConversationSummary[]; /** * GS2-19 — all turns of one conversation in chronological (insert) order, so a search hit can be * expanded into the whole thread it belonged to. Fail-soft ([] on error / unknown id). */ getConversationThread(conversationId: number): SessionRecord[]; /** * Aggregate token/cost totals, a top-tool tally, and a per-command breakdown over the whole * store. Tool tallying reads each row's JSON `tools` array in JS (robust to nulls). Fail-soft: * returns a zeroed summary on any error. */ insights(topN?: number): HistoryInsights; /** Close the underlying connection (fail-soft). */ close(): void; } /** * Fail-soft open of the history store. Returns `null` (never throws) when the store can't be opened * or, for read-only callers (`create: false`, the default), when nothing is there yet. */ export declare function openHistoryStore(dbPath: string, options?: OpenHistoryStoreOptions): HistoryStore | null; /** * GS2-121 — rebuild the index of the store at `storePath` from its thread files alone, which is * what `gth history rebuild` runs. Splits a single-file store first, like any open. `null` when * there is no store there or it could not be rebuilt; never throws. */ export declare function rebuildHistoryIndexSafe(storePath: string): IndexRebuildSummary | null;