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