@marktiderman/genesis-chat 0.1.1 → 0.2.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.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.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-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. 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-DMa2j7kz.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. */
@@ -261,6 +330,7 @@ interface ExpoPushMessage {
261
330
  collapseId?: string;
262
331
  channelId: string;
263
332
  categoryId: string;
333
+ mutableContent?: boolean;
264
334
  }
265
335
  type ExpoPushTicket = {
266
336
  status: "ok";
@@ -308,4 +378,423 @@ interface ChatRunnerHandlerOptions {
308
378
  }
309
379
  declare function chatRunnerHandler(options: ChatRunnerHandlerOptions): (request: Request) => Promise<Response>;
310
380
 
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 };
381
+ /**
382
+ * OpenAI's moderation endpoint behind `Moderator`
383
+ * (https://developers.openai.com/api/docs/guides/moderation):
384
+ *
385
+ * POST https://api.openai.com/v1/moderations
386
+ * { model: "omni-moderation-latest", input: [{ type: "text", text }, { type: "image_url", image_url: { url } }] }
387
+ *
388
+ * Each result has `flagged`, `categories`, `category_scores` (0 to 1) and
389
+ * `category_applied_input_types`. A message's text goes with its first photo;
390
+ * further photos go one request each (the guide doesn't say how many images one
391
+ * request takes), and the highest score per category wins.
392
+ *
393
+ * Mapping: any category at or over its `hide` threshold → `hidden`; else any at
394
+ * or over `flag` (or OpenAI's own `flagged`, unless `trustFlagged: false`) →
395
+ * `flagged`; else `clean`. The reason names the categories and scores, never
396
+ * the text.
397
+ */
398
+
399
+ declare const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
400
+ /** The categories omni-moderation scores (text and images, or text only where noted in OpenAI's guide). */
401
+ 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"];
402
+ interface ModerationThresholds {
403
+ /** At or over: a report for observers (default 0.5). */
404
+ flag?: number | undefined;
405
+ /** At or over: the message is hidden from members and reported (default 0.9). */
406
+ hide?: number | undefined;
407
+ }
408
+ interface OpenAiModeratorOptions {
409
+ apiKey: string;
410
+ /** Default `omni-moderation-latest`. */
411
+ model?: string | undefined;
412
+ /** Defaults for every category. */
413
+ thresholds?: ModerationThresholds | undefined;
414
+ /**
415
+ * Per category, over the defaults. Default: `sexual/minors` hides at 0.5 and
416
+ * flags at 0.2; `self-harm/intent` and `self-harm/instructions` flag at 0.3
417
+ * (a coach should see them) and hide at 0.95.
418
+ */
419
+ categories?: Record<string, ModerationThresholds> | undefined;
420
+ /** Count OpenAI's own `flagged` as at least `flagged` (default true). */
421
+ trustFlagged?: boolean | undefined;
422
+ /** Text sent per request (default 10 000 characters, a message's maximum). */
423
+ maxTextChars?: number | undefined;
424
+ fetch?: typeof fetch | undefined;
425
+ timeoutMs?: number | undefined;
426
+ }
427
+ /** Maps category scores to a verdict with the given thresholds (exported for tests and other providers). */
428
+ declare function moderationVerdict(scores: Record<string, number>, options?: {
429
+ thresholds?: ModerationThresholds | undefined;
430
+ categories?: Record<string, ModerationThresholds> | undefined;
431
+ flagged?: boolean;
432
+ }): ModerationVerdict;
433
+ declare function openAiModerator(options: OpenAiModeratorOptions): Moderator;
434
+
435
+ /**
436
+ * Link previews without letting a message aim the server at its own network.
437
+ *
438
+ * - https only, port 443, no user:password@; at most 3 redirects, each hop
439
+ * checked again (redirects are followed by hand, never by fetch);
440
+ * - the host must not be, or resolve to, a private, loopback, link-local,
441
+ * carrier-grade NAT, multicast, documentation or metadata address
442
+ * (169.254.169.254, fd00:ec2::254), nor an IPv6 form that carries one
443
+ * (::ffff:0:0/96 mapped and translated, NAT64, 6to4 2002::/16, Teredo),
444
+ * nor `localhost`, `*.localhost`, `*.local`, `*.internal` or a single-label
445
+ * name;
446
+ * - no URL whose query carries what looks like a credential (`token=`,
447
+ * `sig=`, `code=`, `X-Amz-Signature=` ...): fetching it could use up a
448
+ * one-time link;
449
+ * - HTML only (`text/html`, `application/xhtml+xml`), read up to `maxBytes`
450
+ * and no further; nothing is executed;
451
+ * - og:title, og:description, og:image (https only, resolved against the
452
+ * page, and its host held to the same address rules, else dropped),
453
+ * og:site_name, with twitter:* and <title> / meta description as
454
+ * fallbacks; entities decoded, whitespace collapsed, lengths capped to the
455
+ * columns of `link_previews`.
456
+ *
457
+ * DNS: the host is resolved first (Node's `node:dns`, Deno's `Deno.resolveDns`,
458
+ * or a `resolve` you pass), within the timeout, and refused if any address is
459
+ * private. Where nothing can resolve (some edge runtimes), every host that is
460
+ * not an IP literal is refused (`blocked`, logged once) unless you pass
461
+ * `allowUnresolved: true`, accepting that a public name pointing at a private
462
+ * address gets through.
463
+ *
464
+ * Rebinding: fetch resolves the name again, so a host that changes its answer
465
+ * between the check and the connection is not caught by the check alone. On
466
+ * Node, close that gap by connecting through the checked lookup: pass
467
+ * `dispatcher: new Agent({ connect: { lookup: guardedLookup } })` (`Agent` from
468
+ * `undici`), which refuses a private address at connect time. Elsewhere, run
469
+ * the runner where outbound traffic to private ranges is firewalled.
470
+ */
471
+
472
+ interface LinkUnfurlerOptions {
473
+ fetch?: typeof fetch | undefined;
474
+ /** For the whole fetch, redirects included (default 5000). */
475
+ timeoutMs?: number | undefined;
476
+ /** Bytes of HTML read at most (default 512 KiB). */
477
+ maxBytes?: number | undefined;
478
+ /** At most this many redirects (default 3). */
479
+ maxRedirects?: number | undefined;
480
+ /**
481
+ * The host's addresses. Default: `node:dns` or `Deno.resolveDns` where
482
+ * present; `null` means "can't resolve here" and only literal and local
483
+ * hosts are refused. Throwing means the name doesn't resolve: `failed`.
484
+ */
485
+ resolve?: ((hostname: string) => Promise<string[] | null>) | undefined;
486
+ /**
487
+ * Fetch a name even when nothing here can resolve it (default false: such a
488
+ * name is `blocked`). Only for a runner behind an egress firewall.
489
+ */
490
+ allowUnresolved?: boolean | undefined;
491
+ /**
492
+ * Passed to fetch as `dispatcher` (Node, undici): `new Agent({ connect: {
493
+ * lookup: guardedLookup } })` pins each connection to an address checked at
494
+ * connect time, which closes DNS rebinding.
495
+ */
496
+ dispatcher?: unknown;
497
+ /** Where a misconfiguration is reported (no resolver). Default `console.warn`. */
498
+ log?: ((line: string) => void) | undefined;
499
+ userAgent?: string | undefined;
500
+ }
501
+ /** True when `ip` (v4 or v6, brackets allowed) is not a public unicast address. Unparseable counts as private. */
502
+ declare function isPrivateAddress(ip: string): boolean;
503
+ /** True when the URL's query or fragment carries what looks like a credential (exported for tests). */
504
+ declare function carriesCredential(url: string): boolean;
505
+ type LookupAddress = {
506
+ address: string;
507
+ family: number;
508
+ };
509
+ type LookupCallback = (error: Error | null, address?: string | LookupAddress[], family?: number) => void;
510
+ /**
511
+ * A `dns.lookup` for Node's socket `connect.lookup` (undici `Agent({ connect: { lookup } })`):
512
+ * resolves the name and refuses it when any address is private, so the connection
513
+ * goes only to an address that was checked. That closes DNS rebinding.
514
+ */
515
+ declare function guardedLookup(hostname: string, options: {
516
+ all?: boolean;
517
+ family?: number;
518
+ } | number | undefined, callback: LookupCallback): void;
519
+ /** Decodes HTML entities (named, decimal, hex). */
520
+ declare function decodeEntities(text: string): string;
521
+ /**
522
+ * Open Graph data from a page's HTML (exported for tests). `base` resolves a relative og:image.
523
+ * The page is hostile input and this runs synchronously, so every scan is linear in its length.
524
+ */
525
+ declare function parseOpenGraph(html: string, base: string): Omit<LinkPreview, "url" | "status">;
526
+ declare function createLinkUnfurler(options?: LinkUnfurlerOptions): LinkUnfurler;
527
+
528
+ /**
529
+ * Chat pushes through genesis-notifications: `notificationsDeliver` hands a
530
+ * message's `ChatNotification` to `notify()` (one call, every recipient with
531
+ * their own badge), and `CHAT_NOTIFICATION_TYPES` / `CHAT_NOTIFICATION_CATEGORIES`
532
+ * go into the app's `defineNotifications` registry. Typed structurally, so
533
+ * genesis-chat doesn't depend on genesis-notifications; its tests check the
534
+ * shapes against the real package.
535
+ *
536
+ * ```ts
537
+ * import { notify } from "@marktiderman/genesis-notifications/server";
538
+ * const deliver = notificationsDeliver({ notify, client: supabase, registry: notifications });
539
+ * ```
540
+ *
541
+ * The preferences, quiet hours, channels and devices are genesis-notifications'.
542
+ */
543
+
544
+ /** The notification type id chat pushes go out as. */
545
+ declare const CHAT_NOTIFICATION_TYPE_ID = "chat.message";
546
+ /** What `notify()` receives for a chat message (a `NotifyInput<"chat.message">`). */
547
+ interface ChatNotifyInput {
548
+ type: string;
549
+ recipients: {
550
+ userId: string;
551
+ badge?: number;
552
+ }[];
553
+ data: {
554
+ url: string;
555
+ conversationId: string;
556
+ messageId: string;
557
+ seq: number | null;
558
+ title: string;
559
+ body: string;
560
+ senderId: string | null;
561
+ senderName?: string;
562
+ senderAvatar?: string;
563
+ groupName?: string;
564
+ };
565
+ idempotencyKey: string;
566
+ threadKey: string;
567
+ collapseKey?: string;
568
+ tenantId?: string;
569
+ }
570
+ interface NotificationsDeliverOptions<Client, Registry> {
571
+ /** genesis-notifications' `notify` from `@marktiderman/genesis-notifications/server`. */
572
+ notify: (client: Client, input: ChatNotifyInput, options?: {
573
+ registry?: Registry;
574
+ }) => Promise<unknown>;
575
+ /** The service-role client `notify` calls `genesis_notify` through. */
576
+ client: Client;
577
+ /** Passed to `notify` as `{ registry }`, so an unknown type fails before the database. */
578
+ registry?: Registry | undefined;
579
+ /** Default `chat.message`. `collapseKey` is passed on only when the runner set it (its `collapse` option). */
580
+ type?: string | undefined;
581
+ /** The practice or workspace the conversation belongs to, when the app has one. */
582
+ tenantFor?: ((notification: ChatNotification) => string | null | undefined | Promise<string | null | undefined>) | undefined;
583
+ }
584
+ /** Maps a chat push onto genesis-notifications' `notify()` (exported for tests and apps that call notify themselves). */
585
+ declare function chatNotifyInput(notification: ChatNotification, options?: {
586
+ type?: string | undefined;
587
+ }): ChatNotifyInput;
588
+ declare function notificationsDeliver<Client, Registry>(options: NotificationsDeliverOptions<Client, Registry>): ChatDeliver;
589
+ /** The `messages` category, for `defineNotifications({ categories })`. */
590
+ declare const CHAT_NOTIFICATION_CATEGORIES: {
591
+ readonly messages: {
592
+ readonly label: "Messages";
593
+ readonly description: "New messages in your conversations.";
594
+ readonly android: {
595
+ readonly importance: "high";
596
+ };
597
+ };
598
+ };
599
+ /**
600
+ * The `chat.message` type, for `defineNotifications({ types })`: in the feed and
601
+ * pushed, no email unless the app adds it. Spread it and override what you need
602
+ * (`actions: "message"` with genesis-notifications' `MESSAGE_ACTIONS` for reply
603
+ * and mark-as-read buttons).
604
+ */
605
+ declare const CHAT_NOTIFICATION_TYPES: {
606
+ readonly "chat.message": {
607
+ readonly category: "messages";
608
+ readonly channels: {
609
+ readonly in_app: true;
610
+ readonly push: true;
611
+ };
612
+ readonly render: (data: Record<string, unknown>) => {
613
+ push: {
614
+ ids: Record<string, string | number>;
615
+ display: Record<string, string | number>;
616
+ mutableContent: boolean;
617
+ };
618
+ url?: string | undefined;
619
+ body?: string | undefined;
620
+ title: string;
621
+ };
622
+ };
623
+ };
624
+
625
+ /**
626
+ * AI help for a conversation, through the Claude API: a short recap of what
627
+ * came in since someone last looked, and a reply draft in a coach's voice.
628
+ * Nothing is ever sent: the caller shows the text, labelled as written by AI,
629
+ * and a person decides. Internal notes (visibility `observers`) and messages
630
+ * moderation hid are left out unless the caller says the reader is an
631
+ * observer: take `observer` from `genesis_chat.i_observe(conversation)` asked
632
+ * as the reader, never from the request.
633
+ *
634
+ * Pass `anthropic` (an `@anthropic-ai/sdk` client) or nothing: without one it
635
+ * calls `POST /v1/messages` with fetch and `apiKey`. Messages go to the API as
636
+ * a transcript inside one user turn, marked as data to summarise, not
637
+ * instructions to follow; `<` and `>` in bodies, names and the coach's voice
638
+ * are replaced, so nothing in a message can close or open a tag.
639
+ *
640
+ * `fallbacks` is a Claude API feature: Bedrock, Vertex AI and Foundry clients
641
+ * reject it, so pass `fallbacks: false` with those.
642
+ */
643
+ declare const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
644
+ /** The Messages API version the fetch path sends. */
645
+ declare const ANTHROPIC_VERSION = "2026-10-21";
646
+ /** The default model: the current Sonnet. */
647
+ declare const CHAT_ASSISTANT_MODEL = "claude-sonnet-5-5";
648
+ /** One message as the assistant reads it: genesis-chat's `ChatMessage` fits. */
649
+ interface AssistantMessage {
650
+ senderId: string | null;
651
+ body: string;
652
+ createdAt: string;
653
+ kind?: "text" | "system" | undefined;
654
+ visibility?: "everyone" | "observers" | undefined;
655
+ deletedAt?: string | null | undefined;
656
+ /** `hidden` by moderation: left out unless the reader is an observer. */
657
+ moderation?: "flagged" | "hidden" | null | undefined;
658
+ }
659
+ interface AssistantResult {
660
+ text: string;
661
+ /** The model that wrote it (a fallback model when the first declined). */
662
+ model: string;
663
+ /** Always true: show it as written by AI. */
664
+ labelled: true;
665
+ }
666
+ type NameResolver = (userId: string) => Promise<string | null | undefined> | string | null | undefined;
667
+ interface RecapInput {
668
+ messages: readonly AssistantMessage[];
669
+ /** When the reader last looked (ISO time). Messages after it are what the recap covers; up to 20 before it are context. */
670
+ since?: string | Date | null | undefined;
671
+ resolveName?: NameResolver | undefined;
672
+ /** The reader, so their own messages read "You". */
673
+ me?: string | undefined;
674
+ /**
675
+ * The reader observes the conversation (staff), so internal notes and hidden
676
+ * messages are included. Default false. Take it from `genesis_chat.i_observe`
677
+ * asked as the reader, never from the request.
678
+ */
679
+ observer?: boolean | undefined;
680
+ }
681
+ interface DraftReplyInput {
682
+ messages: readonly AssistantMessage[];
683
+ /** How the coach writes ("warm, brief, first names, no emoji"). */
684
+ voice?: string | undefined;
685
+ /** The coach the draft is for: their messages read "You". */
686
+ me?: string | undefined;
687
+ resolveName?: NameResolver | undefined;
688
+ observer?: boolean | undefined;
689
+ }
690
+ /** The part of an `@anthropic-ai/sdk` client this uses. */
691
+ interface AnthropicLike {
692
+ beta: {
693
+ messages: {
694
+ create(body: Record<string, unknown>): Promise<{
695
+ content: {
696
+ type: string;
697
+ text?: string;
698
+ }[];
699
+ model: string;
700
+ stop_reason?: string | null;
701
+ }>;
702
+ };
703
+ };
704
+ }
705
+ interface ChatAssistantOptions {
706
+ /** An `@anthropic-ai/sdk` client (`new Anthropic()`); else fetch with `apiKey`. */
707
+ anthropic?: AnthropicLike | undefined;
708
+ fetch?: typeof fetch | undefined;
709
+ /** `ANTHROPIC_API_KEY`, for the fetch path. */
710
+ apiKey?: string | undefined;
711
+ /** Default `claude-sonnet-5-5`. */
712
+ model?: string | undefined;
713
+ /** Output cap per answer, thinking included (default 4000). */
714
+ maxTokens?: number | undefined;
715
+ /** Re-run a declined request on Anthropic's recommended fallback model (default true). */
716
+ fallbacks?: boolean | undefined;
717
+ timeoutMs?: number | undefined;
718
+ }
719
+ interface ChatAssistant {
720
+ recap(input: RecapInput): Promise<AssistantResult>;
721
+ draftReply(input: DraftReplyInput): Promise<AssistantResult>;
722
+ }
723
+ /** The transcript lines (exported for tests): notes and hidden messages stripped unless `observer`, deleted messages left out, angle brackets neutralised, oldest first. */
724
+ declare function transcriptLines(messages: readonly AssistantMessage[], options: {
725
+ resolveName?: NameResolver | undefined;
726
+ me?: string | undefined;
727
+ observer?: boolean | undefined;
728
+ }): Promise<{
729
+ at: number;
730
+ line: string;
731
+ }[]>;
732
+ /** The recap's user turn (exported for tests). */
733
+ declare function recapPrompt(input: RecapInput): Promise<string>;
734
+ /** The draft's user turn (exported for tests). */
735
+ declare function draftPrompt(input: DraftReplyInput): Promise<string>;
736
+ declare function createChatAssistant(options: ChatAssistantOptions): ChatAssistant;
737
+
738
+ /**
739
+ * A fetch handler that gives a composer a direct-upload URL for a video, after
740
+ * the app confirms the person may post in that conversation.
741
+ *
742
+ * POST { conversationId } → { url, providerId, provider: "mux" }
743
+ * OPTIONS → 204 with CORS headers, for an allowed origin
744
+ *
745
+ * The client PUTs the file to `url` and sends its message with an attachment
746
+ * `{ kind: "video", provider: "mux", provider_id: providerId, status: "pending" }`.
747
+ *
748
+ * The caller chooses nothing the webhook trusts. The server sets the upload's
749
+ * passthrough to the conversation (and the uploader, when `authorize` names
750
+ * them); the webhook finds the attachment only by the upload id the client sent
751
+ * with its message, and applies an event only when the passthrough names that
752
+ * attachment's conversation (and its sender). So nobody can aim their upload at
753
+ * another conversation's video.
754
+ */
755
+
756
+ /** Who an upload is for: written by the server into the upload's passthrough, never by the client. */
757
+ interface ChatUploadBinding {
758
+ conversationId: string;
759
+ /** The uploader, when the app's `authorize` named them. */
760
+ userId?: string | undefined;
761
+ }
762
+ /** `gc1:<conversation id>:<user id or empty>`: the passthrough the upload handler sets. */
763
+ declare function chatUploadPassthrough(binding: ChatUploadBinding): string;
764
+ /** Reads a passthrough the upload handler set, or null for anything else. */
765
+ declare function parseChatUploadPassthrough(value: string | null | undefined): ChatUploadBinding | null;
766
+ interface VideoUploadHandlerOptions {
767
+ /** `muxVideo(...)`. */
768
+ video: Pick<VideoProvider, "name" | "createUpload">;
769
+ /**
770
+ * Must confirm the request's user may post in `conversationId` (verify their
771
+ * session, then ask the database as them, e.g. a select on
772
+ * `genesis_chat.members` under row level security). Resolve false to refuse.
773
+ * Resolve `{ userId }` to also bind the upload to its uploader: the webhook
774
+ * then applies it only to a message they sent.
775
+ */
776
+ authorize: (request: Request, context: {
777
+ conversationId: string;
778
+ }) => Promise<boolean | {
779
+ userId: string;
780
+ }> | boolean | {
781
+ userId: string;
782
+ };
783
+ /**
784
+ * Required. The web origin(s) allowed to upload: one origin, or a list that
785
+ * the request's `origin` header must be in. Native apps send no origin; they
786
+ * get the first. `"*"` allows any origin (only for an app with no web client).
787
+ */
788
+ corsOrigin: string | readonly string[];
789
+ /**
790
+ * A per-user cap on minting uploads (each one can cost money). Resolve false
791
+ * to answer 429. Called after `authorize`, with the user it named.
792
+ */
793
+ rateLimit?: ((request: Request, context: {
794
+ conversationId: string;
795
+ userId: string | undefined;
796
+ }) => Promise<boolean> | boolean) | undefined;
797
+ }
798
+ declare function createVideoUploadHandler(options: VideoUploadHandlerOptions): (request: Request) => Promise<Response>;
799
+
800
+ 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 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, guardedLookup, isPrivateAddress, moderationVerdict, muxPlaybackToken, muxPlaybackUrl, muxVideo, notificationsDeliver, openAiModerator, openAiTranscriber, parseChatUploadPassthrough, parseOpenGraph, recapPrompt, transcriptLines, verifyMuxSignature };