@marktiderman/genesis-chat 0.1.0 → 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/README.md CHANGED
@@ -7,11 +7,11 @@ replies, threads, reactions, read state, live updates and push.
7
7
  | Entry | What it is |
8
8
  |---|---|
9
9
  | `@marktiderman/genesis-chat` | The headless core: types, `createChatClient`, the send outbox, thread logic, media rules. No React. |
10
- | `/react` | Hooks for web and native: `ChatProvider`, `useInbox`, `useConversation`, `useSend`, `useMessageActions`. |
11
- | `/web` | Renderers on genesis-ui: inbox, thread, composer (text, photos, voice), media. |
10
+ | `/react` | Hooks for web and native: `ChatProvider`, `useInbox`, `useConversation`, `useMessageActions`, and for 0002 `useTopics`, `useScheduled`, `useAvailability`, `useBroadcast`, `useSearch`, `useLinkPreviews`. |
11
+ | `/web` | Renderers on genesis-ui: inbox, thread, composer (text, photos, voice, video, emoji, scheduled send), media, topics, search, availability, broadcast. |
12
12
  | `/native` | Coming in the next release: renderers on genesis-ui-native (`InboxPage` and `ThreadPage` wired to chat, voice notes with expo-audio). Empty in this version. |
13
13
  | `/server` | `createChatRunner`: works the outbox (push, transcripts, storage cleanup). |
14
- | `migrations/0001_genesis_chat.sql` | The schema, copied into your app by `genesis add chat`, with its pgTAP test. |
14
+ | `migrations/0001_genesis_chat.sql`, `0002_genesis_chat_coaching.sql` | The schema, copied into your app by `genesis add chat`, each with its pgTAP test. |
15
15
 
16
16
  ## On the web
17
17
 
@@ -73,7 +73,7 @@ reaches nothing. The pgTAP test beside the migration proves each rule.
73
73
 
74
74
  New and changed messages, reactions and memberships are broadcast as hints (ids only) to private
75
75
  Realtime topics, `genesis-chat:conv:<id>` and `genesis-chat:user:<id>`; clients fetch through row
76
- level security, and fetch what they missed after a reconnect. Typing and recording are broadcast
76
+ level security, and fetch what they missed every time a channel joins (`onJoin`), the first time included. Typing and recording are broadcast
77
77
  by clients and never stored.
78
78
 
79
79
  ## The outbox
@@ -82,6 +82,165 @@ A message queues a `push` effect, a voice note or video a `transcribe` effect, a
82
82
  `storage.remove` effect. `createChatRunner` (from `/server`) claims and settles them; nothing is sent
83
83
  from a trigger.
84
84
 
