/** * MessageStore — CRUD for `chat_messages` plus the server-side * sequence assigner described in tech-spec §4. * * Key properties: * - Every insert assigns `seq = MAX(seq)+1` for the channel inside a * transaction that also bumps `chat_channels.last_message_at`. * - Concurrent writes serialize through the SQLite write lock; the * `UNIQUE(channel_id, seq)` constraint is a belt-and-braces guard. * - `clientMessageId` is an optional idempotency key stored in * `metadata.clientMessageId`; duplicate inserts surface the * persisted row instead of erroring. * * @module services/chat-v2/sqlite/message.store */ import { type ChatChannelType, type ChatContentType, type ChatMessageCursorPayload, type ChatMessageRow, type ChatSenderType } from '../types.js'; import type { ChatDatabase } from './chat-db.js'; import { type OwnerIdentity } from './unified-log.js'; /** * Encode a pagination cursor to an opaque base64url string. * * @param payload - Cursor fields * @returns Opaque base64url-encoded JSON string */ export declare function encodeCursor(payload: ChatMessageCursorPayload): string; /** * Decode an opaque pagination cursor. * * @param cursor - The cursor string (or null/undefined) * @returns The decoded payload, or null if input is missing * @throws {ChatError} `invalid_cursor` (400) if the cursor fails to parse or * doesn't contain the expected fields. */ export declare function decodeCursor(cursor: string | null | undefined): ChatMessageCursorPayload | null; /** Options for inserting a message. */ export interface MessageInsertInput { channelId: string; senderType: ChatSenderType; senderId: string; content: string; contentType?: ChatContentType; /** Optional metadata object. Will be JSON-stringified. */ metadata?: Record; /** Stable client id for idempotent retries; folded into metadata. */ clientMessageId?: string; /** * Phase A (SEALED §3.2) — array of mention IDs (member or team) * referenced inline in `content`. Stored as a JSON-encoded string in * the `mentions` column. Empty array or omitted → null in storage. */ mentions?: string[]; /** * Phase A (SEALED §3.2) — Slack-style thread root. When set, this * message is a reply within the thread rooted at `threadId`. Omitted * for top-level channel messages. */ threadId?: string; /** Override the insert clock (tests). */ nowMs?: number; /** Override the generated id (tests / deterministic callers). */ id?: string; } /** A timeline row: the message, its unified-log columns, and its channel. */ export interface AgentTimelineRow extends ChatMessageRow { /** SQLite rowid — the tie-breaker for rows created in the same ms. */ rowid: number; source: string | null; direction: string | null; sender_kind: string | null; agent_session: string | null; ext_ref: string | null; channel_name: string; channel_type: ChatChannelType; } /** One hit of {@link MessageStore.searchForAgent}. */ export interface ChatSearchRow { id: string; channel_id: string; channel_name: string; channel_type: ChatChannelType; sender_type: string; sender_id: string; content: string; created_at: number; thread_id: string | null; } /** Keywords and filters of a chat search. */ export interface ChatSearchOptions { /** Keywords, all required (case-insensitive substring) */ terms: string[]; /** Channel id, or name (with or without `#`) */ channel?: string; /** Only messages created at or after this (epoch ms) */ fromMs?: number; /** Only messages created before this (epoch ms) */ toMs?: number; /** Most hits */ limit: number; } /** Result of inserting a message — includes whether this was a dedupe hit. */ export interface MessageInsertResult { row: ChatMessageRow; /** True when the returned row pre-existed under the given clientMessageId. */ deduped: boolean; } /** Options for listing messages with cursor pagination. */ export interface MessageListOptions { cursor?: string | null; /** 1..100, default 50. */ limit?: number; /** `backward` (default) loads older; `forward` loads newer. */ direction?: 'backward' | 'forward'; } /** SQLite-backed store for chat messages. */ export declare class MessageStore { private readonly db; /** Cap on a single page size. */ static readonly MAX_LIMIT = 100; /** Default page size when caller didn't specify. */ static readonly DEFAULT_LIMIT = 50; /** Who the owner is (Slack user / workspace) — feeds `sender_kind` and `ext_ref`. */ private ownerIdentity; constructor(db: ChatDatabase); /** * Wire the owner-identity lookup used when deriving the unified-log * columns. Called post-construction because Slack is configured after the * chat store opens. * * @param provider - Returns the owner's identity, or null when unknown */ setOwnerIdentityProvider(provider: () => OwnerIdentity | null): void; /** * Resolve the owner identity without letting a faulty provider break a write. * * @returns The identity, or null */ private safeOwnerIdentity; /** * Insert a message into the channel. * * Performs sequence assignment (`MAX(seq)+1`) and idempotency dedupe in a * single transaction. If a row with the same `(channel_id, clientMessageId)` * already exists, returns it with `deduped=true` instead of inserting. * * Also updates the parent channel's `last_message_at`. * * @param input - Insert payload * @returns The persisted row plus a dedupe flag * @throws {ChatError} `channel_not_found` (404) if the channel is missing * (or archived and we want to refuse). */ insert(input: MessageInsertInput): MessageInsertResult; /** * Look up a message by its id. * * @param id - The message id * @returns The row, or null */ getById(id: string): ChatMessageRow | null; /** * List messages for a channel with cursor pagination. * * @param channelId - The channel id to read * @param options - Listing options (cursor, limit, direction) * @returns An ordered array of rows and cursors for next/prev pages */ listByChannel(channelId: string, options?: MessageListOptions): { rows: ChatMessageRow[]; nextCursor: string | null; prevCursor: string | null; }; /** Execute the paginated SELECT for one page of messages. */ private queryPage; /** Compute next/prev cursors based on the page result. */ private buildCursors; /** * Aggregate Slack-style thread reply counts for a channel in a single * query. * * Replies carry `thread_id = `; root messages have * `thread_id IS NULL`. This groups every reply by its root and returns * one entry per thread with the reply count and the timestamp (ms) of * the most recent reply. The caller attaches these to the matching * root-message DTOs. * * Kept as ONE aggregate query per page (O(rows-with-threads)) rather * than a per-message lookup, so a page render stays a fixed two-query * cost regardless of page size. * * @param channelId - The channel id to aggregate over * @returns Map of `rootMessageId` to `{ replyCount, lastReplyAtMs }` */ threadReplySummary(channelId: string): Map; /** * The most recent messages of a channel, or of one thread within it. * * Used to show an agent what was said before the message it is being woken * for. Without it an agent @-mentioned into a channel sees exactly one * line and nothing else, so it cannot tell whether a colleague already * answered, and cannot check its own account of what it was told. * * Ordered oldest-first, which is the order it will be read in. * * @param channelId - The channel id * @param rootId - Thread root to scope to, or undefined for the channel * @param limit - Maximum messages to return * @returns Rows oldest first, newest last */ recentTurns(channelId: string, rootId: string | undefined, limit: number): Array<{ senderType: string; senderId: string; content: string; createdAt: number; threadId: string | null; }>; /** * Agents already engaged in a thread: every agent that posted in it * (root or reply) plus every session @-mentioned anywhere in it. A * human's follow-up in that thread goes to these agents without another * @, which is what makes a Slack thread feel like a conversation. * * @param channelId - The channel id * @param rootId - The thread root message id * @returns Distinct session names, in first-seen order */ threadParticipants(channelId: string, rootId: string): string[]; /** * Every message of a thread (the root and its replies), oldest first. * Read by the open-items backfill (specs/2026-10-01-reply-open-items.md). * * @param channelId - The channel id * @param rootId - The thread root message id * @param limit - Max rows (newest kept when over) * @returns Rows in seq order */ listThread(channelId: string, rootId: string, limit?: number): ChatMessageRow[]; /** * The agent that spoke last in a thread. * * A bare follow-up in a thread addresses whoever just spoke, the way it * does between people. Treating every engaged agent as required meant an * agent answered — and acted on — a line meant for a colleague: the owner * wrote "那要不算了?" to one agent and a second one rolled back its own * unrelated config (2026-09-21, #daily-info). * * @param channelId - The channel id * @param rootId - The thread root message id * @returns The last agent sender, or null when no agent has posted */ lastThreadSpeaker(channelId: string, rootId: string): string | null; /** * Return the current `MAX(seq)` for a channel, or 0 when empty. * * @param channelId - The channel id * @returns The largest seq, or 0 */ getLastSeq(channelId: string): number; /** Count messages for a channel. Used by tests and metrics. */ count(channelId: string): number; /** * Count all messages across every channel in the database. * * Onboarding v3 (B1) — used by the cold-start detector to decide whether * a fresh OSS install should auto-launch onboarding. The "any chat at all * means user has been here before" semantics is intentional: the detector * combines this with the per-project `firstLaunchedAt` marker to defend * against the case where chat-v2 is wiped accidentally on an existing * install (a non-zero count short-circuits onboarding before the marker * is even consulted). * * Single COUNT(*) over the index — O(rows) in the worst case but * negligible at install time when the table is empty (the only time the * caller actually cares about the result). * * @returns Total number of persisted chat messages */ countAll(): number; /** * Count messages created after `sinceMs` that a PERSON wrote. Agent rows * are excluded on purpose: an agent's own status echo ("[DONE] Agent orc: * Task complete") or a cron post lands in chat as an agent row, and * counting it would let the orchestrator re-arm its own reflect nudge. * System rows (dispatches, nudges) are not conversation either. * * Used by the wiki reflect trigger to decide whether there is anything new * to sweep before it wakes the orchestrator (2026-09-17): a reflect nudge * that follows no conversation costs a full model turn and always ends in * "nothing this period". * * @param sinceMs - Epoch ms; rows with `created_at > sinceMs` count * @returns Number of user/agent messages newer than `sinceMs` */ countConversationSince(sinceMs: number): number; /** * Phase 6.0b — delete every message in a channel while leaving the * channel row in place. Used by the legacy `clearConversation` * controller route. Not transactional with channel state; callers * that need atomicity should wrap in their own transaction. * * @param channelId - The channel whose messages to remove * @returns Number of rows deleted */ deleteAllByChannel(channelId: string): number; /** * Replace a message's content in place. Used for server-authored rows that * change state after they are posted (a ticket receipt turning into * "is no longer tracked"); user and agent turns are never edited. * * @param id - Message id * @param content - New content * @returns The updated row, or null when no such message exists */ updateContent(id: string, content: string): ChatMessageRow | null; /** * Merge a JSON patch into an existing message's metadata column. * Implementation of Phase 6.0 of the unified-chat-message-store spec * — replaces the legacy `ChatService.updateMessageMetadata` so all * mutation flows through the canonical store. SQLite's `json_patch` * built-in handles the merge atomically. * * @param id - Message id to update * @param patch - Partial metadata object to merge in (shallow merge) * @returns The post-update row, or null if the message doesn't exist */ updateMetadata(id: string, patch: Record): ChatMessageRow | null; /** * Find messages with `metadata.slackDeliveryStatus = 'pending'` AND a * non-empty `metadata.slackChannelId`, created within the past * `maxAgeMs`. Used by `NotifyReconciliationService` to retry Slack * deliveries that failed mid-flight. * * The `slackDeliveryStatus` field is set by callers (legacy code and * Phase 3 Slack writes) — this method is the canonical replacement * for `ChatService.getMessagesWithPendingSlackDelivery`. * * @param maxAgeMs - Lookback window in milliseconds * @param nowMs - Optional clock override for tests * @returns Up to `MAX_LIMIT` matching rows, newest first */ /** * Find the thread-root message a Slack thread maps to. Slack team channels * persist every inbound root with `metadata.slackThreadTs = `; * a Slack reply carries that ts as `thread_ts`, so this lookup recovers * the chat-v2 root the reply belongs under. * * @param channelId - The chat-v2 channel (huddle) id * @param slackThreadTs - Slack thread timestamp (`1712345678.000100`) * @returns The root row, or null when no message carries that ts */ findThreadRootBySlackTs(channelId: string, slackThreadTs: string): ChatMessageRow | null; /** * Whether any chat channel recorded the Slack message `slackTs` from Slack * channel `slackChannelId` (`metadata.slackChannelId` + `metadata.slackTs`). * The delivery audit asks this for each owner message Slack has. * * @param slackChannelId - Slack channel id * @param slackTs - The Slack message's own ts * @returns True when a row carries it */ hasSlackMessage(slackChannelId: string, slackTs: string): boolean; /** * The newest user-origin message in a channel that @-mentioned `agentSession` * at or after `sinceMs` — the message that agent was most recently asked in * this channel. Evidence that an agent's reply belongs here, and which thread. * * @param channelId - The chat-v2 channel id * @param agentSession - The agent's session name (as stored in `mentions`) * @param sinceMs - Oldest `created_at` (epoch ms) to consider * @returns The row, or null */ findLatestUserMessageMentioning(channelId: string, agentSession: string, sinceMs: number): ChatMessageRow | null; /** * The most recent thread-root in a channel that originated from Slack — * an inbound message, not a local agent's own top-level post (those carry * `slackThreadTs` too once mirrored, so replies under them can find them). * Used to tell whether an un-@'d Slack message continues a person-to-person * exchange. It is no longer an outbound fallback: an agent message with no * thread is posted top level (2026-10-03, nightly reports buried in the * owner's latest thread). * * @param channelId - The chat-v2 channel (huddle) id * @returns The latest Slack-origin root row, or null */ findLatestSlackRoot(channelId: string): ChatMessageRow | null; /** * Epoch ms of the owner's latest turn on a channel (any surface). * * @param channelId - chat-v2 channel id * @returns Epoch ms, or null */ latestOwnerTurnAt(channelId: string): number | null; /** * Surface of the owner's most recent message in a channel — the Slack * reply-affinity rule (spec §A.3 G6) mirrors an agent's answer to Slack only * when the owner last spoke there. * * Rows an agent wrote as a `user` turn (`sender_kind = 'agent'`) and other * people's rows (`human`) are not the owner and are skipped. * * @param channelId - The chat-v2 channel id * @returns The source column (`slack`, `crewly-chat`, `cloud-talk`, …), or null */ latestOwnerTurnSource(channelId: string): string | null; /** * One page of an agent's merged timeline across every surface: rows whose * `agent_session` is the agent, plus every row of the huddles the agent is * a member of. Newest first (spec §A.5). * * @param agentSession - Agent session name * @param options.before - Only rows strictly older than this (ms), or a * `{createdAt, rowid}` position from a previous page * @param options.limit - Page size (already clamped by the caller) * @param options.sources - Restrict to these `source` values * @returns Rows (with their channel's name/type and the rowid used for paging) */ listAgentTimeline(agentSession: string, options: { before?: { createdAt: number; rowid?: number; }; limit: number; sources?: string[]; }): Array; /** * Search the messages an agent can see — its DMs, the channels and huddles * it is a member of, and any message that @-mentions it — by keywords (all * must appear), optional channel and date range, newest first. * * @param agentSession - The agent whose view is searched * @param opts - Keywords and filters, see {@link ChatSearchOptions} * @returns Hits, newest first */ searchForAgent(agentSession: string, opts: ChatSearchOptions): ChatSearchRow[]; /** * Search every conversation on this machine — the owner's view (#1400 * cross-conversation search). Same keywords and filters as * {@link searchForAgent}, no membership scope. * * @param opts - Keywords and filters, see {@link ChatSearchOptions} * @returns Hits, newest first */ searchAll(opts: ChatSearchOptions): ChatSearchRow[]; /** * Shared body of the two searches: keyword, channel and date clauses on * top of an optional scope clause. * * @param scope - SQL predicate restricting whose messages are seen, or null for all * @param params - Bind values the scope predicate consumes, in order * @param opts - Keywords and filters * @returns Hits, newest first */ private runSearch; /** * Every message since a time in the owner's live channels (not archived), * oldest first, capped at the newest `limit` — Drive mode recall reads its * window from here (specs/2026-10-08-drive-mode.md §7). * * @param sinceMs - Only messages created at or after this (epoch ms) * @param limit - Most rows returned (newest kept) * @returns Rows with their unified-log columns and channel, oldest first */ listOwnerFeedRows(sinceMs: number, limit: number): Array; /** * When the owner last spoke in each conversation, all time: per channel * for a DM, per thread (root message id) for a channel or huddle. * * @returns One row per conversation the owner ever wrote in */ listOwnerTurnMarks(): Array<{ channelId: string; root: string; lastAt: number; }>; /** * Slack channels where an inbound (owner/user) Slack message was stored * since `sinceMs`, most recently active first. * * @param sinceMs - Epoch ms lower bound * @param limit - Most channels returned * @returns Slack channel ids */ listSlackInboundChannels(sinceMs: number, limit: number): string[]; findPendingSlackDelivery(maxAgeMs: number, nowMs?: number): ChatMessageRow[]; } //# sourceMappingURL=message.store.d.ts.map