/** * ChatV2Service — orchestrator for the Agent-First Chat MVP. * * Wires ChannelStore + MessageStore, enforces the authorization rules * laid out in the tech spec §7.2, and maps DB rows to wire-format DTOs. * * WebSocket push, offline queuing, and attachment durability land in * later passes of Phase 1 — see §18 rollout plan. * * @module services/chat-v2/chat-v2.service */ import type { ChatV2Config } from './config.js'; import { EventEmitter } from 'events'; import { type ChatDatabase } from './sqlite/chat-db.js'; import { CloudOutboxStore } from './sqlite/cloud-outbox.store.js'; import { type OwnerIdentity } from './sqlite/unified-log.js'; import { type ChatAttachmentDTO, type ChatChannelDTO, type ChatChannelType, type ChatContentType, type ChatHuddleMember, type ChatMessageDTO, type ChatMessageListResult, type ChatPrincipal, type ChatReadStateDTO, type ChatUnreadSummaryDTO, type ChatSenderType, type ChatTimelineItemDTO, type ChatTimelineResult } from './types.js'; /** Constructor options for `ChatV2Service`. */ export interface ChatV2ServiceOptions { config: ChatV2Config; /** Optional pre-opened DB (tests pass an in-memory handle). */ db?: ChatDatabase; /** Presence lookup — wired to `AgentRegistrationService` in wiring; tests pass a stub. */ getPresence?: (agentSession: string) => { status: ChatChannelDTO['agentPresence']['status']; lastSeenAt: number | null; }; /** * F2b (#333) — outer-ring tenant defense. Called when creating a * `type='channel'` channel; if the principal cannot bind a channel * to `teamId`, return false → `createChannel` throws * `forbidden_team` (HTTP 403). Sync only, since `createChannel` runs * inside the synchronous `runHandler` wrapper (an async refactor is * a Phase E concern, not a F2b requirement). * * In OSS production, wire to a check that confirms `teamId` exists * in `StorageService.getTeams()` — single-user OSS treats existence * as membership. Cloud Portal Phase E swaps in a real user-tenant * binding check. * * When omitted (legacy tests, REST-only fixtures) `createChannel` * preserves pre-F2b behavior: only validates teamId is non-empty. * The composition root in `backend/src/index.ts` MUST inject this * to close the leak in production. */ validateTeamMembership?: (principal: ChatPrincipal, teamId: string) => boolean; /** Clock override for tests. */ now?: () => number; } /** Minimal interface the service needs from a presence provider. */ export interface ChatPresenceProvider { getPresence(agentSession: string): { status: ChatChannelDTO['agentPresence']['status']; lastSeenAt: number | null; }; } /** Arguments for `createChannel`. */ export interface CreateChannelArgs { /** * Wire-level session binding. Required when `type='dm'` (default); * for `type='channel'`, callers may omit it (server stores ''). */ agentSession?: string; name: string; purpose?: string; principal: ChatPrincipal; /** * Phase A (SEALED §3.1) — channel kind. Defaults to `'dm'` to preserve * the Phase 1 contract for callers that omit this field. */ type?: ChatChannelType; /** Phase A — required when `type='channel'`. */ teamId?: string; /** Phase A — optional even when `type='channel'`. */ projectId?: string; /** Phase A — optional when `type='dm'`. */ targetMemberId?: string; } /** Arguments for `createHuddle` (Phase B-2). */ export interface CreateHuddleArgs { /** Display name shown in the channel list (e.g. "Q4 planning"). */ name: string; /** Optional one-liner — purpose of the huddle. */ purpose?: string; /** * Agent session names (matches `DirectoryAgent.agentSession`) for * every member of the huddle. Must contain at least one entry; the * service rejects empty arrays so the dispatcher never has zero * recipients to fan out to. Order is not significant — the store * dedupes on insert. */ memberSessions: string[]; principal: ChatPrincipal; } /** Arguments for `listChannels`. */ export interface ListChannelsArgs { principal: ChatPrincipal; includeArchived?: boolean; limit?: number; /** * Phase C — filter to a single channel type. The controller validates * the wire value against {@link CHAT_CHANNEL_TYPES} before reaching * this layer; an unknown value is rejected as `validation_error`. */ type?: ChatChannelType; /** * Phase C — filter to channels with this `team_id`. Empty string is * treated the same as `undefined` (no filter) so callers don't have * to pre-normalize blank query strings. The store-level filter still * uses an exact-match against the column, so DMs (which have null * team_id) drop out of the result. */ teamId?: string; /** * Include "bridged" channels (Slack inbound, owned by `'system'`) in the * result. Defaults to true. Only applies to the unfiltered list (no * `type`/`teamId` filter) — bridged threads are surfaced alongside the * user's own DMs/channels in the consolidated chat list. */ includeBridged?: boolean; } /** Arguments for `sendMessage`. */ export interface SendMessageArgs { channelId: string; principal: ChatPrincipal; content: string; /** * An agent's interim note (plan / "got it") before the real answer. Only * honoured for agent senders; stored as `metadata.interim`. */ interim?: boolean; contentType?: ChatContentType; clientMessageId?: string; /** * Surface the message came through when it is not Crewly Chat. Only * `'cloud-talk'` (the portal's Talk page via the relay) is accepted today; * stored as `metadata.source` so the unified log can tell Talk apart * (spec §A.3 G3). */ origin?: 'cloud-talk'; /** Attachment hooks — the store is added in a later step, so pre-resolved DTOs are accepted. */ attachments?: ChatAttachmentDTO[]; /** * Phase A (SEALED §3.2) — array of mention IDs referenced inline in * `content`. Persisted as JSON; emitted on the outbound message DTO. * Empty / omitted → no mentions. * * **Wire-shape varies by channel type — callers must pass the right * one for the channel they're posting to:** * * - `type='channel'`: each entry is a **member id** or **team id**. * The dispatcher routes these through `ChatV2MentionResolver` to * look up the matching session name(s). * * - `type='huddle'`: each entry is an **agent session name** (the * same shape that goes into `chat_channel_members.member_session`). * The dispatcher does NOT run them through the resolver — they're * matched directly against the huddle roster to set per-recipient * `responseMode` (`'required'` for mentioned members, `'optional'` * otherwise). * * - `type='dm'`: mentions are advisory only — DMs have a single * recipient already, and the dispatcher doesn't re-route based on * mentions. */ mentions?: string[]; /** * Phase A (SEALED §3.2) — Slack-style thread reply root. When set, * the new message is a reply within the thread rooted at this id; * the service validates that the thread root lives in the same * channel before persisting. */ threadId?: string; /** * Why an agent posted ("Triggered by" provenance, turn-trigger.ts). Only * honoured for agent senders; stored as `metadata.triggeredBy`. */ triggeredBy?: Record; } /** * Allowed values for `RecordTurnInput.metadata.source` — the audit-trail * discriminator that identifies which subsystem produced the message. * * Per spec `2026-05-14-unified-chat-message-store.md`, every {@link * ChatV2Service.recordTurn} caller MUST set `metadata.source` to one of * these values. The set is intentionally closed so future audits can * `GROUP BY metadata->>'$.source'` without surprise values. */ export declare const RECORD_TURN_SOURCES: readonly ["web", "slack", "pty-runtime", "in-process-runtime", "reply-tool", "system", "telegram", "google-chat", "whatsapp", "cloud-talk", "app-comment"]; /** Union type of the values in {@link RECORD_TURN_SOURCES}. */ export type RecordTurnSource = (typeof RECORD_TURN_SOURCES)[number]; /** Server-internal write input for {@link ChatV2Service.recordTurn}. */ export interface RecordTurnInput { channelId: string; /** Server resolved — runtime/controller already knows who's sending. */ senderType: ChatSenderType; /** User id, agent session name, or `'system'`. */ senderId: string; content: string; contentType?: ChatContentType; /** Stable idempotency key — dedup keyed on `(channel_id, clientMessageId)`. */ clientMessageId?: string; /** Optional thread-root reference; validated to live in the same channel. */ threadId?: string; /** Optional inline mention ids (member or team). */ mentions?: string[]; /** Required audit metadata. Caller MUST set `source`. */ metadata: { source: RecordTurnSource; /** Which agent runtime emitted the message, when applicable. */ runtime?: 'claude-code' | 'gemini-cli' | 'crewly-agent'; /** Slack correlation fields for cross-store reconciliation. */ slackChannelId?: string; slackThreadTs?: string; /** Free-form additional context — not parsed by the store. */ [key: string]: unknown; }; } /** Result of {@link ChatV2Service.recordTurn}. */ export interface RecordTurnResult { /** The persisted (or pre-existing, when deduped) message DTO. */ message: ChatMessageDTO; /** True when `clientMessageId` matched an existing row. */ deduped: boolean; } /** Arguments for `listMessages`. */ export interface ListMessagesArgs { channelId: string; principal: ChatPrincipal; cursor?: string | null; limit?: number; direction?: 'backward' | 'forward'; } /** One hit of a chat search (`GET /api/chat/search`): a compact, owner-visible snippet. */ export interface ChatSearchHit { id: string; /** ISO timestamp */ time: string; channelId: string; /** Channel name as stored (channels may carry a leading `#`) */ channel: string; channelType: ChatChannelType; sender: string; /** `user` | `agent` (system rows are never returned) */ senderType: string; /** Flattened, redacted snippet, at most `CHAT_SEARCH_CONSTANTS.SNIPPET_CHARS` characters */ text: string; threadId: string | null; } export declare class ChatV2Service extends EventEmitter { /** Phase A spec §3.2: max mention count per message. */ static readonly MAX_MENTIONS_PER_MESSAGE = 50; /** Phase A spec §3.2: max JSON-encoded byte size of the mentions array. */ static readonly MAX_MENTIONS_JSON_BYTES = 1024; readonly config: ChatV2Config; private readonly db; private readonly channels; private readonly messages; private readonly readState; private readonly outbox; private presence; private readonly validateTeamMembership; private readonly now; /** Which system-owned huddles the owner may read and post in (Crewly channels). */ private sharedChannel; constructor(options: ChatV2ServiceOptions); /** * Wire (or replace) the synchronous presence provider used by the channel * DTO mapper — i.e. what drives the DM presence dots in the conversation * list. Settable POST-construction because the singleton is first-constructed * by whichever caller wins the race (slack bridge, ws gateway, replay, …), * usually with no presence wired; the composition root then calls this once * so the dots reflect real agent liveness regardless of construction order. * * @param getPresence - Sync presence lookup (e.g. `createOssSyncPresence()`) */ setPresenceProvider(getPresence: ChatV2ServiceOptions['getPresence']): void; /** * Wire which huddles are shared with the owner even though a bridge created * them under the synthetic `'system'` owner: Crewly channels, including * ones found in Slack. The owner (a principal without an agent session) may * then open, read and post in them like in their own channels; agents still * need to be members. * * @param isShared - True for a channel id that is a Crewly channel; null to unwire */ setSharedChannelResolver(isShared: ((channelId: string) => boolean) | null): void; /** * Wire who the owner is on shared surfaces (their Slack user id and the * workspace this machine serves). Feeds each new row's `sender_kind`, * `ext_ref.slackTeamId` and — in shared Slack channels — whether it may be * synced to Cloud (owner decision O3). * * Also upgrades rows recorded before the owner was known (legacy backfill, * early boot) from `human` to `owner`. * * @param provider - Returns the owner's identity, or null when unknown */ setOwnerIdentityProvider(provider: () => OwnerIdentity | null): void; /** * Mark the owner's Slack rows as the owner's (see {@link setOwnerIdentityProvider}). * Safe to call repeatedly; never throws. * * @param provider - Owner identity lookup * @returns Rows reclassified */ reclassifyOwnerRows(provider: () => OwnerIdentity | null): number; /** * The durable Cloud outbox + sync state on this database (the uploader's * only way in; see `ConversationCloudSyncService`). * * @returns The outbox store */ getCloudOutbox(): CloudOutboxStore; /** * Surface of the owner's latest message on a channel — `slack`, * `crewly-chat`, `cloud-talk`, … — or null when the owner never spoke * there. Server-internal read used by the Slack reply-affinity rule (G6). * * @param channelId - chat-v2 channel id * @returns The source, or null */ getLatestOwnerTurnSource(channelId: string): string | null; /** * When the owner last spoke on a channel (any surface), epoch ms. * * @param channelId - chat-v2 channel id * @returns Epoch ms, or null when the owner never spoke there */ getLatestOwnerTurnAt(channelId: string): number | null; /** * An agent's merged timeline across every surface — its DMs, Slack * threads, messenger conversations and the huddles it belongs to — * newest first (spec §A.5). Replaces the client-side fan-out over * channels. * * Server-internal read with no per-channel owner check (the OSS backend * serves one owner). An agent principal may read only its own timeline. * * @param args.agentSession - Agent session name * @param args.principal - Caller * @param args.before - Only messages created strictly before this (ms) * @param args.cursor - `nextCursor` of a previous page (wins over `before`) * @param args.limit - Page size (default 50, max 200) * @param args.sources - Only these surfaces * @returns The page and the cursor of the next one * @throws {ChatError} `validation_error` (400) on a bad session / cursor / source * @throws {ChatError} `forbidden` (403) when an agent reads another agent's timeline */ getAgentTimeline(args: { agentSession: string; principal: ChatPrincipal; before?: number; cursor?: string | null; limit?: number; sources?: string[]; }): ChatTimelineResult; /** * Search the chat messages one agent can see (its DMs, the channels and * rooms it is in, and messages that @-mention it) and return compact hits. * An agent principal may only search as itself. The owner (a principal * without an agent badge) may leave `agentSession` empty to search every * conversation on this machine — the `/team-chat` cross-conversation * search (#1400). * * @param args.agentSession - Whose view is searched; empty = the owner's view of everything * @param args.principal - Caller * @param args.query - Keywords (whitespace separated, all must appear) * @param args.channel - Channel id or name * @param args.from - Start (inclusive), epoch ms * @param args.to - End (exclusive), epoch ms * @param args.limit - Most hits * @returns Hits, newest first * @throws {ChatError} `validation_error` (400) without a keyword, or without a session for an agent caller; `forbidden` (403) for another agent's view */ searchMessagesForAgent(args: { agentSession?: string; principal: ChatPrincipal; query: string; channel?: string; from?: number; to?: number; limit?: number; }): ChatSearchHit[]; /** * The owner's conversations since a time (Drive mode recall, * specs/2026-10-08-drive-mode.md §7): every message in a live channel * (oldest first, capped) plus when the owner last spoke in each * conversation, all time. Server-internal (one owner per OSS backend). * * @param args.sinceMs - Window start (epoch ms) * @param args.limit - Most messages returned (newest kept) * @returns Messages and owner turn marks */ getOwnerFeed(args: { sinceMs: number; limit: number; }): { messages: ChatTimelineItemDTO[]; ownerTurns: Array<{ channelId: string; root: string; lastAt: number; }>; }; /** * When the owner last wrote in each conversation (per DM channel, per * channel thread), all time. Server-internal. * * @returns One mark per conversation */ getOwnerTurnMarks(): Array<{ channelId: string; root: string; lastAt: number; }>; /** Release the DB handle. Safe to call during graceful shutdown / in tests. */ close(): void; /** * Count every persisted chat message across every channel. * * Onboarding v3 (B1) — surfaced for the cold-start detector. Defers to * `MessageStore.countAll()`. Intentionally bypasses the principal / * authorization layer because the caller is the orchestrator bootstrap, * which runs without an HTTP-request principal context. The detector * only needs an unsigned "is the store empty" answer; no row contents * are exposed. * * @returns Total message count across all channels (0 on a fresh install) */ countAllMessages(): number; /** * Slack channels with stored inbound messages since `sinceMs` (backfill scope). * * @param sinceMs - Epoch ms lower bound * @param limit - Most channels returned * @returns Slack channel ids */ listSlackInboundChannels(sinceMs: number, limit: number): string[]; /** * Whether a person has said anything since `sinceMs`. Agent and system * rows do not count (an agent's own echo must not re-arm the nudge that * produced it). Bypasses principal scoping on purpose — this is a global * "is there new conversation to reflect on" probe. * * @param sinceMs - Epoch ms lower bound (exclusive) * @returns True when at least one user/agent message is newer */ hasConversationSince(sinceMs: number): boolean; /** * Edit a SYSTEM row's content (and optionally merge metadata), then emit * `chat_message_updated` so live views can refresh it. Only rows with * `senderType === 'system'` can be edited — the owner's and agents' words * are never rewritten. * * @param messageId - Message id * @param content - New content * @param metadataPatch - Optional shallow metadata patch * @returns The updated message, or null when missing or not a system row */ updateSystemMessage(messageId: string, content: string, metadataPatch?: Record): ChatMessageDTO | null; /** * Phase 6.0 of unified-chat-message-store spec — replacement for the * legacy `ChatService.updateMessageMetadata`. Merges a partial * metadata object into the stored row's `metadata` JSON column using * SQLite's `json_patch` (atomic, server-side). * * No principal check — this is a server-internal mutation path used * by reconciliation jobs (Slack delivery status updates) and never * exposed directly to user HTTP traffic. Phase 6c will retire the * legacy method that called this; until then it is the only callable * write-through for the existing reconciliation code. * * @param messageId - Message id to update * @param patch - Shallow metadata patch to merge * @returns The updated message DTO, or null if no such message exists */ updateMessageMetadata(messageId: string, patch: Record): ChatMessageDTO | null; /** * Phase 6.0 — replacement for the legacy * `ChatService.getMessagesWithPendingSlackDelivery`. Returns the * messages still marked `slackDeliveryStatus='pending'` within the * caller-supplied lookback window, used by NotifyReconciliationService * to retry stuck Slack deliveries. * * @param maxAgeMs - Lookback window in milliseconds * @returns Pending-delivery messages, newest first, capped at MAX_LIMIT */ findMessagesWithPendingSlackDelivery(maxAgeMs: number): ChatMessageDTO[]; /** * Phase 6.0 — replacement for the legacy `ChatService.getStatistics`. * Aggregate counts used by the boot-time telemetry and the * admin/audit dashboards. * * @returns Active/archived channel counts plus total message count */ getStatistics(): { totalChannels: number; activeChannels: number; archivedChannels: number; totalMessages: number; }; /** * Create a channel for an agent session (an agent may hold N concurrent * channels — the 1:1 binding was dropped, unified-chat-message-store * spec Option B). Server always assigns * `owner_user_id = principal.userId` — the body's owner fields are ignored. * * F2b (#333): when `type='channel'` and a `validateTeamMembership` provider * is wired into the service, the principal must be authorized for the * requested `teamId`. Failures throw `forbidden_team` (403) — distinct * from generic `forbidden` so the FE can surface a tenant-specific * message. * * @param args - Channel creation args * @returns The created channel as a DTO * @throws {ChatError} `validation_error` (400) / `forbidden_team` (403) */ createChannel(args: CreateChannelArgs): ChatChannelDTO; /** * Phase B-2 (2026-05-17) — create a huddle (ad-hoc multi-agent group * channel). Creates a `type='huddle'` channel row with no team * binding, then inserts one row per member into * `chat_channel_members`. The dispatcher uses that roster to fan out * subsequent user messages to every member; agents whose session is * in the outgoing message's `mentions[]` get a "must respond" * prompt, others get an "optional" one. * * @param args - name, optional purpose, member roster, owning principal * @returns The created huddle channel as a DTO, with `members` populated * @throws {ChatError} `validation_error` (400) when name is empty/too long, * purpose too long, or memberSessions is empty / has too many entries. */ createHuddle(args: CreateHuddleArgs): ChatChannelDTO; /** * Dedupe + trim a huddle roster and enforce its bounds. Agent existence * is not checked here — the dispatcher validates sessions at fan-out. * * @param raw - Session names as supplied by the caller * @returns The cleaned roster, in input order * @throws {ChatError} `validation_error` (400) when the roster is empty or * over {@link MAX_HUDDLE_MEMBERS} */ private normalizeHuddleRoster; /** * Replace the roster of a group chat the owner holds (#1400 private * channels): invite or remove agents after the fact. Crewly channels are * edited through `/api/channels` (their Slack bots follow) and refused here. * * @param channelId - The huddle * @param memberSessions - The complete desired roster * @param principal - The owner * @returns The channel with its new `members` * @throws {ChatError} `channel_not_found` (404) when unknown or not owned; * `validation_error` (400) for a non-huddle, a Crewly channel, an empty * roster or one over the member cap */ setHuddleRoster(channelId: string, memberSessions: readonly string[], principal: ChatPrincipal): ChatChannelDTO; /** Internal: refuse owner edits on a Crewly channel, which `/api/channels` owns. */ private requireNotCrewlyChannel; /** * Phase B-2 — list the members of a huddle channel. Returns an empty * array (not an error) for non-huddle channels so consumers can call * this unconditionally during channel rendering. * * @param channelId - The channel to enumerate * @param principal - The caller; used to verify ownership * @returns Array of `{ sessionName, joinedAt }` rows * @throws {ChatError} `not_found` (404) when the channel doesn't exist or * isn't owned by `principal`. */ listHuddleMembers(channelId: string, principal: ChatPrincipal): ChatHuddleMember[]; /** * Phase B-2 — dispatcher-facing roster lookup. Returns the session * names of every member in a huddle, in `joined_at ASC` order. * Unlike {@link listHuddleMembers} this is NOT principal-scoped: * the dispatcher runs server-side and already holds the channel * (it just persisted a message into it). Returns an empty array for * non-huddle channels or unknown ids so the dispatcher's * `members.length === 0` skip path stays clean. * * @param channelId - The huddle channel id * @returns Array of agent session names */ queryHuddleMembersForDispatch(channelId: string): string[]; /** * Agents already engaged in a thread (posted in it or @-mentioned in it), * so a human's follow-up in the thread reaches them without another @. * * @param channelId - The channel id * @param threadId - The thread root message id * @returns Session names, first-seen order */ queryThreadParticipantsForDispatch(channelId: string, threadId: string): string[]; /** * What was said before, for the prompt an agent is about to receive. * * Excludes the newest message: that is the one being dispatched, and the * prompt shows it in full directly below. Repeating it as context reads as * if the agent were being asked about it twice. * * @param channelId - The channel id * @param threadId - Thread root to scope to, or undefined for the channel * @param limit - Maximum messages to gather before the newest is dropped * @returns Turns oldest first */ queryRecentTurnsForDispatch(channelId: string, threadId: string | undefined, limit: number): Array<{ senderId: string; content: string; createdAt: string; inThread: boolean; }>; /** * The agent that spoke last in a thread — the one a bare follow-up is * addressed to. * * @param channelId - The channel id * @param threadId - The thread root message id * @returns The session name, or null when no agent has posted in it */ queryLastThreadSpeakerForDispatch(channelId: string, threadId: string): string | null; /** * Replace a huddle's roster with exactly `memberSessions` (insert the * missing ones, delete the rest). Used by Slack team channels to keep * the huddle in step with the Crewly team's members. No-op on non-huddle * channels. * * @param channelId - The huddle channel id * @param memberSessions - The complete desired roster (deduped here) * @returns Sessions added and removed, for logging */ setHuddleMembers(channelId: string, memberSessions: string[]): { added: string[]; removed: string[]; }; /** * Server-internal channel lookup with no principal check — for bridges * (Slack team channels) that hold a channel id they created themselves. * Archived channels are returned too; the caller decides. * * @param channelId - The channel id * @returns The DTO, or null when the id is unknown */ getChannelForBridge(channelId: string): ChatChannelDTO | null; /** * Rename a channel server-side (no principal) — keeps a Crewly channel's * huddle in step with its Slack name. No-op for an unknown id or a blank name. * * @param channelId - The channel id * @param name - The new name, as shown in the chat list * @returns True when the row exists and was renamed */ renameChannelForBridge(channelId: string, name: string): boolean; /** * Archive a channel server-side (no principal). Idempotent. * * @param channelId - The channel id * @returns True when a row was archived by this call */ archiveChannelForBridge(channelId: string): boolean; /** * Resolve the chat-v2 root message for a Slack thread. See * {@link MessageStore.findThreadRootBySlackTs}. * * @param channelId - The huddle channel id * @param slackThreadTs - Slack `thread_ts` * @returns The root message DTO, or null */ findSlackThreadRoot(channelId: string, slackThreadTs: string): ChatMessageDTO | null; /** * Whether this machine recorded a Slack message (any chat channel). See * {@link MessageStore.hasSlackMessage}. Server-side readers only. * * @param slackChannelId - Slack channel id * @param slackTs - The message's own ts * @returns True when recorded */ hasSlackMessageForBridge(slackChannelId: string, slackTs: string): boolean; /** * The latest Slack-origin thread root in a channel. See * {@link MessageStore.findLatestSlackRoot}. * * @param channelId - The huddle channel id * @returns The root message DTO, or null */ findLatestSlackRoot(channelId: string): ChatMessageDTO | null; /** * The newest user message in a channel that @-mentioned an agent since a * given time, server-side (no principal). See * {@link MessageStore.findLatestUserMessageMentioning}. * * @param channelId - The channel id * @param agentSession - The agent's session name * @param sinceMs - Oldest creation time (epoch ms) to consider * @returns The message DTO, or null */ findLatestUserMessageMentioning(channelId: string, agentSession: string, sinceMs: number): ChatMessageDTO | null; /** * Bridge lookup: every message of a thread (root + replies), oldest * first, without an auth check (server-side readers only). * * @param channelId - The channel id * @param rootId - The thread root message id * @returns Messages */ listThreadForBridge(channelId: string, rootId: string): ChatMessageDTO[]; /** * Read a single message by id, server-side (no principal). Used by the * outbound Slack mirror to recover a reply's thread root metadata. * * @param messageId - The message id * @returns The DTO, or null */ getMessageForBridge(messageId: string): ChatMessageDTO | null; /** * Internal: a Crewly channel (see {@link setSharedChannelResolver}) opened * by the owner — a principal acting as no agent. */ private isSharedWithOwner; /** Internal: is `sessionName` on the huddle's roster? */ private isHuddleMember; /** Internal: read members straight from the DB (no ownership check). */ private queryHuddleMembers; /** * Idempotent DM channel lookup-or-create for the /agents page. * * Returns the most-recent active DM channel owned by `principal.userId` * and bound to `agentSession`; creates a new one when none exists. The * caller is the human owner (auth principal), unlike * {@link ensureChannelForLegacyConversation} which runs as `'system'` * for server-internal bridge paths. * * Used by `POST /api/chat/channels/dm/ensure` so the /agents page can * map "user clicked an agent in the directory" → "send/receive messages * on this channel" without leaking duplicate DMs every time the page * is reloaded. * * @param args - Lookup-or-create args * @returns The channel DTO plus a `created` flag (true when a new row * was inserted; false when an existing row was reused). * @throws {ChatError} `validation_error` (400) on empty `agentSession` * or oversize `name` / `purpose`. */ ensureDmChannel(args: { agentSession: string; name?: string; purpose?: string; principal: ChatPrincipal; }): { channel: ChatChannelDTO; created: boolean; }; /** * Find-or-create the canonical `type='channel'` channel for a team. * * Backs the consolidated team-chat surface: opening a team's workspace * deep-link must always land on a real channel, but nothing else * auto-creates one. This is idempotent — if the team already has any * active channel it is returned untouched; otherwise a single default * channel (e.g. `#general`) is created. The same tenant/validation rules * as the public {@link createChannel} apply (the caller must pass team * membership when a `validateTeamMembership` check is wired). * * @param args - Ensure args * @param args.teamId - The team to ensure a channel for (required) * @param args.name - Display name for a freshly created channel; defaults to `#general` * @param args.purpose - Optional channel purpose * @param args.principal - The authenticated caller * @returns The existing or freshly created channel, with `created` flag * @throws {ChatError} `validation_error` (400) when `teamId` is blank * @throws {ChatError} `forbidden_team` (403) when the caller fails the membership check */ ensureTeamChannel(args: { teamId: string; name?: string; purpose?: string; principal: ChatPrincipal; }): { channel: ChatChannelDTO; created: boolean; }; /** * Server-internal idempotent helper used by migration / bridge code to * map a legacy conversationId (e.g. `slack-D0AC7-1234`, `web-conv-abc`) * onto a chat-v2 channel row with the conversationId as the primary key. * * Unlike {@link createChannel}, this method: * - Returns the existing channel when one already lives at the given * id — idempotent for runtimes that call it before every recordTurn. * - Accepts a synthetic owner (`'system'`) for paths where no human * principal is on the call stack (PTY finish hooks, Slack inbound * bridge, in-process runtime auto-route). * - Sets `type='dm'` so the channel matches the conversation-per-thread * legacy model the user approved (spec Option B). * * The `agent_already_bound` failure mode that existed in chat-v2 Phase A * does not apply — the `uq_channel_agent_dm_active` index was dropped * per the unified-chat-message-store spec exactly so this helper can * lazy-create N concurrent DM channels for a single agent. * * @param args - Legacy bridge args * @returns The existing or freshly created channel DTO * @throws {ChatError} `validation_error` on missing id / agentSession * * @example * ```typescript * // Called from `routeInProcessResponseToChat` before `recordTurn`: * const channel = chatV2.ensureChannelForLegacyConversation({ * conversationId: 'slack-D0AC7-1700000000.000111', * agentSession: 'crewly-orc', * }); * chatV2.recordTurn({ channelId: channel.id, ... }); * ``` */ ensureChannelForLegacyConversation(args: { conversationId: string; agentSession: string; /** Display name; defaults to the conversationId. */ name?: string; /** Owner id; defaults to `'system'` for server-internal callers. */ ownerUserId?: string; }): ChatChannelDTO; /** * Phase 5 of unified-chat-message-store spec — idempotent import of one * legacy conversation file (`~/.crewly/chat/.json`) * into the chat-v2 SQLite store. Each legacy message becomes one * `chat_messages` row keyed by a deterministic `clientMessageId` so * re-running the import is safe — the underlying `MessageStore` * dedups on `(channel_id, clientMessageId)`. * * Designed as a service method (not a free function) so the CLI * migration script can call it per file and so callers can unit-test * the mapping in isolation. * * The mapping: * - `conversation.id` → `chat_channels.id` (via * {@link ensureChannelForLegacyConversation}). `agentSession` * defaults to `'crewly-orc'` because every legacy conversation was * a DM between the user and the orchestrator. * - `messages[].from.type === 'user'` → `senderType: 'user'` * - `messages[].from.type === 'orchestrator'` (or 'agent') → * `senderType: 'agent'`, `senderId: 'crewly-orc'` * - Anything else → `senderType: 'system'`, `senderId: 'system'` * - `messages[].id` → `clientMessageId = 'legacy-' + msg.id` for * stable idempotency across re-runs. * - `messages[].metadata.source` (legacy slack/web) → carried * through; `recordTurn`'s required outer `metadata.source` is set * to `'system'` to identify the migration as the writer. * * @param input - Parsed legacy JSON (the entire file body) * @returns Per-message outcome (imported vs deduped) + the channel id * * @example * ```typescript * const json = JSON.parse(await readFile(filePath, 'utf-8')); * const result = chatV2.importLegacyConversation(json); * console.log(`Imported ${result.imported} new, ${result.deduped} dedup`); * ``` */ importLegacyConversation(input: { conversation: { id: string; }; messages: Array<{ id: string; from: { type: string; name?: string; }; content: string; contentType?: string; timestamp?: string; metadata?: Record; }>; }): { channelId: string; imported: number; deduped: number; skipped: number; }; /** * List channels owned by the caller. * * Phase C: extended with optional `type` + `teamId` filters so the * channel-rail can request a focused slice (e.g. "channels in this * workspace only") without paging the full owner-scoped list. An * unknown `type` value is rejected as `validation_error`. Blank * `teamId` is normalized to "no filter" so callers don't need to * sanitize empty query strings. * * @param args - List args (principal + optional filters) * @returns DTO-mapped channels * @throws {ChatError} `validation_error` (400) when `type` is set * to a value outside {@link CHAT_CHANNEL_TYPES}. */ listChannels(args: ListChannelsArgs): ChatChannelDTO[]; /** * Look up a single channel the caller owns. * * @param channelId - Channel id * @param principal - Auth principal * @returns DTO shape * @throws {ChatError} `channel_not_found` (404) if the caller doesn't own it */ getChannel(channelId: string, principal: ChatPrincipal): ChatChannelDTO; /** * Archive a channel. Returns true if a row was modified. * * @param channelId - Channel id * @param principal - Auth principal * @returns True if newly archived, false if already archived * @throws {ChatError} `channel_not_found` (404) */ archiveChannel(channelId: string, principal: ChatPrincipal): boolean; /** * Phase 6.0b — clear the `archived_at` flag on a channel. Inverse of * {@link archiveChannel}; required to retire the legacy * `unarchiveConversation` route. * * @param channelId - The channel to unarchive * @param principal - Auth principal (must own the channel) * @returns True if newly unarchived, false if already active * @throws {ChatError} `channel_not_found` (404) */ unarchiveChannel(channelId: string, principal: ChatPrincipal): boolean; /** * Phase 6.0b — rename a channel. Replaces the legacy * `updateConversationTitle` route. Server validates the same name * constraints as `createChannel`. Exposed as `PATCH /channels/:id` and the * `renameChannel` relay RPC for group chats (#1400 private channels). * * @param channelId - The channel to rename * @param name - New name (trimmed, ≤ maxChannelNameChars) * @param principal - Auth principal (must own the channel) * @returns The renamed channel DTO * @throws {ChatError} `validation_error` (400) on empty / oversize name, * `channel_not_found` (404) */ renameChannel(channelId: string, name: string, principal: ChatPrincipal): ChatChannelDTO; /** * Phase 6.0b — hard-delete a channel and all its messages. Distinct * from {@link archiveChannel} (soft delete). Replaces the legacy * `deleteConversation` route. Uses SQLite FK cascade so the row * deletion atomically removes child messages. * * @param channelId - The channel to delete * @param principal - Auth principal (must own the channel) * @returns True if removed, false if the channel didn't exist * @throws {ChatError} `channel_not_found` (404) */ deleteChannel(channelId: string, principal: ChatPrincipal): boolean; /** * Phase 6.0b — delete all messages in a channel while keeping the * channel row. Replaces the legacy `clearConversation` route. Useful * when the user wants a "fresh start" without losing the channel * itself (and its bookkeeping like `agent_session` binding). * * @param channelId - The channel to clear * @param principal - Auth principal (must own the channel) * @returns Number of messages deleted * @throws {ChatError} `channel_not_found` (404) */ clearChannel(channelId: string, principal: ChatPrincipal): number; /** * Phase 6.0b — count messages in a single channel. Replaces the * legacy `getMessageCount` filtered to one conversation. * * @param channelId - The channel to count * @param principal - Auth principal (must own the channel) * @returns Message count (0 for empty channels) * @throws {ChatError} `channel_not_found` (404) */ countChannelMessages(channelId: string, principal: ChatPrincipal): number; /** * Returns the text of recent OWNER-authored messages (`sender_type='user'`) * across ALL channels, newest first, created at or after `sinceMs`. * * Purpose: the Commitment Approval Guard (2026-06-02 autonomy incident) needs * an UNFAKEABLE signal of owner approval — the orchestrator can fabricate a * WorkItem title claiming "owner approved", but it cannot fabricate the * owner's actual chat history. This is a deliberate cross-channel read (no * per-channel authorization) used only server-side by the guard; it returns * message TEXT only, never agent/system messages. * * Channel-agnostic on purpose (#730): every surface that carries the owner's * words to the orchestrator — Chat UI, Slack DM / thread / team channel, * WhatsApp, Telegram, Google Chat, portal/mobile relay — records a `user` * row, so an approval given on any of them counts. What it filters on is * AUTHORSHIP, from the row's source metadata: a `user` row an agent wrote * (a colleague's Slack post, an agent-session API write, an agent-reply * source tag — see {@link OWNER_EVIDENCE_METADATA}) is excluded, as is a * row whose metadata cannot be parsed. Filtering happens in SQL so agent * chatter cannot push the owner's approval past `limit`. * * @param sinceMs - Unix epoch ms; only messages created at/after this are returned. * @param limit - Max messages to return (default 100, capped at 500). * @returns Owner message contents, newest first. */ getRecentOwnerMessageContents(sinceMs: number, limit?: number): string[]; /** * When the owner wrote, as epoch ms, newest first. Same owner filter as * {@link getRecentOwnerMessageContents} (agents quoting the owner or posting * through the reply tool are not the owner), but reads only the time, never * the text: for the owner active-minutes estimate (#1387). * * With `bucketMs`, returns one time per bucket the owner wrote in (the * bucket's start), so a busy fortnight is a few thousand rows, never cut off. * * @param sinceMs - Only messages created at/after this * @param limit - Max times returned (default 100, capped at 100000) * @param bucketMs - Optional bucket size: one time per bucket the owner wrote in * @returns Message times (or bucket starts), newest first */ getOwnerMessageTimes(sinceMs: number, limit?: number, bucketMs?: number): number[]; /** * Returns when the owner last spoke on a channel and when it was last * replied to by anyone else (agent/orc/system), as epoch ms. * * Purpose: the OrcDeliveryEnforcer (issue #731) must not demand a * deliverable in a Slack thread the owner abandoned long ago, and must stop * nagging once a reply has landed on the thread through ANY path — every * outbound Slack reply is mirrored here, so this is the single place that * knows. Server-internal read; no per-channel authorization. * * @param channelId - chat-v2 channel id (for Slack threads this is the * synthesized `slack--` conversation id). * @returns Both timestamps (`null` when no such row), or `null` when the * channel itself does not exist. */ getChannelActivity(channelId: string): { lastOwnerMessageAt: number | null; lastReplyAt: number | null; } | null; /** * Persist a message sent by the caller. The server decides `sender_type` * and `sender_id` from the auth principal; client fields are ignored. * * @param args - Send args * @returns The persisted message as a DTO (fresh or deduped) * @throws {ChatError} on validation, authorization, or size limit */ sendMessage(args: SendMessageArgs): ChatMessageDTO; /** * Canonical server-internal write entry for chat messages. * * Unlike {@link sendMessage}, which derives `sender_type` / `sender_id` * from an authenticated request principal, `recordTurn` is the path * used by runtimes, controllers, and bridges that have already * resolved exactly who the sender is — e.g.: * * - In-process agent runtime finishing a turn * - PTY runtime emitting a complete reply * - Slack inbound bridge persisting a user DM * - `/slack/send` controller persisting the agent's outbound reply * * Per spec `2026-05-14-unified-chat-message-store.md`, every chat * write in the system funnels through this method. No caller should * write to {@link MessageStore} directly; no caller should reach * into legacy {@link ChatService} (Phase 6 retires it). * * Idempotent via `clientMessageId` — the underlying store dedups by * `(channel_id, clientMessageId)` and returns the existing row with * `deduped=true` instead of inserting a duplicate. * * @param input - Turn payload (channel, sender, content, metadata) * @returns The persisted message DTO + dedupe flag * @throws {ChatError} `channel_not_found` (404) if the channel is missing * @throws {ChatError} `validation_error` (400) on empty content or invalid contentType * @throws {ChatError} `payload_too_large` (413) if content exceeds maxMessageBytes * * @example * ```typescript * const { message, deduped } = chatV2.recordTurn({ * channelId: 'slack-D0AC7-1234', * senderType: 'agent', * senderId: 'crewly-orc', * content: 'Hello!', * clientMessageId: 'agent-finish-2026-05-14T22:30:00Z', * metadata: { * source: 'in-process-runtime', * runtime: 'crewly-agent', * slackChannelId: 'D0AC7', * slackThreadTs: '1234', * }, * }); * ``` */ recordTurn(input: RecordTurnInput): RecordTurnResult; /** * Phase A — validate the mentions array passed to sendMessage. * Returns the cleaned array (or undefined when input is empty/missing). * * @param raw - The raw `mentions` field from the request body * @returns Validated mention IDs (caller passes to messageStore) * @throws {ChatError} `validation_error` (400) on type / size / count violation */ private validateMentions; /** * Phase A — validate the threadId passed to sendMessage. * * Returns the original threadId if it's a valid reference to a message * in the same channel, or undefined when input is empty/missing. * Refuses dangling references and cross-channel thread roots. * * @param raw - The raw `threadId` field from the request body * @param channelId - The channel this message will be inserted into * @returns Validated thread root id (caller passes to messageStore) * @throws {ChatError} `validation_error` (400) when invalid */ private validateThreadId; /** * Page messages for a channel the caller may read. * * @param args - List args * @returns A pagination envelope * @throws {ChatError} on auth or invalid cursor */ listMessages(args: ListMessagesArgs): ChatMessageListResult; /** Fetch the channel and enforce caller = owner. */ private requireOwnedChannel; /** * Fetch the channel and allow if the caller is the owner OR the bound agent * OR the channel is a shared bridged Slack conversation. * * Slack-bridged channels (`slack-…`, persisted under the synthetic `'system'` * owner by the inbound bridge) belong to no single Crewly user — exactly like * {@link ChannelStore.listBridged} surfaces them in the consolidated list * regardless of owner, this lets the web user READ them (e.g. the team-chat * surface merges them inline into the Orchestrator timeline). Writes are still * gated downstream by {@link resolveSender}, which throws `forbidden` unless * the caller is the owner or bound agent — so this read allowance does not let * a user post into a bridged channel. * * Used for read + send; agents must always be acting from their own session. */ private requireReadableChannel; /** Decide sender_type + sender_id for a message based on principal + channel. */ private resolveSender; /** * Mark a channel read up to `seq` (default: everything). Forward-only. * Emits `read_state` so every connected client converges. * * @param channelId - Channel id * @param principal - Auth principal (must be able to read the channel) * @param seq - Read up to this seq; omit for the channel's last message * @returns The new marker and unread count * @throws {ChatError} `validation_error` (400) on a non-integer / negative seq, * `channel_not_found` (404) */ markChannelRead(channelId: string, principal: ChatPrincipal, seq?: number): ChatReadStateDTO; /** * Mark the message at `seq` unread (marker = `seq - 1`, may move backward) so * the owner can keep it for later, even after replying. * * @param channelId - Channel id * @param principal - Auth principal (must be able to read the channel) * @param seq - `seq` of the message to mark unread (>= 1) * @returns The new marker and unread count * @throws {ChatError} `validation_error` (400) on a missing / invalid seq, * `channel_not_found` (404) */ markChannelUnread(channelId: string, principal: ChatPrincipal, seq: number): ChatReadStateDTO; /** * Unread summary across every channel the caller sees in the conversation * list (own + bridged, not archived). Only channels with unread are listed. * * @param principal - Auth principal * @returns Total unread and the per-channel breakdown */ getUnreadSummary(principal: ChatPrincipal): ChatUnreadSummaryDTO; /** Validate a seq argument: integer, >= `min`. */ private assertSeq; /** * Keep the read marker in step with a freshly persisted message: the owner's * own message advances it (forward-only); a message that counts as unread * announces the raised count. Both emit `read_state`. Never throws — read * state must not fail a message write. * * @param dto - The persisted message * @param ownerAuthored - True when the owner (not an agent writing as the owner) sent it */ private syncReadStateAfterMessage; /** Map a channel row + live presence into the wire DTO. */ private toChannelDTO; /** Map a message row to the wire DTO. Attachments passed in by the caller. */ private toMessageDTO; } /** * Encode a timeline position (created_at + rowid) as an opaque cursor. * * @param createdAt - Message created_at (ms) * @param rowid - Message rowid (tie-breaker) * @returns base64url cursor */ export declare function encodeTimelineCursor(createdAt: number, rowid: number): string; /** * Decode a timeline cursor. * * @param cursor - Cursor from {@link encodeTimelineCursor} * @returns The position * @throws {ChatError} `invalid_cursor` (400) when malformed */ export declare function decodeTimelineCursor(cursor: string): { createdAt: number; rowid: number; }; //# sourceMappingURL=chat-v2.service.d.ts.map