85
+ ## Topics, scheduled send, availability, broadcast, search, previews (migration 0002)
86
+
87
+ Everything here is in the core and `/react`, so web and native share it; `/web` draws it.
88
+
89
+ ### Core client
90
+
91
+ ```ts
92
+ // Topics: named sub-threads; messages join one through `topic`.
93
+ chat.topics(conversationId): Promise<ChatTopic[]>
94
+ chat.setTopic(conversationId, topic, { title?, position?, closed? }): Promise<void> // owners and admins
95
+
96
+ // Scheduled send: a minute to a year ahead, posted as you by the runner.
97
+ chat.schedule({ conversationId, clientId, sendAt, body?, attachments?, replyToId?, topic?, mentions?, silent? }): Promise<string>
98
+ chat.scheduled(conversationId?): Promise<ScheduledMessage[]> // waiting or failed, soonest first
99
+ chat.cancelScheduled(id): Promise<void>
100
+
101
+ // Availability: away message, until when, hours per weekday, time zone.
102
+ chat.availabilityFor(conversationId): Promise<ChatAvailability[]>
103
+ chat.myAvailability(): Promise<ChatAvailability | null>
104
+ chat.setAvailability({ awayMessage?, awayUntil?, timeZone?, hours? }): Promise<void> // replaces all four
105
+
106
+ // Broadcast: one text message into each conversation you may post in; replies come back privately.
107
+ chat.broadcast(conversationIds, clientId, body, { silent? }): Promise<string[]>
108
+
109
+ // Search: text and transcripts, newest first; snippets mark matches with « ».
110
+ chat.search(query, conversationId?, { limit? }): Promise<ChatSearchHit[]>
111
+
112
+ // Link previews the runner fetched (status ok only), by message id, through link_previews_for
113
+ // (one call per message, six at a time): only for messages you can read. The table is closed.
114
+ chat.linkPreviewsFor(messageIds): Promise<Record<string, ChatLinkPreview[]>>
115
+ ```
116
+
117
+ `ChatMessage.moderation` is `"flagged" | "hidden" | null`: a hidden message reaches only its
118
+ sender and the conversation's observers. Pure helpers: `isWithinWorkingHours(hours, timeZone,
119
+ now)` (an end at or before the start runs past midnight), `isAway(a, now)`, `hoursSummary(hours)`
120
+ ("Mon–Fri, 9–5"), `awaySummary(people, { now })` (a group: "Sam, Lee and 2 others are away"),
121
+ `awayLine(a, name, { now })` ("Mark replies Mon–Fri, 9–5 (Eastern)", "Away until
122
+ Monday: Back Monday", or null when they're around), `extractLinks(body)` (the database's `message_links` rule, which skips credential-looking queries),
123
+ `isHttpsUrl`, `snippetParts(snippet)`, `scheduleQuickPicks(now)`, `scheduleLabel`, `isSchedulable`,
124
+ `topicKey(title)`.
125
+
126
+ ```ts
127
+ const line = awayLine(await chat.availabilityFor(id).then((a) => a[0]), "Mark");
128
+ ```
129
+
130
+ ### React hooks
131
+
132
+ ```tsx
133
+ const { topics, setTopic } = useTopics(conversationId); // live: set_topic's hint refreshes it
134
+ const thread = useConversation(conversationId, { topic: "week-1" }); // one topic; sends go into it
135
+ const { items, schedule, cancel } = useScheduled(conversationId); // schedule(input, files?) uploads byte files first
136
+ const { mine, setMine, members, others, forUser } = useAvailability(conversationId);
137
+ const { send, sending } = useBroadcast(); // send(ids, body) -> message ids
138
+ const { hits, isSearching, query } = useSearch(text, { conversationId, debounceMs: 250 });
139
+ const previews = useLinkPreviews(messageIds); // Record<messageId, ChatLinkPreview[]>, in link order
140
+ ```
141
+
142
+ `useConversation` also returns `signalRecordingVideo()` (throttled, like `signalRecording`), `loadUntil(seq)` (loads earlier pages until that message is here,
143
+ for a search result), `accessError` and `refetchAccess` (the role check failed: offer a retry, not
144
+ "can't post"). It catches up (messages after the newest one held) every time its live channel
145
+ joins, the first time included, and keeps `hasEarlier` in the cached page. Catch-up pages 200 at a
146
+ time until a short page; after 10 full pages it shows the newest page instead, with `hasEarlier`.
147
+ Only the whole timeline marks the conversation read: a topic or thread view doesn't (the read
148
+ place is one number per conversation, and a topic's newest message would pass unseen messages in
149
+ other topics).
150
+
151
+ `useChat()` adds `retryAll()` (call it from AppState or NetInfo on native; the web provider retries
152
+ on `online` and once after loading the saved outbox) and `clearOutbox()` (call it on sign-out).
153
+ `retryAll` goes through each conversation in order and stops at the first message that can't go on
154
+ its own: one waiting for a lost file, or one the server refused (`permanent`: 42501, 22xxx, 23xxx,
155
+ 42xxx, P0001), which only a manual `retry` sends again. `clearOutbox` stops sends in flight at once:
156
+ uploads get an aborted `signal` (the `upload` prop's third argument), and nothing posts or is saved
157
+ afterwards, so a send can't go out under the next account. The outbox owns a file's `previewUrl`
158
+ once queued and lets go of it (`releasePreview`, default: revoke `blob:` URLs) when the message
159
+ lands or is discarded.
160
+
161
+ `ChatProvider` takes two more props:
162
+
163
+ ```tsx
164
+ <ChatProvider
165
+ client={chat}
166
+ userId={user.id}
167
+ // Mux direct uploads: your server makes the upload; the composer PUTs the recording there and
168
+ // sends { kind: "video", provider: "mux", providerId, status: "pending" }. Without it, videos
169
+ // upload to storage as files.
170
+ // Pairs with createVideoUploadHandler from /server, which mints the upload and its passthrough;
171
+ // the client sends only the upload id (no attachment id).
172
+ videoUpload={{
173
+ create: (conversationId) =>
174
+ fetch("/api/chat-video-upload", {
175
+ method: "POST",
176
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.access_token}` },
177
+ body: JSON.stringify({ conversationId }),
178
+ }).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Couldn't start the upload")))),
179
+ }}
180
+ // Cards for your own attachment kinds (no forms dependency): a title, a subtitle, one action.
181
+ attachmentKinds={{
182
+ form: {
183
+ title: (a) => String(a.ref.title ?? "Form"),
184
+ subtitle: (a) => (a.ref.dueAt ? `Due ${a.ref.dueAt}` : null),
185
+ action: (a) => ({ label: "Fill in", href: `/forms/${a.ref.formId}` }),
186
+ },
187
+ }}
188
+ >
189
+ ```
190
+
191
+ A registered kind draws as `ChatAttachmentCard`; any other app kind goes to `ChatThread`'s
192
+ `attachment` slot (a generic card by default). For anything richer than a card, render the kind
193
+ yourself in that slot:
194
+
195
+ ```tsx
196
+ <ChatThread slots={{ attachment: (props) =>
197
+ props.attachment.kind === "check-in"
198
+ ? <CheckInCard id={String(props.attachment.ref.checkInId)} mine={props.mine} />
199
+ : <GenericAttachment {...props} />
200
+ }} />
201
+ ```
202
+
203
+ ### Web components
204
+
205
+ One message can't break a thread: each row renders inside `ChatErrorBoundary` ("This message
206
+ couldn't be shown"), an `attachment` slot that throws falls back to `GenericAttachment`, and an
207
+ `attachmentKinds` entry is used only for its own key (never `constructor` and the like) when its
208
+ `title` returns a string; a kind whose functions throw or return the wrong shapes gets the
209
+ generic card. Errors go to the console.
210
+
211
+ - **Topics**: `ChatThread` shows `ChatTopicBar` (All plus each topic, a lock on closed ones) when
212
+ the conversation has topics (`showTopics` shows it before the first, for owners). Owners and
213
+ admins get "New topic" and close or reopen. In a closed topic members see "This topic is closed".
214
+ - **Scheduled send**: the arrow beside Send offers tomorrow 9 AM, Monday 9 AM, or a date and time;
215
+ "N scheduled messages" above the box opens `ChatScheduledList` with Cancel (`scheduling={false}`
216
+ turns it off). `ChatScheduledList` also works on its own, for all conversations.
217
+ - **Availability**: `ChatAwayBanner` above the messages when the other person is away or outside
218
+ their hours, or in a group everyone who is (two names, then "and N others"); `ChatAvailabilitySettings` for your own.
219
+ - **Broadcast**: `<ChatBroadcast resolveUser={…} kind="direct" />` picks conversations from the inbox
220
+ and sends once, saying replies come back privately.
221
+ - **Search**: `<ChatSearch resolveUser onOpen={(hit) => …} conversationId? />`; the thread header has
222
+ it for one conversation and jumps to the message (loading earlier pages); `ChatPanel` has it
223
+ across all (`search={false}` hides it) and opens the conversation at the message
224
+ (`ChatThread jumpTo={{ messageId, seq }}`).
225
+ - **Link previews**: `ChatLinkPreview` under a message with a link: plain text only, no referrer,
226
+ and **no image by default**. An og:image lives on the linked site, so loading it in readers'
227
+ browsers tells that site (which the sender may control) each reader's IP and when they read it.
228
+ `ChatThread linkPreviewImages`: `"off"` (default), `"proxy"` (loads `linkPreviewProxyUrl(url)`,
229
+ your own image proxy; no image without it), or `"direct"` (from the linked site, https only, if
230
+ you accept that). `ChatLinkPreview` takes the same as `images` and `proxyUrl`.
231
+ - **Moderation**: the sender sees "Hidden by moderation. Only you can see it."; observers see a
232
+ "Hidden by moderation" or "Flagged" badge.
233
+ - **Emoji**: `ChatEmojiPicker` (frimousse) behind "+" in the reaction bar and the composer's smiley,
234
+ offered only when you pass `emojibaseUrl` (`ChatThread`, `ChatComposer`): host the
235
+ `emojibase-data` package's `en/data.json` and `en/messages.json` there (frimousse fetches
236
+ `${emojibaseUrl}/en/data.json`). Without it nothing is fetched and only the quick reactions show;
237
+ frimousse's own default, jsDelivr's unpinned `emojibase-data@latest`, is never used. If you point
238
+ it at a CDN instead, add that host to your CSP's `connect-src`.
239
+ - **Video**: the composer's camera button opens `ChatVideoRecorder` (preview, a limit by role,
240
+ retake, send). `ChatVideoPlayer` streams Mux (`https://stream.mux.com/<playbackId>.m3u8`) with
241
+ native HLS where the browser has it and `hls.js` (an optional peer, imported only then)
242
+ elsewhere, with Mux's thumbnail as the poster and "Processing…" while pending.
243
+
85
244
  ## Server: the runner
86
245
 
87
246
  `@marktiderman/genesis-chat/server` works the outbox with a service-role client. It runs in Node,
@@ -97,7 +256,7 @@ export const video = muxVideo({
97
256
  });
98
257
  const runner = createChatRunner({
99
258
  supabase,
100
- // Or: (n) => notify({ type: "chat.message", recipients: n.recipients, data: n.data, idempotencyKey: n.idempotencyKey, threadKey: n.threadKey, collapseKey: n.collapseKey })
259
+ // Or through genesis-notifications: notificationsDeliver({ notify, client: supabase, registry: notifications })
101
260
  deliver: createExpoDeliver({ tokensFor: myPushTokens, accessToken: process.env.EXPO_ACCESS_TOKEN }),
102
261
  transcriber: deepgramTranscriber({ apiKey: process.env.DEEPGRAM_API_KEY! }),
103
262
  video,
@@ -108,13 +267,26 @@ export const POST = chatRunnerHandler({ runner, secret: process.env.CHAT_RUNNER_
108
267
 
109
268
  Each `run()` claims up to 50 due effects (`batch.limit`), works up to 4 of a kind at once
110
269
  (`batch.concurrency`), settles each (`complete_effect`; an error backs off 30 s × 2^attempts) and
111
- answers `{ claimed, done, failed }`. It claims only `push`, `transcribe`, `storage.remove` and
112
- `video.remove` (or the `kinds` you pass); after the 8th attempt an effect is left alone.
270
+ answers `{ claimed, done, failed }`, plus `scheduled` (`{ due, posted, skipped, failed }`) unless
271
+ `scheduled: false`, and `purged` and `errors` when there are any. It
272
+ claims `push`, `transcribe`, `storage.remove` and `video.remove`, plus `moderate` with a `moderator`
273
+ and `unfurl` with an `unfurler` (or the `kinds` you pass); after the 8th attempt an effect is left
274
+ alone. Each run also posts due scheduled messages (`due_scheduled`, then `post_scheduled` each, as
275
+ their sender, with every check `send_message` makes; a refusal is recorded on the row at once, a
276
+ passing error such as a deadlock is tried again on the next run, up to five times; `scheduled:
277
+ false` turns it off) and, with
278
+ `retention: true`, removes messages past their kind's retention (up to 10 `purge_expired` calls a run,
279
+ each removing up to `retentionBatch`, default 1 000; never in a conversation on legal hold). A failed
280
+ `due_scheduled` or `purge_expired` is reported in `errors`, never thrown; a scheduled message whose
281
+ `post_scheduled` throws counts in `scheduled.failed` and is logged, not added to `errors`.
113
282
 
114
283
  - **push**: one `deliver(notification)` per message, with every recipient and their badge. The
115
284
  title is the conversation's, or the sender's name (`resolveName`) in a direct conversation; in a
116
285
  group the body is `Name: preview`. `idempotencyKey` is `push:<message id>`, so a sender that
117
- dedupes never pushes a message twice. Throw from `deliver` to retry. `createExpoDeliver` sends
286
+ dedupes never pushes a message twice; `data` always carries `messageId` and `seq` (native derives
287
+ a reply's client id from them). There is no `collapseKey` unless you pass `collapse: true`: on iOS
288
+ the collapse id becomes the notification's identifier, so replies from two notifications of one
289
+ conversation would collide. Throw from `deliver` to retry. `createExpoDeliver` sends
118
290
  through Expo in requests of 100 (`threadId`, `collapseId`, `badge`, channel `messages`, category
119
291
  `chat-message`) and returns the tickets: forget `deadTokens(tickets)`. It throws only when
120
292
  nothing was sent. Note that `collapseId` replaces a conversation's earlier notification on iOS.
@@ -149,16 +321,135 @@ those up.
149
321
  | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` | `muxVideo` uploads and removal |
150
322
  | `MUX_WEBHOOK_SECRET` | the Mux webhook's signing secret |
151
323
  | `MUX_SIGNING_KEY_ID`, `MUX_SIGNING_KEY` | only for `playbackPolicy: "signed"` (`signing` option) |
324
+ | `OPENAI_API_KEY` | `openAiModerator` (omni-moderation-latest) |
325
+ | `ANTHROPIC_API_KEY` | `createChatAssistant` on the fetch path (or pass an `@anthropic-ai/sdk` client) |
152
326
 
153
327
  ### Mux
154
328
 
155
- The client asks your server for an upload (`video.createUpload({ corsOrigin, passthrough })`), puts
156
- the file to its `url`, and sends the message with an attachment `{ kind: "video", provider: "mux",
157
- provider_id: uploadId, status: "pending" }`. Point a Mux webhook (Settings → Webhooks) at a route
158
- that calls `video.handleWebhook(request, { supabase })`. It checks `mux-signature` (HMAC-SHA256,
159
- 5 minutes' tolerance), and on `video.asset.ready` sets the attachment ready with its asset id,
329
+ The client asks your server for an upload (`createVideoUploadHandler`, below), puts the file to its
330
+ `url`, and sends the message with an attachment `{ kind: "video", provider: "mux", provider_id:
331
+ uploadId, status: "pending" }`. Point a Mux webhook (Settings → Webhooks) at a route that calls
332
+ `video.handleWebhook(request, { supabase })`. It checks `mux-signature` (HMAC-SHA256, 5 minutes'
333
+ tolerance) and finds the attachment only by that upload id (unique per attachment). It applies an
334
+ asset event only when the upload's passthrough, which the upload handler sets, names the
335
+ attachment's conversation (and its sender, when `authorize` named the uploader); anything else
336
+ answers 409 and changes nothing. On `video.asset.ready` it sets the attachment ready with its asset id,
160
337
  playback id and duration; on `video.asset.errored` failed; on the English auto-generated caption
161
338
  track's `video.asset.track.ready` it stores the caption text as the transcript. An event for an
162
339
  attachment not there yet answers 404, so Mux retries it. Playback is public by default
163
340
  (`https://stream.mux.com/<playback id>.m3u8`); with `playbackPolicy: "signed"` and `signing`,
164
341
  `video.playbackUrl(id)` (or `muxPlaybackUrl`) returns a URL with an RS256 token.
342
+
343
+ ### Push through genesis-notifications
344
+
345
+ ```ts
346
+ import { defineNotifications, MESSAGE_ACTIONS } from "@marktiderman/genesis-notifications";
347
+ import { notify } from "@marktiderman/genesis-notifications/server";
348
+ import { CHAT_NOTIFICATION_CATEGORIES, CHAT_NOTIFICATION_TYPES, notificationsDeliver } from "@marktiderman/genesis-chat/server";
349
+
350
+ export const notifications = defineNotifications({
351
+ categories: { ...CHAT_NOTIFICATION_CATEGORIES },
352
+ actions: { message: MESSAGE_ACTIONS },
353
+ types: { "chat.message": { ...CHAT_NOTIFICATION_TYPES["chat.message"], actions: "message" } },
354
+ });
355
+ const deliver = notificationsDeliver({ notify, client: supabase, registry: notifications });
356
+ ```
357
+
358
+ One `notify()` per message: type `chat.message` (category `messages`, in the feed and pushed),
359
+ every recipient with their own badge, `idempotencyKey: push:<message id>`, `threadKey` the
360
+ conversation, `data` `{ url, conversationId, messageId, seq, title, body, senderId }`, and
361
+ `collapseKey` only when the runner's `collapse` is on. Preferences, quiet hours and devices are
362
+ genesis-notifications'.
363
+
364
+ ### Moderation
365
+
366
+ `set_kind_options('cohort', p_moderate => true)` (a migration of yours) queues a `moderate` effect
367
+ for each new or edited text message in that kind, and holds the message's push until the verdict:
368
+ clean or flagged sends it, hidden never does (nor counts the message in a badge or shows it in the
369
+ inbox). So a runner without a moderator sends no pushes for that kind. Give the runner
370
+ `moderator: openAiModerator({ apiKey: process.env.OPENAI_API_KEY! })`: it sends the text and every
371
+ photo (signed 10 minute URLs; the text with the first, the others one request each) to
372
+ `omni-moderation-latest`. A category score at or over `hide` (default 0.9) hides the message from
373
+ members and files a report for observers; at or over `flag` (default 0.5), or flagged by OpenAI,
374
+ files the report only. `sexual/minors` hides at 0.5; `self-harm/intent` and
375
+ `self-harm/instructions` flag at 0.3 so a coach sees them. Change them with `thresholds` and
376
+ `categories`. The report names the categories and scores, never the text. A verdict never lowers
377
+ one already given (a retry can't un-hide a message), the same verdict on the same text files one
378
+ report, and a moderator still failing on the last of 8 attempts files `flagged` with the reason
379
+ "moderation unavailable", so observers see it and the push goes.
380
+
381
+ ### Link previews
382
+
383
+ `unfurler: createLinkUnfurler()` works `unfurl` effects (a message's first three https links,
384
+ leaving out any whose query carries a credential such as `token=`, `sig=`, `code=` or
385
+ `X-Amz-Signature=`) and stores `ok`, `failed` or `blocked` with `save_link_preview`. Clients read
386
+ previews with `genesis_chat.link_previews_for(message_id)`, which returns them only for a message
387
+ the caller can read; the table itself is not readable, since its URLs come from private messages.
388
+ It fetches https on port 443 only, follows at most 3 redirects and checks each one, refuses private,
389
+ loopback, link-local, carrier-grade NAT and cloud metadata addresses and the IPv6 forms that carry
390
+ them (and `localhost`, `*.local`, `*.internal`, single-label names), holds og:image to the same
391
+ rules (else drops it), reads HTML only, up to 512 KiB, in 5 seconds (the DNS lookup included), and
392
+ runs nothing. It resolves the host first through `node:dns` or `Deno.resolveDns`; where neither
393
+ exists (some edge runtimes) it refuses every named host and logs once, unless you pass `resolve`,
394
+ or `allowUnresolved: true` behind an egress firewall. fetch resolves the name again; on Node, close
395
+ that rebinding gap with `dispatcher: new Agent({ connect: { lookup: guardedLookup } })` (`Agent`
396
+ from `undici`), which checks the address each connection actually uses. Elsewhere, firewall private
397
+ ranges from the runner.
398
+
399
+ ### Video uploads
400
+
401
+ ```ts
402
+ import { createVideoUploadHandler } from "@marktiderman/genesis-chat/server";
403
+ const handler = createVideoUploadHandler({
404
+ video, // muxVideo(...) from above
405
+ // Confirm the session's user may post there (e.g. a select on genesis_chat.members as that
406
+ // user) and name them, so the upload is bound to its uploader.
407
+ authorize: async (request, { conversationId }) => {
408
+ const user = await userFrom(request);
409
+ return user && (await mayPost(user, conversationId)) ? { userId: user.id } : false;
410
+ },
411
+ corsOrigin: ["https://app.example.com"], // required; native apps send no origin and get the first
412
+ rateLimit: async (_request, { userId }) => underUploadCap(userId), // optional: 429 when false
413
+ });
414
+ export const POST = handler;
415
+ export const OPTIONS = handler; // CORS preflight for a web client on another origin
416
+ ```
417
+
418
+ The composer POSTs `{ conversationId }` and gets `{ url, providerId, provider: "mux" }`; it PUTs
419
+ the file to `url` and sends the attachment with `provider_id: providerId`. The client chooses
420
+ nothing the webhook trusts: the server sets the upload's passthrough to the conversation and
421
+ uploader. A refusal answers 403; over the cap, 429.
422
+
423
+ ### AI recaps and reply drafts
424
+
425
+ ```ts
426
+ import { createChatAssistant } from "@marktiderman/genesis-chat/server";
427
+ const assistant = createChatAssistant({ apiKey: process.env.ANTHROPIC_API_KEY! }); // or { anthropic: new Anthropic() }
428
+ const { text, model, labelled } = await assistant.recap({ messages, since: lastReadAt, me: userId, resolveName });
429
+ const draft = await assistant.draftReply({ messages, me: coachId, voice: "warm, brief, first names", resolveName });
430
+ ```
431
+
432
+ Default model `claude-sonnet-5-5`, low effort, with `fallbacks: "default"` (a declined request is
433
+ re-run on Anthropic's recommended model; `fallbacks: false` turns it off). Nothing is sent: show the
434
+ text as written by AI (`labelled: true`) and let the person edit and send it. Internal notes and
435
+ hidden messages are left out unless you pass `observer: true` for a staff reader: take it from
436
+ `genesis_chat.i_observe(conversation)` asked as that reader, never from the request. The transcript
437
+ goes in as data, with `<` and `>` in bodies and names replaced so nothing can close its tags, and
438
+ the prompt tells the model to ignore instructions inside it. Call it from your server only, for a
439
+ user you have checked may read the conversation. `fallbacks` is a Claude API feature: Bedrock,
440
+ Vertex AI and Foundry clients reject it, so pass `fallbacks: false` with those.
441
+
442
+ ### Retention once a day
443
+
444
+ Run a second runner daily with retention on (and scheduled posting off, since the minute runner
445
+ does it). `vercel.json`: `{ "crons": [{ "path": "/api/chat-runner", "schedule": "* * * * *" },
446
+ { "path": "/api/chat-retention", "schedule": "30 3 * * *" }] }`, and:
447
+
448
+ ```ts
449
+ // app/api/chat-retention/route.ts
450
+ const retention = createChatRunner({ supabase, deliver, kinds: [], scheduled: false, retention: true });
451
+ export const GET = chatRunnerHandler({ runner: retention, secret: process.env.CHAT_RUNNER_SECRET! });
452
+ ```
453
+
454
+ Set a kind's retention with `set_kind_options('support', p_retention => interval '365 days')`, and
455
+ hold a conversation with `set_legal_hold(id, true)` (service role).