@marktiderman/genesis-chat 0.1.1 → 0.2.1

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.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as ChatSupabase } from './client-BtdALFCx.js';
1
+ import { w as ChatSupabase } from './client-DUIG1hum.js';
2
2
  import '@supabase/supabase-js';
3
3
 
4
4
  /**
@@ -30,18 +30,35 @@ interface ChatNotification {
30
30
  /** The preview; in a group, prefixed with the sender's name ("Ana: see you"). */
31
31
  body: string;
32
32
  senderId: string | null;
33
- /** `chat:<conversation id>`: a newer push replaces an older one where the platform can. */
34
- collapseKey: string;
33
+ /**
34
+ * `chat:<conversation id>`, only when the runner's `collapse` is on: a newer push
35
+ * replaces an older one. Off by default, because on iOS it becomes the
36
+ * notification's identifier, so replies from two notifications of one
37
+ * conversation would collide.
38
+ */
39
+ collapseKey?: string | undefined;
35
40
  /** The conversation id: groups a conversation's pushes on the lock screen. */
36
41
  threadKey: string;
37
42
  /** `push:<message id>`: the same message is never pushed twice by a sender that dedupes. */
38
43
  idempotencyKey: string;
44
+ /**
45
+ * The push's data, built by `chatPushPayload` (genesis-chat/push): flat strings the iOS
46
+ * Notification Service Extension reads (sender name and https photo, group name) plus `seq`.
47
+ */
39
48
  data: {
40
49
  url: string;
41
50
  conversationId: string;
42
51
  messageId: string;
43
52
  seq: number | null;
53
+ senderId?: string;
54
+ senderName?: string;
55
+ senderAvatar?: string;
56
+ groupName?: string;
44
57
  };
58
+ /** Let the iOS extension rewrite it into a Communication Notification (sender photo). */
59
+ mutableContent?: boolean;
60
+ /** The notification category the native push block registers for chat (`messages`). */
61
+ categoryId?: string;
45
62
  }
46
63
  /**
47
64
  * Sends a message's push. Throw to have the effect retried (with backoff, up
@@ -87,8 +104,40 @@ interface VideoProvider {
87
104
  supabase: ChatSupabase;
88
105
  }): Promise<Response>;
89
106
  }
107
+ /** What moderation decided: `hidden` takes a message from members' view, `flagged` files a report only. */
108
+ type ModerationResult = "clean" | "flagged" | "hidden";
109
+ /** A message to moderate: its text and signed URLs (10 minutes) for its photos. */
110
+ interface ModerateInput {
111
+ text: string;
112
+ imageUrls: string[];
113
+ }
114
+ interface ModerationVerdict {
115
+ result: ModerationResult;
116
+ /** A short, safe reason for the report observers see ("harassment 0.93"). Never the message itself. */
117
+ reason?: string | undefined;
118
+ }
119
+ /** A moderation provider. Throw on failure: the effect is retried. */
120
+ interface Moderator {
121
+ readonly name: string;
122
+ moderate(input: ModerateInput): Promise<ModerationVerdict>;
123
+ }
124
+ /** One link's Open Graph data, as `save_link_preview` stores it. */
125
+ interface LinkPreview {
126
+ url: string;
127
+ /** `blocked`: the URL is not one the server may fetch (private address, not https). */
128
+ status: "ok" | "failed" | "blocked";
129
+ title?: string | null | undefined;
130
+ description?: string | null | undefined;
131
+ /** https only. */
132
+ imageUrl?: string | null | undefined;
133
+ siteName?: string | null | undefined;
134
+ }
135
+ /** Fetches a link's preview. Resolve `failed` or `blocked` rather than throw; a throw is retried. */
136
+ interface LinkUnfurler {
137
+ unfurl(url: string): Promise<LinkPreview>;
138
+ }
90
139
  /** The outbox kinds the runner works. */
91
- type ChatEffectKind = "push" | "transcribe" | "storage.remove" | "video.remove";
140
+ type ChatEffectKind = "push" | "transcribe" | "storage.remove" | "video.remove" | "moderate" | "unfurl";
92
141
  /** A claimed row of `genesis_chat.effects`. */
