@ai-matrx/messaging 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1273 @@
1
+ import { ReactNode, SVGProps } from 'react';
2
+ import { MatrxTransport } from '@ai-matrx/agents/matrx';
3
+ import { RealtimeClientLike, RealtimeManager } from '@ai-matrx/realtime';
4
+
5
+ /**
6
+ * THE MESSAGING DOMAIN TYPES.
7
+ *
8
+ * Ported from `matrx-frontend/features/messaging/types.ts` with the coupling
9
+ * seams inverted: no imported host types, no app singletons. The DB-shaped
10
+ * types mirror `communication.dm_*` exactly (snake_case, nullable where the
11
+ * column is nullable) so a row read from PostgREST IS one of these without a
12
+ * mapping step that can silently drop a column.
13
+ *
14
+ * Strictness posture (C22): identity-bearing values are BRANDED, invalid states
15
+ * are unrepresentable (a `sending` message has a `clientMessageId`; a `failed`
16
+ * one carries a reason), and no surface here is `any`. The one honest `unknown`
17
+ * is the JSON boundary — `metadata` and `action_data` are host/DB-generated
18
+ * shapes this package does not own (the data 0.2.1 `Json = unknown` lesson).
19
+ */
20
+ /** Nominal id types. A conversation id can never be passed where a user id goes. */
21
+ declare const brand: unique symbol;
22
+ type Brand<T, B extends string> = T & {
23
+ readonly [brand]: B;
24
+ };
25
+ type ConversationId = Brand<string, "ConversationId">;
26
+ type MessageId = Brand<string, "MessageId">;
27
+ type UserId = Brand<string, "UserId">;
28
+ type OrganizationId = Brand<string, "OrganizationId">;
29
+ /** The client-minted idempotency key that makes a send exactly-once end to end. */
30
+ type ClientMessageId = Brand<string, "ClientMessageId">;
31
+ declare const asConversationId: (value: string) => ConversationId;
32
+ declare const asMessageId: (value: string) => MessageId;
33
+ declare const asUserId: (value: string) => UserId;
34
+ declare const asOrganizationId: (value: string) => OrganizationId;
35
+ declare const asClientMessageId: (value: string) => ClientMessageId;
36
+ type JsonValue = unknown;
37
+ type JsonObject = Readonly<Record<string, JsonValue>>;
38
+ type ConversationType = "direct" | "group" | "org";
39
+ type ParticipantRole = "owner" | "admin" | "member";
40
+ type MessageKind = "text" | "image" | "video" | "audio" | "file" | "system" | "action";
41
+ /**
42
+ * The delivery ladder. `sending` and `failed` exist only client-side: they are
43
+ * the outbox's states, and the DB's `status` column never holds them.
44
+ */
45
+ type DeliveryState = "sending" | "sent" | "delivered" | "read" | "failed";
46
+ interface Conversation {
47
+ readonly id: ConversationId;
48
+ readonly type: ConversationType;
49
+ readonly groupName: string | null;
50
+ readonly groupImageUrl: string | null;
51
+ readonly createdBy: UserId | null;
52
+ readonly organizationId: OrganizationId;
53
+ readonly createdAt: string;
54
+ readonly updatedAt: string;
55
+ readonly metadata: JsonObject;
56
+ }
57
+ interface Participant {
58
+ readonly conversationId: ConversationId;
59
+ readonly userId: UserId;
60
+ readonly role: ParticipantRole;
61
+ readonly joinedAt: string | null;
62
+ readonly lastReadAt: string | null;
63
+ readonly isMuted: boolean;
64
+ readonly isArchived: boolean;
65
+ }
66
+ interface UserSummary {
67
+ readonly userId: UserId;
68
+ readonly displayName: string;
69
+ readonly email: string | null;
70
+ readonly avatarUrl: string | null;
71
+ /** True when this participant is an AI agent rather than a person (R4). */
72
+ readonly isAgent: boolean;
73
+ }
74
+ /**
75
+ * A structured action carried by a message. THE SEAM other packages extend —
76
+ * `@ai-matrx/meet` puts a call invitation here rather than inventing a message
77
+ * type (D1).
78
+ *
79
+ * `version` is not decoration: a renderer that does not understand a version
80
+ * renders NOTHING rather than guessing, which is what lets a new sender ship
81
+ * before every reader has caught up.
82
+ */
83
+ interface MessageAction<TKind extends string = string, TPayload = JsonObject> {
84
+ readonly kind: TKind;
85
+ readonly version: number;
86
+ readonly payload: TPayload;
87
+ }
88
+ /**
89
+ * A typed reference to a platform entity, rendered as a live openable card.
90
+ * NO DEAD ENDS: everything a message names must open, so a reference always
91
+ * carries enough to resolve — never a bare label.
92
+ */
93
+ interface MatrxReference {
94
+ readonly entityType: string;
95
+ readonly entityId: string;
96
+ readonly label: string;
97
+ readonly href?: string | undefined;
98
+ }
99
+ interface Attachment {
100
+ /** A DURABLE file ref, never a signed URL. Signed URLs expire; identities do not. */
101
+ readonly fileId: string;
102
+ readonly fileName: string;
103
+ readonly mimeType: string | null;
104
+ readonly sizeBytes: number | null;
105
+ readonly width: number | null;
106
+ readonly height: number | null;
107
+ }
108
+ interface Message {
109
+ readonly id: MessageId;
110
+ readonly conversationId: ConversationId;
111
+ readonly senderId: UserId;
112
+ readonly organizationId: OrganizationId;
113
+ readonly content: string;
114
+ readonly kind: MessageKind;
115
+ readonly deliveryState: DeliveryState;
116
+ readonly replyToId: MessageId | null;
117
+ readonly clientMessageId: ClientMessageId | null;
118
+ readonly createdAt: string;
119
+ readonly editedAt: string | null;
120
+ readonly deletedAt: string | null;
121
+ readonly deletedForEveryone: boolean;
122
+ readonly action: MessageAction | null;
123
+ readonly attachments: readonly Attachment[];
124
+ readonly references: readonly MatrxReference[];
125
+ readonly metadata: JsonObject;
126
+ /** Set only while this message is in the outbox and its last send failed. */
127
+ readonly failureReason?: string | undefined;
128
+ }
129
+ interface ConversationSummary {
130
+ readonly conversation: Conversation;
131
+ readonly participants: readonly UserSummary[];
132
+ readonly lastMessageContent: string | null;
133
+ readonly lastMessageSenderId: UserId | null;
134
+ readonly lastMessageAt: string | null;
135
+ readonly unreadCount: number;
136
+ readonly isMuted: boolean;
137
+ readonly isArchived: boolean;
138
+ /** Resolved for display: the group name, or the other participant's name. */
139
+ readonly displayName: string;
140
+ readonly displayImageUrl: string | null;
141
+ /** The keyset sort value. Pagination cursors are built from this + `id`. */
142
+ readonly sortAt: string;
143
+ }
144
+ /**
145
+ * THE PAGINATION CURSOR. Stable ordering is terminated by a UNIQUE column
146
+ * (R5 scale honesty): ordering by a timestamp alone silently duplicates or
147
+ * skips rows whenever two rows share a millisecond, which in a large org is
148
+ * every page.
149
+ */
150
+ interface ConversationCursor {
151
+ readonly beforeSortAt: string;
152
+ readonly beforeConversationId: ConversationId;
153
+ }
154
+ interface MessageCursor {
155
+ readonly beforeCreatedAt: string;
156
+ readonly beforeMessageId: MessageId;
157
+ }
158
+ interface Page<TItem, TCursor> {
159
+ readonly items: readonly TItem[];
160
+ readonly nextCursor: TCursor | null;
161
+ readonly hasMore: boolean;
162
+ }
163
+ /** What the host injects. Identity ONLY — every hard part is in the package. */
164
+ interface MessagingIdentity {
165
+ readonly userId: UserId;
166
+ readonly organizationId: OrganizationId;
167
+ }
168
+ interface DraftMessage {
169
+ readonly conversationId: ConversationId;
170
+ readonly content: string;
171
+ readonly kind?: MessageKind | undefined;
172
+ readonly replyToId?: MessageId | undefined;
173
+ readonly action?: MessageAction | undefined;
174
+ readonly attachments?: readonly Attachment[] | undefined;
175
+ readonly references?: readonly MatrxReference[] | undefined;
176
+ readonly metadata?: JsonObject | undefined;
177
+ }
178
+
179
+ /**
180
+ * ACTIONABLE MESSAGES — a message that carries something you can DO.
181
+ *
182
+ * A registry, never a `switch`. Two properties come from that choice, and both
183
+ * are load-bearing:
184
+ *
185
+ * - **Forward compatibility.** An unknown `kind` — or a known kind at a version
186
+ * this build does not understand — renders NOTHING and executes nothing. A
187
+ * `switch` with a `default: throw` means the day a new sender ships, every
188
+ * older reader's thread breaks. Silently rendering an unknown payload with a
189
+ * generic button is worse: it offers an action nobody can honor.
190
+ * - **Extensibility across packages.** `@ai-matrx/meet` registers its call
191
+ * invitation here rather than this package learning about meetings (D1).
192
+ *
193
+ * 🚨 **IDEMPOTENCE IS ENFORCED HERE, NOT PROMISED BY HANDLERS.** Two tabs, a
194
+ * double-click, and a retried tap must apply an action ONCE. The registry
195
+ * de-duplicates by `(kind, messageId, actorId)`: a second execution while the
196
+ * first is in flight AWAITS it and returns the same receipt, and a settled
197
+ * action returns its stored receipt without touching the server.
198
+ *
199
+ * 🚨 **THE PAYLOAD IS NOT AUTHORIZATION.** A handler must re-resolve the
200
+ * durable, caller-authorized request row server-side before it writes. The
201
+ * message's `action_data` says what was ASKED, never what is ALLOWED — trusting
202
+ * it turns any message into a privilege grant. Handlers declare this by
203
+ * construction: they receive the payload and the actor, and must go to the
204
+ * database themselves.
205
+ */
206
+
207
+ type ActionOutcome = "applied" | "already" | "declined" | "unavailable";
208
+ interface ActionReceipt {
209
+ readonly kind: string;
210
+ readonly messageId: MessageId;
211
+ readonly actorId: UserId;
212
+ readonly outcome: ActionOutcome;
213
+ readonly label: string;
214
+ readonly settledAt: string;
215
+ readonly detail?: string | undefined;
216
+ }
217
+ interface ActionContext {
218
+ readonly messageId: MessageId;
219
+ readonly actorId: UserId;
220
+ readonly organizationId: string;
221
+ /** Which of the choices the user picked, e.g. `approve` / `decline`. */
222
+ readonly choice: string;
223
+ }
224
+ interface ActionChoice {
225
+ readonly id: string;
226
+ readonly label: string;
227
+ readonly tone: "primary" | "neutral" | "danger";
228
+ }
229
+ interface ActionHandler<TPayload = JsonObject> {
230
+ readonly kind: string;
231
+ /** Versions this build understands. An unlisted version renders nothing. */
232
+ readonly versions: readonly number[];
233
+ /** What the chips say. Return `[]` to render the action as read-only. */
234
+ choices: (payload: TPayload) => readonly ActionChoice[];
235
+ /** One-line summary for the inbox preview and notifications. */
236
+ summarize?: (payload: TPayload) => string;
237
+ /**
238
+ * Do the thing. MUST re-resolve the authorizing row server-side; the payload
239
+ * is the request, not the permission. Returning `already` is how a handler
240
+ * reports that the server had settled it — the registry treats it as success.
241
+ */
242
+ execute: (payload: TPayload, context: ActionContext) => Promise<ActionReceipt>;
243
+ }
244
+ interface ActionRegistry {
245
+ register<TPayload = JsonObject>(handler: ActionHandler<TPayload>): void;
246
+ /** The handler for an action, or null when this build cannot honor it. */
247
+ resolve(action: MessageAction): ActionHandler | null;
248
+ choicesFor(action: MessageAction): readonly ActionChoice[];
249
+ summarize(action: MessageAction): string | null;
250
+ /** Idempotent execution. Concurrent callers share one in-flight result. */
251
+ execute(action: MessageAction, context: ActionContext): Promise<ActionReceipt>;
252
+ /** The stored receipt for a settled action, if there is one. */
253
+ receiptFor(kind: string, messageId: MessageId, actorId: UserId): ActionReceipt | null;
254
+ /** Record a receipt observed from another client's broadcast. */
255
+ observeReceipt(receipt: ActionReceipt): void;
256
+ known(): readonly string[];
257
+ }
258
+ declare function createActionRegistry(): ActionRegistry;
259
+
260
+ /**
261
+ * AI-NATIVE, OUT OF THE BOX (R4).
262
+ *
263
+ * Four conversation intelligences — catch me up, summarize, extract action
264
+ * items, draft a reply — plus agents as participants. All of it executes on the
265
+ * platform's agent system through `@ai-matrx/agents`; this package never talks
266
+ * to a model, never holds a prompt, and never carries an agent definition.
267
+ *
268
+ * 🚨 **THE USER-INPUT LAW.** `user_input` is what a HUMAN typed. Machine
269
+ * content — a transcript, a participant roster, a cutoff timestamp — travels as
270
+ * NAMED VARIABLES. This is not style: the server treats `user_input` as the
271
+ * turn's human utterance (it is stored as such, shown as such, and shapes the
272
+ * agent's framing), so smuggling a transcript through it silently corrupts
273
+ * every conversation it touches. Every call below sends `variables` and either
274
+ * a short human-shaped `user_input` or none.
275
+ *
276
+ * 🚨 **AGENT DEFINITIONS LIVE IN THE DATABASE.** The package takes agent IDs as
277
+ * injected identity. When an id is not configured the capability reports
278
+ * `unavailable` WITH the remedy — it never silently no-ops, and the UI hides
279
+ * the action rather than offering a dead button (no dead ends).
280
+ */
281
+
282
+ /**
283
+ * Which platform agent backs each capability. Every field is a DATABASE row id
284
+ * supplied by the host; an omitted one disables exactly that capability.
285
+ */
286
+ interface MessagingAgents {
287
+ readonly catchUp?: string | undefined;
288
+ readonly summarize?: string | undefined;
289
+ readonly actionItems?: string | undefined;
290
+ readonly draftReply?: string | undefined;
291
+ }
292
+ interface MessagingAiOptions {
293
+ transport: MatrxTransport;
294
+ organizationId: string;
295
+ agents: MessagingAgents;
296
+ /** Stable source slugs so server-side analytics can see where a run came from. */
297
+ sourceApp?: string | undefined;
298
+ sourceFeature?: string | undefined;
299
+ /** Cap on how much transcript is sent. Default 200 messages. */
300
+ maxTranscriptMessages?: number | undefined;
301
+ }
302
+ type AiCapability = keyof MessagingAgents;
303
+ interface AiResult {
304
+ readonly capability: AiCapability;
305
+ readonly text: string;
306
+ readonly conversationId: string | null;
307
+ }
308
+ interface MessagingAi {
309
+ /** Which capabilities this host actually configured. Drives what the UI offers. */
310
+ available(): readonly AiCapability[];
311
+ isAvailable(capability: AiCapability): boolean;
312
+ catchMeUp(args: {
313
+ conversationId: ConversationId;
314
+ messages: readonly Message[];
315
+ participants: readonly UserSummary[];
316
+ since: string | null;
317
+ signal?: AbortSignal;
318
+ }): Promise<AiResult>;
319
+ summarize(args: {
320
+ conversationId: ConversationId;
321
+ messages: readonly Message[];
322
+ participants: readonly UserSummary[];
323
+ signal?: AbortSignal;
324
+ }): Promise<AiResult>;
325
+ extractActionItems(args: {
326
+ conversationId: ConversationId;
327
+ messages: readonly Message[];
328
+ participants: readonly UserSummary[];
329
+ signal?: AbortSignal;
330
+ }): Promise<AiResult>;
331
+ draftReply(args: {
332
+ conversationId: ConversationId;
333
+ messages: readonly Message[];
334
+ participants: readonly UserSummary[];
335
+ /** What the human asked for, if anything — this IS a human utterance. */
336
+ instruction?: string | undefined;
337
+ signal?: AbortSignal;
338
+ }): Promise<AiResult>;
339
+ }
340
+ declare function createMessagingAi(options: MessagingAiOptions): MessagingAi;
341
+
342
+ /**
343
+ * THE OUTBOX — a message you typed is NEVER lost (R2, table-stakes law).
344
+ *
345
+ * This is the piece hosts always get wrong, so it is entirely in the package
346
+ * (C22). What "never lost" actually requires, and what each part defends:
347
+ *
348
+ * - **Durability across a reload.** The draft is persisted BEFORE the network
349
+ * call, not after it succeeds. A tab closed mid-send comes back with the
350
+ * message still queued. The storage port has a working default; a host that
351
+ * injects nothing still gets in-memory queuing, and it SAYS so rather than
352
+ * pretending to be durable.
353
+ * - **Exactly-once on retry.** Every entry carries a client-minted
354
+ * `clientMessageId` that is generated ONCE and reused by every attempt. A
355
+ * retry after a response that was actually delivered is deduped by the
356
+ * database's idempotency key instead of posting the message twice.
357
+ * - **Order.** Entries for one conversation send strictly in order; message 2
358
+ * never overtakes a retrying message 1.
359
+ * - **Bounded, honest failure.** Attempts back off; after `maxAttempts` the
360
+ * entry stays in the queue marked `failed` WITH a reason and a retry door.
361
+ * It is never silently dropped, and it never retries forever.
362
+ */
363
+
364
+ interface OutboxEntry {
365
+ readonly id: string;
366
+ readonly clientMessageId: ClientMessageId;
367
+ readonly draft: DraftMessage;
368
+ readonly queuedAt: number;
369
+ readonly attempts: number;
370
+ readonly state: "queued" | "sending" | "failed";
371
+ readonly failureReason: string | null;
372
+ }
373
+ /**
374
+ * Persistence for pending sends. A default is always supplied — the package
375
+ * never leaves a port empty (THE ALL-INCLUSIVE LAW).
376
+ */
377
+ interface OutboxStorage {
378
+ /** Human-readable, shown in diagnostics so "durable" is never assumed. */
379
+ readonly name: string;
380
+ readonly durable: boolean;
381
+ load(): readonly OutboxEntry[];
382
+ save(entries: readonly OutboxEntry[]): void;
383
+ }
384
+ declare function createMemoryOutboxStorage(): OutboxStorage;
385
+ interface WebStorageLike {
386
+ getItem(key: string): string | null;
387
+ setItem(key: string, value: string): void;
388
+ }
389
+ /**
390
+ * The real default in a browser. Falls back to memory — announcing itself —
391
+ * when storage is unavailable (private mode, a disabled-cookies browser, SSR).
392
+ */
393
+ declare function createWebOutboxStorage(args: {
394
+ key?: string;
395
+ storage?: WebStorageLike | null;
396
+ onFallback?: (reason: string) => void;
397
+ }): OutboxStorage;
398
+ interface OutboxOptions {
399
+ /** Actually send. Returns the persisted message. */
400
+ send: (draft: DraftMessage, clientMessageId: ClientMessageId) => Promise<Message>;
401
+ /** Called when an entry lands. The store collapses it with the optimistic row. */
402
+ onSent: (entry: OutboxEntry, message: Message) => void;
403
+ onChange: (entries: readonly OutboxEntry[]) => void;
404
+ onDiagnostic?: (message: string) => void;
405
+ storage?: OutboxStorage;
406
+ maxAttempts?: number;
407
+ /** Backoff schedule per attempt, ms. The last value repeats. */
408
+ backoffMs?: readonly number[];
409
+ timers?: {
410
+ setTimeout: (run: () => void, ms: number) => unknown;
411
+ clearTimeout: (handle: unknown) => void;
412
+ now: () => number;
413
+ };
414
+ }
415
+ interface Outbox {
416
+ entries(): readonly OutboxEntry[];
417
+ /** Queue a draft. Returns the id the optimistic message must carry. */
418
+ enqueue(draft: DraftMessage): ClientMessageId;
419
+ /** Retry one failed entry, on the user's explicit ask. */
420
+ retry(entryId: string): void;
421
+ /** Drop one entry — the only sanctioned way a typed message leaves the queue. */
422
+ discard(entryId: string): void;
423
+ /** Network came back / tab woke: try everything that is waiting. */
424
+ flush(): void;
425
+ pendingFor(conversationId: ConversationId): readonly OutboxEntry[];
426
+ dispose(): void;
427
+ }
428
+ declare function createOutbox(options: OutboxOptions): Outbox;
429
+
430
+ /**
431
+ * THE STRUCTURAL SUPABASE SHAPE.
432
+ *
433
+ * D2 says messaging is Supabase-locked — there is no provider abstraction and
434
+ * never will be. That is a statement about the CONTRACT, not about the import
435
+ * graph: this package still accepts the client STRUCTURALLY and never imports
436
+ * `@supabase/supabase-js` at runtime.
437
+ *
438
+ * Two reasons, both learned the hard way across the fleet:
439
+ *
440
+ * 1. A host that ships two copies of `@supabase/supabase-js` (the Next.js
441
+ * server/browser split is the usual way) would get two different `Session`
442
+ * identities and a package that refuses the host's own client.
443
+ * 2. `supabase-js` is a heavy dependency with its own release train; a package
444
+ * that pins one is a version-drift generator (THE LATEST LAW's failure mode
445
+ * in miniature).
446
+ *
447
+ * `supabase-shape.test.ts` proves at COMPILE TIME that a real `SupabaseClient`
448
+ * satisfies these interfaces. If that file stops compiling, the interface here
449
+ * is wrong — fix it here, never with a cast at the call site, and never by
450
+ * asking the consumer to adapt.
451
+ */
452
+
453
+ /** What PostgREST hands back. `error` is opaque on purpose — we normalize it. */
454
+ interface PostgrestLikeResponse<T> {
455
+ data: T | null;
456
+ error: {
457
+ message: string;
458
+ code?: string;
459
+ details?: string | null;
460
+ } | null;
461
+ }
462
+ interface PostgrestFilterLike<TRow> extends PromiseLike<PostgrestLikeResponse<TRow[]>> {
463
+ eq(column: string, value: unknown): PostgrestFilterLike<TRow>;
464
+ neq(column: string, value: unknown): PostgrestFilterLike<TRow>;
465
+ in(column: string, values: readonly unknown[]): PostgrestFilterLike<TRow>;
466
+ is(column: string, value: unknown): PostgrestFilterLike<TRow>;
467
+ lt(column: string, value: unknown): PostgrestFilterLike<TRow>;
468
+ gt(column: string, value: unknown): PostgrestFilterLike<TRow>;
469
+ or(filters: string): PostgrestFilterLike<TRow>;
470
+ order(column: string, options?: {
471
+ ascending?: boolean;
472
+ }): PostgrestFilterLike<TRow>;
473
+ limit(count: number): PostgrestFilterLike<TRow>;
474
+ select(columns?: string): PostgrestFilterLike<TRow>;
475
+ single(): PromiseLike<PostgrestLikeResponse<TRow>>;
476
+ maybeSingle(): PromiseLike<PostgrestLikeResponse<TRow>>;
477
+ }
478
+ interface PostgrestTableLike<TRow> {
479
+ select(columns?: string): PostgrestFilterLike<TRow>;
480
+ insert(values: unknown): PostgrestFilterLike<TRow>;
481
+ update(values: unknown): PostgrestFilterLike<TRow>;
482
+ upsert(values: unknown, options?: {
483
+ onConflict?: string;
484
+ }): PostgrestFilterLike<TRow>;
485
+ delete(): PostgrestFilterLike<TRow>;
486
+ }
487
+ interface SchemaLike {
488
+ from<TRow = Record<string, unknown>>(table: string): PostgrestTableLike<TRow>;
489
+ rpc<TResult = unknown>(fn: string, args?: Record<string, unknown>): PromiseLike<PostgrestLikeResponse<TResult>>;
490
+ }
491
+ /**
492
+ * The subset of the Supabase client this package uses. `schema()` matters:
493
+ * every messaging table lives in the `communication` schema, never `public`
494
+ * (the platform's canonical DB conventions, R8).
495
+ */
496
+ interface SupabaseLike extends SchemaLike {
497
+ schema(name: string): SchemaLike;
498
+ }
499
+ /**
500
+ * What `<MessagingProvider client={...}>` actually needs: the PostgREST surface
501
+ * above AND the realtime surface `@ai-matrx/realtime` accepts. A real
502
+ * `SupabaseClient` satisfies both, which is the point — the host passes the one
503
+ * client it already has, and the package never casts to make it fit.
504
+ */
505
+ type MessagingSupabaseClient = SupabaseLike & RealtimeClientLike;
506
+
507
+ /**
508
+ * THE DATA CONTRACT — the ONE place a messaging table or RPC name exists.
509
+ *
510
+ * Every name below was verified against Matrx Main (`https://db.matrxserver.com`)
511
+ * on 2026-08-31, not guessed from a client. `matrx-dm` grew a parallel schema in
512
+ * its own project (`public.conversations` / `messages` / `message_reactions`);
513
+ * per R8 exactly ONE canonical schema survives and it is this one — the
514
+ * platform-conventional `communication.dm_*` tables with explicit
515
+ * `organization_id`, `version`, and soft-delete columns, behind auth-checked
516
+ * SECURITY DEFINER RPCs.
517
+ *
518
+ * Four rules hold this file together:
519
+ *
520
+ * 1. **Org is explicit on every write (R5).** Not defaulted by a trigger we
521
+ * hope fires, not inherited — passed, and refused in-package when absent.
522
+ * 2. **Authorization is the database's job.** There is no permission branch in
523
+ * this file. An RPC that says no is a `forbidden` MessagingError, never a
524
+ * client-side re-decision.
525
+ * 3. **Direct conversations are created ATOMICALLY, by RPC.** The banned
526
+ * pattern — read `find_dm_direct_conversation`, then insert — races two
527
+ * tabs into two conversations for the same pair. The RPC advisory-locks the
528
+ * unordered pair. Nothing here may reintroduce the read-then-insert shape.
529
+ * 4. **Pagination is keyset and terminated by a unique column.** Ordering by a
530
+ * timestamp alone duplicates or skips rows whenever two share a millisecond
531
+ * — which in a large org is every page.
532
+ */
533
+
534
+ /** The schema. Messaging is never in `public`. */
535
+ declare const MESSAGING_SCHEMA = "communication";
536
+ declare const TABLES: {
537
+ readonly conversations: "dm_conversations";
538
+ readonly participants: "dm_conversation_participants";
539
+ readonly messages: "dm_messages";
540
+ };
541
+ declare const RPCS: {
542
+ /** Atomic direct-conversation creation. Advisory-locks the unordered pair. */
543
+ readonly getOrCreateDirect: "dm_get_or_create_direct_conversation";
544
+ /** Conversation list + participants + last message + unread, keyset paged. */
545
+ readonly conversationsWithDetails: "get_dm_conversations_with_details";
546
+ readonly unreadCount: "get_dm_unread_count";
547
+ readonly userInfo: "get_dm_user_info";
548
+ readonly isParticipant: "is_dm_participant";
549
+ };
550
+ /**
551
+ * The host's session source. A messaging read that lands between sign-in and
552
+ * token arrival must not become a red error — it retries ONCE after the host
553
+ * resolves a session, then reports `session-unavailable`.
554
+ */
555
+ type SessionResolver = () => Promise<unknown>;
556
+ interface RepositoryOptions {
557
+ client: SupabaseLike;
558
+ identity: MessagingIdentity;
559
+ /** Called once before a retry when a read fails with a missing session. */
560
+ resolveSession?: SessionResolver | undefined;
561
+ /** Profile-lookup cache TTL. Default 5 minutes (the frontend's proven value). */
562
+ userTtlMs?: number | undefined;
563
+ now?: (() => number) | undefined;
564
+ }
565
+ interface MessagingRepository {
566
+ readonly identity: MessagingIdentity;
567
+ listConversations(args?: {
568
+ limit?: number;
569
+ cursor?: ConversationCursor | null;
570
+ }): Promise<Page<ConversationSummary, ConversationCursor>>;
571
+ getConversation(id: ConversationId): Promise<Conversation>;
572
+ listMessages(conversationId: ConversationId, args?: {
573
+ limit?: number;
574
+ cursor?: MessageCursor | null;
575
+ }): Promise<Page<Message, MessageCursor>>;
576
+ /** Messages created strictly after `since` — the reconnect backfill read. */
577
+ messagesSince(conversationId: ConversationId, since: string): Promise<readonly Message[]>;
578
+ getOrCreateDirectConversation(otherUserId: UserId): Promise<ConversationId>;
579
+ createGroupConversation(args: {
580
+ name: string;
581
+ memberIds: readonly UserId[];
582
+ }): Promise<ConversationId>;
583
+ insertMessage(draft: DraftMessage, clientMessageId: ClientMessageId): Promise<Message>;
584
+ editMessage(id: MessageId, content: string): Promise<Message>;
585
+ deleteMessage(id: MessageId, forEveryone: boolean): Promise<void>;
586
+ markRead(conversationId: ConversationId, at?: string): Promise<void>;
587
+ setConversationFlags(conversationId: ConversationId, flags: {
588
+ isMuted?: boolean;
589
+ isArchived?: boolean;
590
+ }): Promise<void>;
591
+ addMembers(conversationId: ConversationId, memberIds: readonly UserId[]): Promise<void>;
592
+ removeMember(conversationId: ConversationId, memberId: UserId): Promise<void>;
593
+ setMemberRole(conversationId: ConversationId, memberId: UserId, role: ParticipantRole): Promise<void>;
594
+ /** Cached + in-flight-deduped. N callers for one id make ONE request. */
595
+ getUser(userId: UserId): Promise<UserSummary | null>;
596
+ getUsers(userIds: readonly UserId[]): Promise<ReadonlyMap<UserId, UserSummary>>;
597
+ searchMessages(args: {
598
+ query: string;
599
+ conversationId?: ConversationId | null;
600
+ limit?: number;
601
+ }): Promise<readonly Message[]>;
602
+ invalidateUser(userId: UserId): void;
603
+ }
604
+ declare function createMessagingRepository(options: RepositoryOptions): MessagingRepository;
605
+
606
+ /**
607
+ * THE MESSAGE STORE — where "the same message twice" is made impossible.
608
+ *
609
+ * A message can reach this store by FOUR different paths, often in any order:
610
+ * the optimistic bubble, the insert's own response, the broadcast, and the
611
+ * Postgres Changes row. Every duplicate bug in every chat product is one of
612
+ * those four arriving out of order. The rules here, each with a regression test:
613
+ *
614
+ * 1. **Identity is `id` OR `clientMessageId`.** The confirmed row and the
615
+ * optimistic bubble are the SAME message; the client-minted key is what
616
+ * proves it. Matching on `(sender, content)` — the tempting shortcut — merges
617
+ * two genuinely different "ok" messages into one.
618
+ * 2. **Merge collapses; it never appends.** A confirmed row that finds an
619
+ * optimistic twin REPLACES it in place, keeping the twin's position so the
620
+ * bubble does not jump.
621
+ * 3. **Older never overwrites newer.** An out-of-order UPDATE carrying an older
622
+ * `edited_at`/`created_at` than the held copy is DROPPED. Without this an
623
+ * edit visibly reverts itself a second later.
624
+ * 4. **Order is `created_at` then `id`.** The unique tiebreaker is not
625
+ * decoration: two messages in the same millisecond otherwise swap places on
626
+ * every re-render.
627
+ * 5. **The active conversation's unread count is forced to zero.** A stale
628
+ * server count must never re-badge a conversation the user is reading — a
629
+ * safety net the origin needed in three separate places, so it lives in one
630
+ * place here.
631
+ */
632
+
633
+ interface ConversationThread {
634
+ readonly conversationId: ConversationId;
635
+ readonly messages: readonly Message[];
636
+ readonly hasMoreOlder: boolean;
637
+ /** The newest `created_at` held — the reconnect backfill's high-water mark. */
638
+ readonly latestAt: string | null;
639
+ }
640
+ interface MessagingSnapshot {
641
+ readonly conversations: readonly ConversationSummary[];
642
+ readonly hasMoreConversations: boolean;
643
+ /**
644
+ * True once the inbox has actually been READ at least once.
645
+ *
646
+ * An empty list means two completely different things — "you have no
647
+ * conversations" and "we have not looked yet" — and a UI that cannot tell
648
+ * them apart shows a confident "No conversations yet" to someone who has
649
+ * hundreds. That is a screen telling a lie, so the distinction is a fact the
650
+ * store carries rather than something each surface guesses from a timer.
651
+ */
652
+ readonly hasLoadedConversations: boolean;
653
+ readonly threads: ReadonlyMap<ConversationId, ConversationThread>;
654
+ readonly activeConversationId: ConversationId | null;
655
+ /** Conversations WITH unread, not total unread messages (the origin's semantics). */
656
+ readonly totalUnreadConversations: number;
657
+ }
658
+ interface MessagingStore {
659
+ snapshot(): MessagingSnapshot;
660
+ subscribe(listener: (snapshot: MessagingSnapshot) => void): () => void;
661
+ setConversations(items: readonly ConversationSummary[], hasMore: boolean): void;
662
+ appendConversations(items: readonly ConversationSummary[], hasMore: boolean): void;
663
+ upsertConversation(item: ConversationSummary): void;
664
+ removeConversation(id: ConversationId): void;
665
+ setActiveConversation(id: ConversationId | null): void;
666
+ setThread(id: ConversationId, messages: readonly Message[], args?: {
667
+ hasMoreOlder?: boolean;
668
+ }): void;
669
+ prependOlder(id: ConversationId, messages: readonly Message[], hasMoreOlder: boolean): void;
670
+ /** The one door every incoming message uses. Returns what actually happened. */
671
+ ingest(message: Message): "added" | "merged" | "dropped-stale" | "dropped-duplicate";
672
+ ingestMany(messages: readonly Message[]): void;
673
+ removeMessage(conversationId: ConversationId, messageId: MessageId): void;
674
+ markConversationRead(id: ConversationId): void;
675
+ setUnreadCount(id: ConversationId, count: number): void;
676
+ }
677
+ declare function createMessagingStore(): MessagingStore;
678
+ /**
679
+ * The optimistic row a composer shows the instant Enter is pressed. It carries
680
+ * the outbox's client key, which is the whole reason the confirmed row can find
681
+ * and replace it rather than appearing beside it.
682
+ */
683
+ declare function optimisticMessage(args: {
684
+ conversationId: ConversationId;
685
+ senderId: UserId;
686
+ organizationId: Message["organizationId"];
687
+ content: string;
688
+ clientMessageId: ClientMessageId;
689
+ kind?: Message["kind"];
690
+ replyToId?: MessageId | null;
691
+ action?: Message["action"];
692
+ attachments?: readonly Message["attachments"][number][];
693
+ references?: readonly Message["references"][number][];
694
+ now?: () => number;
695
+ }): Message;
696
+
697
+ /**
698
+ * THE MESSAGING ENGINE — the object a host mounts once and never reasons about.
699
+ *
700
+ * It owns the wiring that every chat implementation gets wrong: which channel
701
+ * carries what, what happens on reconnect, which of four arrival paths wins,
702
+ * when a read receipt is written, and what a failed send does. All of it is
703
+ * here so a consumer's job is `engine.send(draft)` and rendering a snapshot.
704
+ *
705
+ * THE RECONNECT RULE, inherited from `@ai-matrx/realtime` and honored here:
706
+ * realtime has NO REPLAY. Every channel this engine opens declares an
707
+ * `onBackfill` door, and those doors re-read from the database. A reconnect
708
+ * without a re-read leaves a permanently wrong thread that looks perfectly
709
+ * healthy — the single most expensive bug class in messaging.
710
+ */
711
+
712
+ interface EngineDiagnostic {
713
+ readonly level: "info" | "warn" | "error";
714
+ readonly message: string;
715
+ readonly remedy?: string | undefined;
716
+ }
717
+ interface MessagingEngineOptions {
718
+ repository: MessagingRepository;
719
+ manager: RealtimeManager;
720
+ identity: MessagingIdentity;
721
+ outboxStorage?: OutboxStorage | undefined;
722
+ onDiagnostic?: ((event: EngineDiagnostic) => void) | undefined;
723
+ /** Fired for a message from someone else, for notification sinks. */
724
+ onIncoming?: ((message: Message) => void) | undefined;
725
+ conversationPageSize?: number | undefined;
726
+ messagePageSize?: number | undefined;
727
+ }
728
+ interface MessagingEngine {
729
+ readonly store: MessagingStore;
730
+ readonly outbox: Outbox;
731
+ readonly identity: MessagingIdentity;
732
+ /** Load page one of the inbox and start the inbox channel. */
733
+ start(): Promise<void>;
734
+ loadMoreConversations(): Promise<void>;
735
+ /** Open a conversation: load its thread and subscribe to its channel. */
736
+ openConversation(id: ConversationId): Promise<void>;
737
+ closeConversation(id: ConversationId): void;
738
+ loadOlderMessages(id: ConversationId): Promise<void>;
739
+ /** Queue a message. Returns immediately with the optimistic row on screen. */
740
+ send(draft: DraftMessage): ClientMessageId;
741
+ retry(entryId: string): void;
742
+ discard(entryId: string): void;
743
+ editMessage(id: MessageId, conversationId: ConversationId, content: string): Promise<void>;
744
+ deleteMessage(id: MessageId, conversationId: ConversationId, forEveryone: boolean): Promise<void>;
745
+ markRead(id: ConversationId): Promise<void>;
746
+ startDirectConversation(otherUserId: UserId): Promise<ConversationId>;
747
+ dispose(): void;
748
+ }
749
+ declare function createMessagingEngine(options: MessagingEngineOptions): MessagingEngine;
750
+ /** The realtime client session id, exposed for diagnostics surfaces. */
751
+ declare function messagingClientId(): string;
752
+
753
+ /**
754
+ * `<MessagingProvider>` — the one place an app wires messaging.
755
+ *
756
+ * THE ONE-HOUR INTEGRATION CONTRACT (R7) is this component's job: a host passes
757
+ * a Supabase client, who they are, and their org. Everything else — the
758
+ * repository, the realtime manager and its channels, the store, the outbox and
759
+ * its durable storage, the action registry, the AI surface — is constructed and
760
+ * owned here.
761
+ *
762
+ * What a host may inject is IDENTITY and app chrome: session source, agent ids,
763
+ * a navigator for reference cards, a notification sink, action handlers. What a
764
+ * host must NEVER have to inject is behavior — if you find yourself catching a
765
+ * package error, retrying a package call, or branching on a package quirk in
766
+ * host code, that logic belongs in here (C22).
767
+ *
768
+ * 🚨 THE PROVIDER'S LIFETIME IS `client` + `userId` + `organizationId`, AND
769
+ * NOTHING ELSE. Hosts pass fresh object literals for `agents`, `actions`, and
770
+ * `onDiagnostic` on every render; keying the engine on those identities would
771
+ * tear down and rebuild every channel on every parent re-render — a permanent
772
+ * resubscribe loop that looks exactly like "realtime is broken".
773
+ */
774
+
775
+ interface MessagingHost {
776
+ readonly engine: MessagingEngine;
777
+ readonly actions: ActionRegistry;
778
+ readonly ai: MessagingAi | null;
779
+ readonly identity: MessagingIdentity;
780
+ readonly openReference: ((reference: MatrxReference) => void) | null;
781
+ }
782
+ interface MessagingProviderProps {
783
+ /** Any Supabase client. `null` while the host is still resolving one. */
784
+ client: MessagingSupabaseClient | null | undefined;
785
+ /** The signed-in user. `null` while auth is still resolving. */
786
+ userId: string | null | undefined;
787
+ /** The active organization. Every write carries it (R5). */
788
+ organizationId: string | null | undefined;
789
+ /**
790
+ * The Matrx API transport for AI features. Omit to run messaging without AI;
791
+ * the AI actions then do not render at all (never a dead button).
792
+ */
793
+ transport?: MatrxTransport | undefined;
794
+ /** Which database agent backs each AI capability. */
795
+ agents?: MessagingAgents | undefined;
796
+ /** Handlers for actionable messages. Registered once per handler identity. */
797
+ actions?: readonly ActionHandler<never>[] | undefined;
798
+ /** Called when a reference card is clicked. Omit and cards render inert-but-labeled. */
799
+ onOpenReference?: ((reference: MatrxReference) => void) | undefined;
800
+ /** A message arrived from someone else — for a toast, sound, or badge. */
801
+ onIncomingMessage?: ((message: Message) => void) | undefined;
802
+ /** Background failures, with a remedy. Defaults to a console sink. */
803
+ onDiagnostic?: ((event: EngineDiagnostic) => void) | undefined;
804
+ /** Re-resolve a session after one goes missing. Enables the single retry. */
805
+ resolveSession?: SessionResolver | undefined;
806
+ /** Override outbox persistence. Defaults to localStorage, then memory. */
807
+ outboxStorage?: OutboxStorage | undefined;
808
+ children: ReactNode;
809
+ }
810
+ declare function MessagingProvider(props: MessagingProviderProps): ReactNode;
811
+ /** The host, or `null` while auth/org are still resolving. */
812
+ declare function useMessagingHost(): MessagingHost | null;
813
+ /**
814
+ * The host, or a thrown error naming the fix. Used by components that cannot
815
+ * render anything meaningful without it — they are inside `<MessagingInbox>`,
816
+ * which itself renders a loading state until the host exists.
817
+ */
818
+ declare function useRequiredMessagingHost(): MessagingHost;
819
+ /** The whole messaging snapshot, re-rendering only when it actually changes. */
820
+ declare function useMessagingSnapshot(): MessagingSnapshot | null;
821
+
822
+ interface UseConversationsResult {
823
+ conversations: readonly ConversationSummary[];
824
+ hasMore: boolean;
825
+ /** True only on the FIRST load; a refresh must not blank the list. */
826
+ isInitialLoading: boolean;
827
+ totalUnreadConversations: number;
828
+ loadMore: () => void;
829
+ select: (id: ConversationId) => void;
830
+ activeConversationId: ConversationId | null;
831
+ startDirect: (otherUserId: UserId) => Promise<ConversationId>;
832
+ }
833
+ declare function useConversations(): UseConversationsResult;
834
+ interface UseConversationResult {
835
+ summary: ConversationSummary | null;
836
+ thread: ConversationThread | null;
837
+ messages: readonly Message[];
838
+ participants: readonly UserSummary[];
839
+ isLoading: boolean;
840
+ hasMoreOlder: boolean;
841
+ loadOlder: () => void;
842
+ pending: readonly OutboxEntry[];
843
+ retry: (entryId: string) => void;
844
+ discard: (entryId: string) => void;
845
+ edit: (messageId: Message["id"], content: string) => Promise<void>;
846
+ remove: (messageId: Message["id"], forEveryone: boolean) => Promise<void>;
847
+ }
848
+ declare function useConversation(id: ConversationId | null): UseConversationResult;
849
+ interface UseComposerResult {
850
+ value: string;
851
+ setValue: (value: string) => void;
852
+ canSend: boolean;
853
+ send: () => void;
854
+ /** Call on EVERY keystroke; the typing lifecycle is entirely inside. */
855
+ onKeystroke: () => void;
856
+ replyTo: Message | null;
857
+ setReplyTo: (message: Message | null) => void;
858
+ }
859
+ /**
860
+ * The composer's state. `value` lives here so a draft SURVIVES a re-render and
861
+ * a conversation switch-and-back — losing a half-typed message on a re-render
862
+ * is the same defect as losing a sent one, one step earlier.
863
+ */
864
+ declare function useComposer(conversationId: ConversationId | null): UseComposerResult;
865
+ interface UseTypistsResult {
866
+ /** Ready-made label: "Ana is typing…". `null` when nobody is. */
867
+ label: string | null;
868
+ userIds: readonly UserId[];
869
+ }
870
+ declare function useTypists(conversationId: ConversationId | null, participants: readonly UserSummary[]): UseTypistsResult;
871
+ declare function useOnlineUserIds(conversationId: ConversationId | null): ReadonlySet<string>;
872
+ interface UseMessagingAiResult {
873
+ available: readonly AiCapability[];
874
+ isRunning: boolean;
875
+ result: AiResult | null;
876
+ error: string | null;
877
+ run: (capability: AiCapability, args?: {
878
+ instruction?: string;
879
+ }) => void;
880
+ clear: () => void;
881
+ }
882
+ declare function useMessagingAi(conversationId: ConversationId | null): UseMessagingAiResult;
883
+ interface UseMessageActionResult {
884
+ choices: ReturnType<ActionRegistry["choicesFor"]>;
885
+ receipt: ActionReceipt | null;
886
+ isRunning: boolean;
887
+ execute: (choice: string) => void;
888
+ }
889
+ /**
890
+ * An actionable message's chips. The double-click guard is in the registry, not
891
+ * here — this hook only reflects it, which is why two components rendering the
892
+ * same action cannot double-apply it either.
893
+ */
894
+ declare function useMessageAction(message: Message): UseMessageActionResult;
895
+
896
+ /**
897
+ * THE DROP-IN SURFACES (R6/R7).
898
+ *
899
+ * `<MessagingInbox />` is the whole product in one element. The paired pieces
900
+ * are exported too, for a host that wants the list in a sidebar and the thread
901
+ * on a route — but composing pages is the host's job, and building a product
902
+ * is not.
903
+ *
904
+ * Every screen here obeys the same three rules: a control is absent or honest
905
+ * (never dead or disabled-looking with no reason); loading is a skeleton of the
906
+ * real thing, never the word "Loading…"; and a degraded connection SAYS so
907
+ * rather than quietly showing a stale thread.
908
+ */
909
+
910
+ interface ConversationListProps {
911
+ onSelect?: (id: ConversationId) => void;
912
+ onNewConversation?: () => void;
913
+ className?: string;
914
+ }
915
+ declare function ConversationList(props: ConversationListProps): ReactNode;
916
+ interface ConversationViewProps {
917
+ conversationId: ConversationId | null;
918
+ onBack?: () => void;
919
+ className?: string;
920
+ }
921
+ declare function ConversationView(props: ConversationViewProps): ReactNode;
922
+ declare function MessageBubble(props: {
923
+ message: Message;
924
+ isMine: boolean;
925
+ isLast: boolean;
926
+ onRetry?: (entryId: string) => void;
927
+ onReply?: (message: Message) => void;
928
+ failedEntryId?: string | null;
929
+ }): ReactNode;
930
+ /**
931
+ * An actionable message's chips. An action this build cannot honor renders
932
+ * NOTHING — never a generic button that offers something nobody can deliver.
933
+ */
934
+ declare function MessageActionChips(props: {
935
+ message: Message;
936
+ }): ReactNode;
937
+ declare function Composer(props: {
938
+ conversationId: ConversationId | null;
939
+ }): ReactNode;
940
+ interface MessagingInboxProps {
941
+ /** Start on a specific conversation (e.g. from a route param). */
942
+ conversationId?: ConversationId | null;
943
+ onConversationChange?: (id: ConversationId | null) => void;
944
+ onNewConversation?: () => void;
945
+ className?: string;
946
+ }
947
+ /**
948
+ * THE WHOLE PRODUCT IN ONE ELEMENT — step 2 of the one-hour contract (R7).
949
+ *
950
+ * On a phone the two panes become one pane that swaps, driven by `data-pane`;
951
+ * a 340px sidebar beside a thread on a 390px screen is the layout every chat
952
+ * app gets wrong exactly once.
953
+ */
954
+ declare function MessagingInbox(props: MessagingInboxProps): ReactNode;
955
+
956
+ declare function Avatar(props: {
957
+ name: string;
958
+ imageUrl?: string | null;
959
+ seed?: string;
960
+ small?: boolean;
961
+ online?: boolean;
962
+ }): React.ReactElement;
963
+ /**
964
+ * The delivery tick. Every state has a DISTINCT glyph and a distinct label —
965
+ * a spinner that means both "sending" and "failed" is the lie this prevents.
966
+ */
967
+ declare function DeliveryTick(props: {
968
+ state: DeliveryState;
969
+ }): React.ReactElement | null;
970
+ /**
971
+ * A reference card. NO DEAD ENDS: when the host wired `onOpenReference` it is a
972
+ * button that opens; when it did not, it renders as a labeled, non-interactive
973
+ * card with a title explaining why — never a button that does nothing.
974
+ */
975
+ declare function ReferenceCard(props: {
976
+ reference: MatrxReference;
977
+ onOpen?: ((reference: MatrxReference) => void) | null;
978
+ }): React.ReactElement;
979
+ declare function AgentTag(): React.ReactElement;
980
+ declare function TypingDots(): React.ReactElement;
981
+ /**
982
+ * Loading states are SKELETONS of the real thing, never the word "Loading…".
983
+ * The shape tells the user what is coming.
984
+ */
985
+ declare function ConversationSkeleton(props: {
986
+ rows?: number;
987
+ }): React.ReactElement;
988
+ declare function EmptyState(props: {
989
+ title: string;
990
+ body?: string;
991
+ action?: React.ReactNode;
992
+ }): React.ReactElement;
993
+
994
+ /**
995
+ * INLINED SVGs. NO ICON LIBRARY, EVER (C19).
996
+ *
997
+ * A package that depends on an icon library forces its version on every
998
+ * consumer and ships a whole set to deliver a dozen glyphs. These are drawn in
999
+ * the Lucide idiom (24×24, 2px stroke, round caps) so they sit correctly beside
1000
+ * a host's Lucide icons, and cost a fraction of a percent of the bundle.
1001
+ *
1002
+ * Every icon inherits `currentColor` and takes its size from `1em` — they are
1003
+ * text, and they scale with the text they sit next to.
1004
+ */
1005
+
1006
+ type IconProps = Omit<SVGProps<SVGSVGElement>, "children">;
1007
+ declare const SendIcon: (props: IconProps) => React.ReactElement;
1008
+ declare const PaperclipIcon: (props: IconProps) => React.ReactElement;
1009
+ declare const SparklesIcon: (props: IconProps) => React.ReactElement;
1010
+ declare const CheckIcon: (props: IconProps) => React.ReactElement;
1011
+ declare const DoubleCheckIcon: (props: IconProps) => React.ReactElement;
1012
+ declare const ClockIcon: (props: IconProps) => React.ReactElement;
1013
+ declare const AlertIcon: (props: IconProps) => React.ReactElement;
1014
+ declare const SearchIcon: (props: IconProps) => React.ReactElement;
1015
+ declare const PlusIcon: (props: IconProps) => React.ReactElement;
1016
+ declare const CloseIcon: (props: IconProps) => React.ReactElement;
1017
+ declare const ChevronLeftIcon: (props: IconProps) => React.ReactElement;
1018
+ declare const ReplyIcon: (props: IconProps) => React.ReactElement;
1019
+ declare const LinkIcon: (props: IconProps) => React.ReactElement;
1020
+ declare const BotIcon: (props: IconProps) => React.ReactElement;
1021
+ declare const UsersIcon: (props: IconProps) => React.ReactElement;
1022
+
1023
+ /**
1024
+ * EFFECTIVE-ACTOR RESOLUTION — who a message is FROM, versus who wrote the row.
1025
+ *
1026
+ * `sender_id` is the AUDIT PRINCIPAL: the session the write happened under. It
1027
+ * must never change, and it must never be what the UI renders when an agent
1028
+ * acted through a human's session. Otherwise an automated message wears a
1029
+ * colleague's face and name — the failure this module exists to prevent (R4
1030
+ * puts agents in conversations, which makes it the normal case, not an edge).
1031
+ *
1032
+ * The effective actor is declared in the message's own metadata by whoever sent
1033
+ * it. Nothing here infers an agent from heuristics on the content.
1034
+ */
1035
+
1036
+ interface ActorPresentation {
1037
+ readonly displayName: string;
1038
+ readonly avatarUrl: string | null;
1039
+ /** True when the message was authored by an agent, not the human principal. */
1040
+ readonly isAgent: boolean;
1041
+ /** Set only for an agent: the human whose session it acted under. */
1042
+ readonly onBehalfOfName: string | null;
1043
+ /** The audit principal. Always the row's `sender_id`. Never rewritten. */
1044
+ readonly principalUserId: Message["senderId"];
1045
+ }
1046
+ declare function resolveActor(message: Message, sender: UserSummary | null): ActorPresentation;
1047
+
1048
+ /**
1049
+ * IN-FLIGHT DEDUP + TTL CACHE — the cure for the request storm.
1050
+ *
1051
+ * The incident (matrx-frontend, 2026-08-21): every conversation row rendered
1052
+ * called the profile lookup for its participants, every one of those calls hit
1053
+ * the network independently, the transport wobbled, and the app captured
1054
+ * **909 errors in 0.6 seconds**. The bug was never the transport — it was that
1055
+ * N identical concurrent reads were N requests.
1056
+ *
1057
+ * Two guarantees, and both are load-bearing:
1058
+ *
1059
+ * 1. **Concurrent callers for the same key share ONE promise.** The second
1060
+ * caller does not start a second request; it awaits the first.
1061
+ * 2. **A failure does not poison the cache.** The rejected promise is evicted
1062
+ * the moment it settles, so the next caller retries instead of inheriting a
1063
+ * permanent error. (The naive version caches the rejection and the surface
1064
+ * stays broken until reload — the reason this has a named test.)
1065
+ *
1066
+ * A resolved value is held for `ttlMs` and then re-read. This is a READ cache
1067
+ * only: writes go straight through and invalidate.
1068
+ */
1069
+ interface ReadCacheOptions {
1070
+ /** How long a resolved value is served without a re-read. */
1071
+ ttlMs: number;
1072
+ /** Injected clock — the tests must not sleep. */
1073
+ now?: () => number;
1074
+ /** Bound on retained entries; the oldest is evicted first. */
1075
+ maxEntries?: number;
1076
+ }
1077
+ interface ReadCache<T> {
1078
+ /** Read through the cache. Identical concurrent keys share one call. */
1079
+ read(key: string, load: () => Promise<T>): Promise<T>;
1080
+ /** Drop one key (after a write that changes it). */
1081
+ invalidate(key: string): void;
1082
+ /** Drop everything (sign-out, org switch). */
1083
+ clear(): void;
1084
+ /** Diagnostics: how many resolved entries are held. */
1085
+ size(): number;
1086
+ }
1087
+ declare function createReadCache<T>(options: ReadCacheOptions): ReadCache<T>;
1088
+
1089
+ /**
1090
+ * THE MESSAGING CHANNEL NAMESPACES.
1091
+ *
1092
+ * D5: zero hand-rolled `.channel(` in this package. Every topic is declared
1093
+ * through `@ai-matrx/realtime`'s registry, which refuses a colliding
1094
+ * re-declaration and hands every connection attempt a unique instance topic.
1095
+ *
1096
+ * TWO channels, deliberately — not four. The origin ran `messages:`, `typing:`,
1097
+ * `presence:` and a global list channel as four independent subscriptions, and
1098
+ * three of the four hardening items were about them fighting each other. Here:
1099
+ *
1100
+ * - `messaging-inbox` (per user): the conversation list's own feed.
1101
+ * - `messaging-conversation` (per conversation): messages, presence, AND
1102
+ * typing on ONE channel, because they are one room. Sharing the channel is
1103
+ * what makes "the person typing is also present" true by construction
1104
+ * instead of by two subscriptions agreeing.
1105
+ *
1106
+ * `defineChannelNamespace` throws on a conflicting re-declaration, so these are
1107
+ * created lazily through a `globalThis` slot: with dual ESM/CJS output the
1108
+ * module can be evaluated twice in one process, and a second identical
1109
+ * declaration must be a no-op rather than a crash.
1110
+ */
1111
+
1112
+ declare function inboxTopic(userId: UserId): string;
1113
+ declare function conversationTopic(conversationId: ConversationId): string;
1114
+ /** Broadcast event names. One place, so a sender and a receiver cannot drift. */
1115
+ declare const MESSAGING_EVENTS: {
1116
+ /** A freshly sent message, broadcast beside the Postgres Changes row so a
1117
+ * receiver gets it on whichever path arrives first (both are deduped). */
1118
+ readonly message: "mx.message";
1119
+ /** A message edited or soft-deleted. */
1120
+ readonly messageUpdated: "mx.message.updated";
1121
+ /** An action receipt, so every viewer's chip settles at once. */
1122
+ readonly actionSettled: "mx.action.settled";
1123
+ };
1124
+
1125
+ /**
1126
+ * ONE error classification for the whole package (C22).
1127
+ *
1128
+ * The banned alternative is the host catching a PostgREST error and deciding
1129
+ * for itself whether it was an auth blip, a permission denial, or a real bug —
1130
+ * which is how the same three-branch `if` ends up copy-pasted into every
1131
+ * consumer and drifts. Everything this package throws is a `MessagingError`
1132
+ * with a stable `code`, and the two that hosts genuinely treat differently
1133
+ * (`session-unavailable`, `forbidden`) say so in the type.
1134
+ *
1135
+ * The incident behind `session-unavailable`: a DM reader mounted before the
1136
+ * access token existed, every RPC came back `42501`, and the app captured 909
1137
+ * errors in 0.6 seconds. A missing session is a NORMAL lifecycle moment — it
1138
+ * warns and retries; it is never a red error.
1139
+ */
1140
+ type MessagingErrorCode = "session-unavailable" | "forbidden" | "not-found" | "conflict" | "transport" | "invalid-response" | "misconfigured" | "unknown";
1141
+ declare class MessagingError extends Error {
1142
+ readonly code: MessagingErrorCode;
1143
+ /** The remedy. Nothing fails silently, and nothing fails without saying what to do. */
1144
+ readonly remedy: string;
1145
+ readonly cause?: unknown;
1146
+ constructor(code: MessagingErrorCode, message: string, remedy: string, cause?: unknown);
1147
+ /** True when retrying after the host re-establishes a session is the fix. */
1148
+ get isRetryable(): boolean;
1149
+ }
1150
+ interface PostgrestErrorLike {
1151
+ message: string;
1152
+ code?: string | undefined;
1153
+ details?: string | null | undefined;
1154
+ }
1155
+ /**
1156
+ * THE ONE PLACE a raw PostgREST/transport error becomes a typed one. Called at
1157
+ * every boundary in `repository.ts`; nothing else in the package inspects a raw
1158
+ * error, and no consumer should ever have to.
1159
+ */
1160
+ declare function normalizeMessagingError(error: PostgrestErrorLike | Error | unknown, operation: string): MessagingError;
1161
+ /** A response whose SHAPE is wrong — the ingress boundary, not the wire. */
1162
+ declare function invalidResponse(operation: string, detail: string): MessagingError;
1163
+
1164
+ /**
1165
+ * Display formatting. In the package because every consumer otherwise rebuilds
1166
+ * it slightly differently and the inbox and the thread disagree about what
1167
+ * "yesterday" means.
1168
+ *
1169
+ * Every function takes an explicit `now` so the tests do not depend on the
1170
+ * clock, and none of them touches `Intl` with an implicit locale — a package
1171
+ * that silently formats in the build machine's locale is a bug that only shows
1172
+ * up for users in other timezones.
1173
+ */
1174
+
1175
+ declare function isSameDay(a: Date, b: Date): boolean;
1176
+ /** Compact stamp for a conversation row: `9:41 AM`, `Yesterday`, `Mar 4`. */
1177
+ declare function formatConversationTime(isoString: string | null, now?: number, locale?: string): string;
1178
+ /** The stamp under a bubble. */
1179
+ declare function formatMessageTime(isoString: string, locale?: string): string;
1180
+ /** The separator between day groups in a thread. */
1181
+ declare function formatDateSeparator(isoString: string, now?: number, locale?: string): string;
1182
+ declare function getInitials(name: string): string;
1183
+ /**
1184
+ * A stable palette index for an avatar with no image. Deterministic on the id,
1185
+ * so the same person is the same color on every device and every reload — a
1186
+ * random color per render is a surprisingly loud bug.
1187
+ */
1188
+ declare function avatarPaletteIndex(seed: string, buckets?: number): number;
1189
+ /**
1190
+ * Group consecutive messages by the same sender within a window, the way every
1191
+ * good chat UI does — one avatar and one name per burst.
1192
+ */
1193
+ interface MessageGroup {
1194
+ readonly senderId: UserId;
1195
+ readonly messages: readonly Message[];
1196
+ readonly dateSeparator: string | null;
1197
+ }
1198
+ declare function groupMessages(messages: readonly Message[], args?: {
1199
+ now?: number;
1200
+ windowMs?: number;
1201
+ locale?: string;
1202
+ }): readonly MessageGroup[];
1203
+ /** "Ana is typing…" / "Ana and Bo are typing…" / "3 people are typing…" */
1204
+ declare function formatTypists(names: readonly string[]): string | null;
1205
+ declare function participantNames(participants: readonly UserSummary[], excluding: UserId): readonly string[];
1206
+ /** "Active now" / "Active 5m ago" — presence, said honestly. */
1207
+ declare function formatLastSeen(lastSeenMs: number | null, now?: number): string;
1208
+
1209
+ /**
1210
+ * THE INGRESS BOUNDARY — where a database row becomes a domain object.
1211
+ *
1212
+ * Everything crossing this line is validated, because the alternative is a
1213
+ * screen that lies. A conversation row whose `participants` aggregate came back
1214
+ * as a string instead of an array used to render an inbox entry named
1215
+ * `undefined` with a working click target; refusing the row here turns a silent
1216
+ * wrong screen into a loud, remediable error (nothing fails silently).
1217
+ *
1218
+ * The rules:
1219
+ * - A missing REQUIRED field is a refusal, never a `?? ""` that renders blank.
1220
+ * - A missing OPTIONAL field is a null, never an invented default.
1221
+ * - `metadata` / `action_data` are host-and-DB-owned JSON: typed over `unknown`
1222
+ * and narrowed by shape, never cast (the data 0.2.1 `Json = unknown` lesson).
1223
+ */
1224
+
1225
+ declare function projectUserSummary(row: Record<string, unknown>): UserSummary;
1226
+ declare function projectMessageAction(value: unknown): MessageAction | null;
1227
+ declare function projectMessage(row: Record<string, unknown>, fallbackOrganizationId: OrganizationId): Message;
1228
+ declare function projectParticipantRole(value: unknown): ParticipantRole;
1229
+ declare function projectConversationSummary(row: Record<string, unknown>, viewerId: UserId, fallbackOrganizationId: OrganizationId): ConversationSummary;
1230
+
1231
+ /**
1232
+ * MATRX REFERENCES — a message can name a platform entity, and everything it
1233
+ * names must OPEN. No dead ends.
1234
+ *
1235
+ * Two transports, both supported because both already exist in the wild:
1236
+ *
1237
+ * 1. **Structured**, in `metadata.references` — what this package writes.
1238
+ * 2. **Fenced**, as a ```matrx block in the content — the platform's
1239
+ * reference-fence protocol, which arrives from senders outside messaging.
1240
+ *
1241
+ * Two failures this module exists to prevent:
1242
+ *
1243
+ * - **A raw fence rendered as a code block.** The reader sees JSON. `splitText`
1244
+ * is what lets the renderer draw a card instead.
1245
+ * - **A fence leaking into a preview.** The inbox row and the desktop
1246
+ * notification are plain text; pasting the JSON there is the same defect one
1247
+ * layer down. `summarizeText` is the one collapse, used by both.
1248
+ */
1249
+
1250
+ type TextSegment = {
1251
+ readonly type: "text";
1252
+ readonly value: string;
1253
+ } | {
1254
+ readonly type: "reference";
1255
+ readonly reference: MatrxReference;
1256
+ };
1257
+ /** Every reference a message carries, from both transports, de-duplicated. */
1258
+ declare function extractReferences(content: string, structured?: readonly MatrxReference[]): readonly MatrxReference[];
1259
+ /**
1260
+ * Split content into renderable segments. The renderer draws text for `text`
1261
+ * and a live card for `reference`; a fence therefore CANNOT reach the screen as
1262
+ * a code block.
1263
+ */
1264
+ declare function splitText(content: string): readonly TextSegment[];
1265
+ /**
1266
+ * ONE plain-text collapse, used by the inbox preview AND the notification body.
1267
+ * A fence becomes its human label; it never reaches either as raw JSON.
1268
+ */
1269
+ declare function summarizeText(content: string, maxLength?: number): string;
1270
+ /** Serialize picked references into a fence the platform's other readers accept. */
1271
+ declare function composeFence(references: readonly MatrxReference[]): string;
1272
+
1273
+ export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, AgentTag, type AiCapability, type AiResult, AlertIcon, type Attachment, Avatar, BotIcon, CheckIcon, ChevronLeftIcon, type ClientMessageId, ClockIcon, CloseIcon, Composer, type Conversation, type ConversationCursor, type ConversationId, ConversationList, type ConversationListProps, ConversationSkeleton, type ConversationSummary, type ConversationThread, type ConversationType, ConversationView, type ConversationViewProps, type DeliveryState, DeliveryTick, DoubleCheckIcon, type DraftMessage, EmptyState, type EngineDiagnostic, type JsonObject, type JsonValue, LinkIcon, MESSAGING_EVENTS, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, MessageActionChips, MessageBubble, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingHost, type MessagingIdentity, MessagingInbox, type MessagingInboxProps, MessagingProvider, type MessagingProviderProps, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, PaperclipIcon, type Participant, type ParticipantRole, PlusIcon, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, ReferenceCard, ReplyIcon, type RepositoryOptions, type SchemaLike, SearchIcon, SendIcon, type SessionResolver, SparklesIcon, type SupabaseLike, TABLES, type TextSegment, TypingDots, type UseComposerResult, type UseConversationResult, type UseConversationsResult, type UseMessageActionResult, type UseMessagingAiResult, type UseTypistsResult, type UserId, type UserSummary, UsersIcon, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, avatarPaletteIndex, composeFence, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createWebOutboxStorage, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, getInitials, groupMessages, inboxTopic, invalidResponse, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, splitText, summarizeText, useComposer, useConversation, useConversations, useMessageAction, useMessagingAi, useMessagingHost, useMessagingSnapshot, useOnlineUserIds, useRequiredMessagingHost, useTypists };