93
142
  interface ChatEffect {
94
143
  id: number;
@@ -103,23 +152,23 @@ interface ChatRunReport {
103
152
  done: number;
104
153
  /** Failed this time; retried later with backoff until the 8th attempt. */
105
154
  failed: number;
155
+ /**
156
+ * Scheduled messages that were due. `skipped`: not posted this run (refused, with the reason
157
+ * on the row; a passing error, tried again next run; or taken by another runner).
158
+ * `failed`: the call itself failed. Absent with `scheduled: false`.
159
+ */
160
+ scheduled?: {
161
+ due: number;
162
+ posted: number;
163
+ skipped: number;
164
+ failed: number;
165
+ } | undefined;
166
+ /** Messages removed past their kind's retention. Present only with `retention: true`. */
167
+ purged?: number | undefined;
168
+ /** Steps besides the outbox that failed this run (scheduled posting, retention), as short messages. */
169
+ errors?: string[] | undefined;
106
170
  }
107
171
 
108
- /**
109
- * The outbox runner: claims due effects from `genesis_chat.effects`, does
110
- * each one, and settles it. Settling with an error has `complete_effect` back
111
- * it off (30 s × 2^attempts); `claim_effects` gives up after the 8th attempt.
112
- *
113
- * push push_targets(message) → deliver(notification), once per message
114
- * transcribe signed URL (10 min) → transcriber → update_attachment
115
- * storage.remove storage.from(bucket).remove([path]); not found is done
116
- * video.remove video.remove(provider id), when a video provider is given
117
- *
118
- * Kinds it doesn't handle are never claimed, so an app's own runner can work
119
- * them. Effects run concurrently per kind, up to a cap; one failure never
120
- * stops the others. Errors are stored as short messages, never payloads.
121
- */
122
-
123
172
  /** `claim_effects` stops claiming an effect after this many attempts. */
124
173
  declare const CHAT_MAX_ATTEMPTS = 8;
125
174
  interface ChatRunnerOptions {
@@ -133,7 +182,17 @@ interface ChatRunnerOptions {
133
182
  transcriptLanguage?: string | undefined;
134
183
  /** Hosted video (Mux), to remove an asset when its attachment goes. */
135
184
  video?: Pick<VideoProvider, "name" | "remove"> | undefined;
136
- /** The kinds this runner works (default all four), so separate schedules can split them. */
185
+ /** Moderation for kinds that ask for it (`set_kind_options(kind, p_moderate => true)`): `openAiModerator(...)`. */
186
+ moderator?: Moderator | undefined;
187
+ /** Link previews: `createLinkUnfurler()`. Without one, `unfurl` effects are left unclaimed. */
188
+ unfurler?: LinkUnfurler | undefined;
189
+ /** Post due scheduled messages on each run (default true). */
190
+ scheduled?: boolean | undefined;
191
+ /** Remove messages past their kind's retention on each run (default false: run it once a day). */
192
+ retention?: boolean | undefined;
193
+ /** Messages removed per purge_expired call (default 1000); up to 10 calls a run. */
194
+ retentionBatch?: number | undefined;
195
+ /** The kinds this runner works (default the four base kinds, plus `moderate` and `unfurl` when their adapters are given). */
137
196
  kinds?: readonly ChatEffectKind[] | undefined;
138
197
  batch?: {
139
198
  /** Effects claimed per run (default 50, at most 500). */
@@ -143,8 +202,18 @@ interface ChatRunnerOptions {
143
202
  };
144
203
  /** A person's display name, for push titles and group previews. */
145
204
  resolveName?: ((userId: string) => Promise<string | null | undefined> | string | null | undefined) | undefined;
205
+ /** A person's photo (an https URL), shown on iOS as the sender of a chat push. */
206
+ resolveAvatar?: ((userId: string) => Promise<string | null | undefined> | string | null | undefined) | undefined;
146
207
  /** Kinds that are one-to-one (default `["direct"]`): their push title is the sender's name. */
147
208
  directKinds?: readonly string[] | undefined;
209
+ /**
210
+ * Set `collapseKey` (`chat:<conversation id>`) so a conversation's newer push
211
+ * replaces the older one. Default false: on iOS the collapse id becomes the
212
+ * notification's identifier, and replies from one conversation's notifications collide.
213
+ */
214
+ collapse?: boolean | undefined;
215
+ /** Kinds left out of the push badge, matching the app's own badge (`useUnreadTotal({ excludeKinds })`). */
216
+ badgeExcludeKinds?: readonly string[] | undefined;
148
217
  /** Where a tapped notification opens (default `/messages/<conversationId>`). */
149
218
  linkFor?: ((conversationId: string, messageId: string) => string) | undefined;
150
219
  /** Safe log lines (effect ids, kinds, short errors). Default: nothing is logged. */
@@ -240,6 +309,47 @@ declare function muxVideo(options: MuxVideoOptions): VideoProvider & {
240
309
  }): Promise<string>;
241
310
  };
242
311
 
312
+ /**
313
+ * A pretend hosted video service for local development, behind `VideoProvider`, so the hosted
314
+ * path (pending attachment, upload, ready) runs without a Mux account.
315
+ *
316
+ * createUpload a local upload URL (`uploadUrl?id=<upload id>`) and its id, `fake_<uuid>`
317
+ * handler the fetch handler to mount at `uploadUrl`: takes the PUT body, stores it in
318
+ * the genesis-chat bucket, then marks the attachment ready
319
+ * markReady what Mux's `video.asset.ready` webhook does: `update_attachment` with status
320
+ * ready, a playback id (`fake-<upload id>`) and a fake HLS URL
321
+ * playbackUrl a signed link to the stored file (the fake HLS URL when nothing was stored)
322
+ *
323
+ * It refuses to be made when NODE_ENV is `production`, unless `force` is set.
324
+ */
325
+
326
+ interface FakeVideoProviderOptions {
327
+ /** A service-role client: it writes the bucket and calls `update_attachment`. */
328
+ supabase: ChatSupabase;
329
+ /** Where `handler` is mounted, e.g. `http://localhost:3000/api/chat/fake-video`. */
330
+ uploadUrl: string;
331
+ /** The `provider` value it stands in for. Default `mux`. */
332
+ name?: "mux" | "cloudflare";
333
+ /** Run even when NODE_ENV is `production`. */
334
+ force?: boolean;
335
+ /** NODE_ENV, read from `process.env` by default. */
336
+ env?: string | undefined;
337
+ /** How long `handler` waits for the message carrying the upload to arrive before answering 202 (default 10 s). */
338
+ readyWaitMs?: number;
339
+ newId?: () => string;
340
+ sleep?: (ms: number) => Promise<void>;
341
+ }
342
+ interface FakeReadyOptions {
343
+ durationMs?: number;
344
+ /** A poster image already in the genesis-chat bucket. */
345
+ posterPath?: string;
346
+ }
347
+ declare function fakeVideoProvider(options: FakeVideoProviderOptions): VideoProvider & {
348
+ markReady(providerId: string, ready?: FakeReadyOptions): Promise<void>;
349
+ handler(request: Request): Promise<Response>;
350
+ playbackUrl(playbackId: string): Promise<string>;
351
+ };
352
+
243
353
  /**
244
354
  * A plain `deliver` for apps without genesis-notifications yet: one Expo push
245
355
  * per device token, in requests of 100 (https://docs.expo.dev/push-notifications/sending-notifications/).
@@ -261,6 +371,7 @@ interface ExpoPushMessage {
261
371
  collapseId?: string;
262
372
  channelId: string;
263
373
  categoryId: string;
374
+ mutableContent?: boolean;
264
375
  }
265
376
  type ExpoPushTicket = {
266
377
  status: "ok";
@@ -308,4 +419,423 @@ interface ChatRunnerHandlerOptions {
308
419
  }
309
420
  declare function chatRunnerHandler(options: ChatRunnerHandlerOptions): (request: Request) => Promise<Response>;
310
421
 
311
- export { CHAT_MAX_ATTEMPTS, type ChatDeliver, type ChatEffect, type ChatEffectKind, type ChatNotification, type ChatRecipient, type ChatRunReport, type ChatRunner, type ChatRunnerHandlerOptions, type ChatRunnerOptions, type DeepgramOptions, EXPO_BATCH, EXPO_SEND_URL, type ExpoDeliverOptions, type ExpoDeliveryTicket, type ExpoPushMessage, type ExpoPushTicket, type MuxSigningKey, type MuxVideoOptions, type OpenAiTranscriberOptions, type TranscribeInput, type TranscribeResult, type Transcriber, type VideoProvider, chatRunnerHandler, createChatRunner, createExpoDeliver, deadTokens, deepgramTranscriber, muxPlaybackToken, muxPlaybackUrl, muxVideo, openAiTranscriber, verifyMuxSignature };
422
+ /**
423
+ * OpenAI's moderation endpoint behind `Moderator`
424
+ * (https://developers.openai.com/api/docs/guides/moderation):
425
+ *
426
+ * POST https://api.openai.com/v1/moderations
427
+ * { model: "omni-moderation-latest", input: [{ type: "text", text }, { type: "image_url", image_url: { url } }] }
428
+ *
429
+ * Each result has `flagged`, `categories`, `category_scores` (0 to 1) and
430
+ * `category_applied_input_types`. A message's text goes with its first photo;
431
+ * further photos go one request each (the guide doesn't say how many images one
432
+ * request takes), and the highest score per category wins.
433
+ *
434
+ * Mapping: any category at or over its `hide` threshold → `hidden`; else any at
435
+ * or over `flag` (or OpenAI's own `flagged`, unless `trustFlagged: false`) →
436
+ * `flagged`; else `clean`. The reason names the categories and scores, never
437
+ * the text.
438
+ */
439
+
440
+ declare const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
441
+ /** The categories omni-moderation scores (text and images, or text only where noted in OpenAI's guide). */
442
+ declare const OPENAI_MODERATION_CATEGORIES: readonly ["sexual", "sexual/minors", "harassment", "harassment/threatening", "hate", "hate/threatening", "illicit", "illicit/violent", "self-harm", "self-harm/intent", "self-harm/instructions", "violence", "violence/graphic"];
443
+ interface ModerationThresholds {
444
+ /** At or over: a report for observers (default 0.5). */
445
+ flag?: number | undefined;
446
+ /** At or over: the message is hidden from members and reported (default 0.9). */
447
+ hide?: number | undefined;
448
+ }
449
+ interface OpenAiModeratorOptions {
450
+ apiKey: string;
451
+ /** Default `omni-moderation-latest`. */
452
+ model?: string | undefined;
453
+ /** Defaults for every category. */
454
+ thresholds?: ModerationThresholds | undefined;
455
+ /**
456
+ * Per category, over the defaults. Default: `sexual/minors` hides at 0.5 and
457
+ * flags at 0.2; `self-harm/intent` and `self-harm/instructions` flag at 0.3
458
+ * (a coach should see them) and hide at 0.95.
459
+ */
460
+ categories?: Record<string, ModerationThresholds> | undefined;
461
+ /** Count OpenAI's own `flagged` as at least `flagged` (default true). */
462
+ trustFlagged?: boolean | undefined;
463
+ /** Text sent per request (default 10 000 characters, a message's maximum). */
464
+ maxTextChars?: number | undefined;
465
+ fetch?: typeof fetch | undefined;
466
+ timeoutMs?: number | undefined;
467
+ }
468
+ /** Maps category scores to a verdict with the given thresholds (exported for tests and other providers). */
469
+ declare function moderationVerdict(scores: Record<string, number>, options?: {
470
+ thresholds?: ModerationThresholds | undefined;
471
+ categories?: Record<string, ModerationThresholds> | undefined;
472
+ flagged?: boolean;
473
+ }): ModerationVerdict;
474
+ declare function openAiModerator(options: OpenAiModeratorOptions): Moderator;
475
+
476
+ /**
477
+ * Link previews without letting a message aim the server at its own network.
478
+ *
479
+ * - https only, port 443, no user:password@; at most 3 redirects, each hop
480
+ * checked again (redirects are followed by hand, never by fetch);
481
+ * - the host must not be, or resolve to, a private, loopback, link-local,
482
+ * carrier-grade NAT, multicast, documentation or metadata address
483
+ * (169.254.169.254, fd00:ec2::254), nor an IPv6 form that carries one
484
+ * (::ffff:0:0/96 mapped and translated, NAT64, 6to4 2002::/16, Teredo),
485
+ * nor `localhost`, `*.localhost`, `*.local`, `*.internal` or a single-label
486
+ * name;
487
+ * - no URL whose query carries what looks like a credential (`token=`,
488
+ * `sig=`, `code=`, `X-Amz-Signature=` ...): fetching it could use up a
489
+ * one-time link;
490
+ * - HTML only (`text/html`, `application/xhtml+xml`), read up to `maxBytes`
491
+ * and no further; nothing is executed;
492
+ * - og:title, og:description, og:image (https only, resolved against the
493
+ * page, and its host held to the same address rules, else dropped),
494
+ * og:site_name, with twitter:* and <title> / meta description as
495
+ * fallbacks; entities decoded, whitespace collapsed, lengths capped to the
496
+ * columns of `link_previews`.
497
+ *
498
+ * DNS: the host is resolved first (Node's `node:dns`, Deno's `Deno.resolveDns`,
499
+ * or a `resolve` you pass), within the timeout, and refused if any address is
500
+ * private. Where nothing can resolve (some edge runtimes), every host that is
501
+ * not an IP literal is refused (`blocked`, logged once) unless you pass
502
+ * `allowUnresolved: true`, accepting that a public name pointing at a private
503
+ * address gets through.
504
+ *
505
+ * Rebinding: fetch resolves the name again, so a host that changes its answer
506
+ * between the check and the connection is not caught by the check alone. On
507
+ * Node, close that gap by connecting through the checked lookup: pass
508
+ * `dispatcher: new Agent({ connect: { lookup: guardedLookup } })` (`Agent` from
509
+ * `undici`), which refuses a private address at connect time. Elsewhere, run
510
+ * the runner where outbound traffic to private ranges is firewalled.
511
+ */
512
+
513
+ interface LinkUnfurlerOptions {
514
+ fetch?: typeof fetch | undefined;
515
+ /** For the whole fetch, redirects included (default 5000). */
516
+ timeoutMs?: number | undefined;
517
+ /** Bytes of HTML read at most (default 512 KiB). */
518
+ maxBytes?: number | undefined;
519
+ /** At most this many redirects (default 3). */
520
+ maxRedirects?: number | undefined;
521
+ /**
522
+ * The host's addresses. Default: `node:dns` or `Deno.resolveDns` where
523
+ * present; `null` means "can't resolve here" and only literal and local
524
+ * hosts are refused. Throwing means the name doesn't resolve: `failed`.
525
+ */
526
+ resolve?: ((hostname: string) => Promise<string[] | null>) | undefined;
527
+ /**
528
+ * Fetch a name even when nothing here can resolve it (default false: such a
529
+ * name is `blocked`). Only for a runner behind an egress firewall.
530
+ */
531
+ allowUnresolved?: boolean | undefined;
532
+ /**
533
+ * Passed to fetch as `dispatcher` (Node, undici): `new Agent({ connect: {
534
+ * lookup: guardedLookup } })` pins each connection to an address checked at
535
+ * connect time, which closes DNS rebinding.
536
+ */
537
+ dispatcher?: unknown;
538
+ /** Where a misconfiguration is reported (no resolver). Default `console.warn`. */
539
+ log?: ((line: string) => void) | undefined;
540
+ userAgent?: string | undefined;
541
+ }
542
+ /** True when `ip` (v4 or v6, brackets allowed) is not a public unicast address. Unparseable counts as private. */
543
+ declare function isPrivateAddress(ip: string): boolean;
544
+ /** True when the URL's query or fragment carries what looks like a credential (exported for tests). */
545
+ declare function carriesCredential(url: string): boolean;
546
+ type LookupAddress = {
547
+ address: string;
548
+ family: number;
549
+ };
550
+ type LookupCallback = (error: Error | null, address?: string | LookupAddress[], family?: number) => void;
551
+ /**
552
+ * A `dns.lookup` for Node's socket `connect.lookup` (undici `Agent({ connect: { lookup } })`):
553
+ * resolves the name and refuses it when any address is private, so the connection
554
+ * goes only to an address that was checked. That closes DNS rebinding.
555
+ */
556
+ declare function guardedLookup(hostname: string, options: {
557
+ all?: boolean;
558
+ family?: number;
559
+ } | number | undefined, callback: LookupCallback): void;
560
+ /** Decodes HTML entities (named, decimal, hex). */
561
+ declare function decodeEntities(text: string): string;
562
+ /**
563
+ * Open Graph data from a page's HTML (exported for tests). `base` resolves a relative og:image.
564
+ * The page is hostile input and this runs synchronously, so every scan is linear in its length.
565
+ */
566
+ declare function parseOpenGraph(html: string, base: string): Omit<LinkPreview, "url" | "status">;
567
+ declare function createLinkUnfurler(options?: LinkUnfurlerOptions): LinkUnfurler;
568
+
569
+ /**
570
+ * Chat pushes through genesis-notifications: `notificationsDeliver` hands a
571
+ * message's `ChatNotification` to `notify()` (one call, every recipient with
572
+ * their own badge), and `CHAT_NOTIFICATION_TYPES` / `CHAT_NOTIFICATION_CATEGORIES`
573
+ * go into the app's `defineNotifications` registry. Typed structurally, so
574
+ * genesis-chat doesn't depend on genesis-notifications; its tests check the
575
+ * shapes against the real package.
576
+ *
577
+ * ```ts
578
+ * import { notify } from "@marktiderman/genesis-notifications/server";
579
+ * const deliver = notificationsDeliver({ notify, client: supabase, registry: notifications });
580
+ * ```
581
+ *
582
+ * The preferences, quiet hours, channels and devices are genesis-notifications'.
583
+ */
584
+
585
+ /** The notification type id chat pushes go out as. */
586
+ declare const CHAT_NOTIFICATION_TYPE_ID = "chat.message";
587
+ /** What `notify()` receives for a chat message (a `NotifyInput<"chat.message">`). */
588
+ interface ChatNotifyInput {
589
+ type: string;
590
+ recipients: {
591
+ userId: string;
592
+ badge?: number;
593
+ }[];
594
+ data: {
595
+ url: string;
596
+ conversationId: string;
597
+ messageId: string;
598
+ seq: number | null;
599
+ title: string;
600
+ body: string;
601
+ senderId: string | null;
602
+ senderName?: string;
603
+ senderAvatar?: string;
604
+ groupName?: string;
605
+ };
606
+ idempotencyKey: string;
607
+ threadKey: string;
608
+ collapseKey?: string;
609
+ tenantId?: string;
610
+ }
611
+ interface NotificationsDeliverOptions<Client, Registry> {
612
+ /** genesis-notifications' `notify` from `@marktiderman/genesis-notifications/server`. */
613
+ notify: (client: Client, input: ChatNotifyInput, options?: {
614
+ registry?: Registry;
615
+ }) => Promise<unknown>;
616
+ /** The service-role client `notify` calls `genesis_notify` through. */
617
+ client: Client;
618
+ /** Passed to `notify` as `{ registry }`, so an unknown type fails before the database. */
619
+ registry?: Registry | undefined;
620
+ /** Default `chat.message`. `collapseKey` is passed on only when the runner set it (its `collapse` option). */
621
+ type?: string | undefined;
622
+ /** The practice or workspace the conversation belongs to, when the app has one. */
623
+ tenantFor?: ((notification: ChatNotification) => string | null | undefined | Promise<string | null | undefined>) | undefined;
624
+ }
625
+ /** Maps a chat push onto genesis-notifications' `notify()` (exported for tests and apps that call notify themselves). */
626
+ declare function chatNotifyInput(notification: ChatNotification, options?: {
627
+ type?: string | undefined;
628
+ }): ChatNotifyInput;
629
+ declare function notificationsDeliver<Client, Registry>(options: NotificationsDeliverOptions<Client, Registry>): ChatDeliver;
630
+ /** The `messages` category, for `defineNotifications({ categories })`. */
631
+ declare const CHAT_NOTIFICATION_CATEGORIES: {
632
+ readonly messages: {
633
+ readonly label: "Messages";
634
+ readonly description: "New messages in your conversations.";
635
+ readonly android: {
636
+ readonly importance: "high";
637
+ };
638
+ };
639
+ };
640
+ /**
641
+ * The `chat.message` type, for `defineNotifications({ types })`: in the feed and
642
+ * pushed, no email unless the app adds it. Spread it and override what you need
643
+ * (`actions: "message"` with genesis-notifications' `MESSAGE_ACTIONS` for reply
644
+ * and mark-as-read buttons).
645
+ */
646
+ declare const CHAT_NOTIFICATION_TYPES: {
647
+ readonly "chat.message": {
648
+ readonly category: "messages";
649
+ readonly channels: {
650
+ readonly in_app: true;
651
+ readonly push: true;
652
+ };
653
+ readonly render: (data: Record<string, unknown>) => {
654
+ push: {
655
+ ids: Record<string, string | number>;
656
+ display: Record<string, string | number>;
657
+ mutableContent: boolean;
658
+ };
659
+ url?: string | undefined;
660
+ body?: string | undefined;
661
+ title: string;
662
+ };
663
+ };
664
+ };
665
+
666
+ /**
667
+ * AI help for a conversation, through the Claude API: a short recap of what
668
+ * came in since someone last looked, and a reply draft in a coach's voice.
669
+ * Nothing is ever sent: the caller shows the text, labelled as written by AI,
670
+ * and a person decides. Internal notes (visibility `observers`) and messages
671
+ * moderation hid are left out unless the caller says the reader is an
672
+ * observer: take `observer` from `genesis_chat.i_observe(conversation)` asked
673
+ * as the reader, never from the request.
674
+ *
675
+ * Pass `anthropic` (an `@anthropic-ai/sdk` client) or nothing: without one it
676
+ * calls `POST /v1/messages` with fetch and `apiKey`. Messages go to the API as
677
+ * a transcript inside one user turn, marked as data to summarise, not
678
+ * instructions to follow; `<` and `>` in bodies, names and the coach's voice
679
+ * are replaced, so nothing in a message can close or open a tag.
680
+ *
681
+ * `fallbacks` is a Claude API feature: Bedrock, Vertex AI and Foundry clients
682
+ * reject it, so pass `fallbacks: false` with those.
683
+ */
684
+ declare const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
685
+ /** The Messages API version the fetch path sends. */
686
+ declare const ANTHROPIC_VERSION = "2026-10-21";
687
+ /** The default model: the current Sonnet. */
688
+ declare const CHAT_ASSISTANT_MODEL = "claude-sonnet-5-5";
689
+ /** One message as the assistant reads it: genesis-chat's `ChatMessage` fits. */
690
+ interface AssistantMessage {
691
+ senderId: string | null;
692
+ body: string;
693
+ createdAt: string;
694
+ kind?: "text" | "system" | undefined;
695
+ visibility?: "everyone" | "observers" | undefined;
696
+ deletedAt?: string | null | undefined;
697
+ /** `hidden` by moderation: left out unless the reader is an observer. */
698
+ moderation?: "flagged" | "hidden" | null | undefined;
699
+ }
700
+ interface AssistantResult {
701
+ text: string;
702
+ /** The model that wrote it (a fallback model when the first declined). */
703
+ model: string;
704
+ /** Always true: show it as written by AI. */
705
+ labelled: true;
706
+ }
707
+ type NameResolver = (userId: string) => Promise<string | null | undefined> | string | null | undefined;
708
+ interface RecapInput {
709
+ messages: readonly AssistantMessage[];
710
+ /** When the reader last looked (ISO time). Messages after it are what the recap covers; up to 20 before it are context. */
711
+ since?: string | Date | null | undefined;
712
+ resolveName?: NameResolver | undefined;
713
+ /** The reader, so their own messages read "You". */
714
+ me?: string | undefined;
715
+ /**
716
+ * The reader observes the conversation (staff), so internal notes and hidden
717
+ * messages are included. Default false. Take it from `genesis_chat.i_observe`
718
+ * asked as the reader, never from the request.
719
+ */
720
+ observer?: boolean | undefined;
721
+ }
722
+ interface DraftReplyInput {
723
+ messages: readonly AssistantMessage[];
724
+ /** How the coach writes ("warm, brief, first names, no emoji"). */
725
+ voice?: string | undefined;
726
+ /** The coach the draft is for: their messages read "You". */
727
+ me?: string | undefined;
728
+ resolveName?: NameResolver | undefined;
729
+ observer?: boolean | undefined;
730
+ }
731
+ /** The part of an `@anthropic-ai/sdk` client this uses. */
732
+ interface AnthropicLike {
733
+ beta: {
734
+ messages: {
735
+ create(body: Record<string, unknown>): Promise<{
736
+ content: {
737
+ type: string;
738
+ text?: string;
739
+ }[];
740
+ model: string;
741
+ stop_reason?: string | null;
742
+ }>;
743
+ };
744
+ };
745
+ }
746
+ interface ChatAssistantOptions {
747
+ /** An `@anthropic-ai/sdk` client (`new Anthropic()`); else fetch with `apiKey`. */
748
+ anthropic?: AnthropicLike | undefined;
749
+ fetch?: typeof fetch | undefined;
750
+ /** `ANTHROPIC_API_KEY`, for the fetch path. */
751
+ apiKey?: string | undefined;
752
+ /** Default `claude-sonnet-5-5`. */
753
+ model?: string | undefined;
754
+ /** Output cap per answer, thinking included (default 4000). */
755
+ maxTokens?: number | undefined;
756
+ /** Re-run a declined request on Anthropic's recommended fallback model (default true). */
757
+ fallbacks?: boolean | undefined;
758
+ timeoutMs?: number | undefined;
759
+ }
760
+ interface ChatAssistant {
761
+ recap(input: RecapInput): Promise<AssistantResult>;
762
+ draftReply(input: DraftReplyInput): Promise<AssistantResult>;
763
+ }
764
+ /** The transcript lines (exported for tests): notes and hidden messages stripped unless `observer`, deleted messages left out, angle brackets neutralised, oldest first. */
765
+ declare function transcriptLines(messages: readonly AssistantMessage[], options: {
766
+ resolveName?: NameResolver | undefined;
767
+ me?: string | undefined;
768
+ observer?: boolean | undefined;
769
+ }): Promise<{
770
+ at: number;
771
+ line: string;
772
+ }[]>;
773
+ /** The recap's user turn (exported for tests). */
774
+ declare function recapPrompt(input: RecapInput): Promise<string>;
775
+ /** The draft's user turn (exported for tests). */
776
+ declare function draftPrompt(input: DraftReplyInput): Promise<string>;
777
+ declare function createChatAssistant(options: ChatAssistantOptions): ChatAssistant;
778
+
779
+ /**
780
+ * A fetch handler that gives a composer a direct-upload URL for a video, after
781
+ * the app confirms the person may post in that conversation.
782
+ *
783
+ * POST { conversationId } → { url, providerId, provider: "mux" }
784
+ * OPTIONS → 204 with CORS headers, for an allowed origin
785
+ *
786
+ * The client PUTs the file to `url` and sends its message with an attachment
787
+ * `{ kind: "video", provider: "mux", provider_id: providerId, status: "pending" }`.
788
+ *
789
+ * The caller chooses nothing the webhook trusts. The server sets the upload's
790
+ * passthrough to the conversation (and the uploader, when `authorize` names
791
+ * them); the webhook finds the attachment only by the upload id the client sent
792
+ * with its message, and applies an event only when the passthrough names that
793
+ * attachment's conversation (and its sender). So nobody can aim their upload at
794
+ * another conversation's video.
795
+ */
796
+
797
+ /** Who an upload is for: written by the server into the upload's passthrough, never by the client. */
798
+ interface ChatUploadBinding {
799
+ conversationId: string;
800
+ /** The uploader, when the app's `authorize` named them. */
801
+ userId?: string | undefined;
802
+ }
803
+ /** `gc1:<conversation id>:<user id or empty>`: the passthrough the upload handler sets. */
804
+ declare function chatUploadPassthrough(binding: ChatUploadBinding): string;
805
+ /** Reads a passthrough the upload handler set, or null for anything else. */
806
+ declare function parseChatUploadPassthrough(value: string | null | undefined): ChatUploadBinding | null;
807
+ interface VideoUploadHandlerOptions {
808
+ /** `muxVideo(...)`. */
809
+ video: Pick<VideoProvider, "name" | "createUpload">;
810
+ /**
811
+ * Must confirm the request's user may post in `conversationId` (verify their
812
+ * session, then ask the database as them, e.g. a select on
813
+ * `genesis_chat.members` under row level security). Resolve false to refuse.
814
+ * Resolve `{ userId }` to also bind the upload to its uploader: the webhook
815
+ * then applies it only to a message they sent.
816
+ */
817
+ authorize: (request: Request, context: {
818
+ conversationId: string;
819
+ }) => Promise<boolean | {
820
+ userId: string;
821
+ }> | boolean | {
822
+ userId: string;
823
+ };
824
+ /**
825
+ * Required. The web origin(s) allowed to upload: one origin, or a list that
826
+ * the request's `origin` header must be in. Native apps send no origin; they
827
+ * get the first. `"*"` allows any origin (only for an app with no web client).
828
+ */
829
+ corsOrigin: string | readonly string[];
830
+ /**
831
+ * A per-user cap on minting uploads (each one can cost money). Resolve false
832
+ * to answer 429. Called after `authorize`, with the user it named.
833
+ */
834
+ rateLimit?: ((request: Request, context: {
835
+ conversationId: string;
836
+ userId: string | undefined;
837
+ }) => Promise<boolean> | boolean) | undefined;
838
+ }
839
+ declare function createVideoUploadHandler(options: VideoUploadHandlerOptions): (request: Request) => Promise<Response>;
840
+
841
+ export { ANTHROPIC_MESSAGES_URL, ANTHROPIC_VERSION, type AnthropicLike, type AssistantMessage, type AssistantResult, CHAT_ASSISTANT_MODEL, CHAT_MAX_ATTEMPTS, CHAT_NOTIFICATION_CATEGORIES, CHAT_NOTIFICATION_TYPES, CHAT_NOTIFICATION_TYPE_ID, type ChatAssistant, type ChatAssistantOptions, type ChatDeliver, type ChatEffect, type ChatEffectKind, type ChatNotification, type ChatNotifyInput, type ChatRecipient, type ChatRunReport, type ChatRunner, type ChatRunnerHandlerOptions, type ChatRunnerOptions, type ChatUploadBinding, type DeepgramOptions, type DraftReplyInput, EXPO_BATCH, EXPO_SEND_URL, type ExpoDeliverOptions, type ExpoDeliveryTicket, type ExpoPushMessage, type ExpoPushTicket, type FakeReadyOptions, type FakeVideoProviderOptions, type LinkPreview, type LinkUnfurler, type LinkUnfurlerOptions, type ModerateInput, type ModerationResult, type ModerationThresholds, type ModerationVerdict, type Moderator, type MuxSigningKey, type MuxVideoOptions, type NotificationsDeliverOptions, OPENAI_MODERATIONS_URL, OPENAI_MODERATION_CATEGORIES, type OpenAiModeratorOptions, type OpenAiTranscriberOptions, type RecapInput, type TranscribeInput, type TranscribeResult, type Transcriber, type VideoProvider, type VideoUploadHandlerOptions, carriesCredential, chatNotifyInput, chatRunnerHandler, chatUploadPassthrough, createChatAssistant, createChatRunner, createExpoDeliver, createLinkUnfurler, createVideoUploadHandler, deadTokens, decodeEntities, deepgramTranscriber, draftPrompt, fakeVideoProvider, guardedLookup, isPrivateAddress, moderationVerdict, muxPlaybackToken, muxPlaybackUrl, muxVideo, notificationsDeliver, openAiModerator, openAiTranscriber, parseChatUploadPassthrough, parseOpenGraph, recapPrompt, transcriptLines, verifyMuxSignature };