Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1 | /** |
| 2 | * What the notify service is told about a new message, or a read: every | |
| 3 | * person in the conversation has its counts moved, and those it is for are | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 4 | * notified (docs.g1t.sh/guides/chat/, "Notifications"). |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 5 | * |
| 6 | * - A direct message notifies everyone else in it, muted or not. | |
| 7 | * - An @mention notifies the person named, muted or not. | |
| 8 | * - A reply notifies the people already in its thread (who started it or | |
| 9 | * replied), unless they muted the conversation. | |
| 10 | * - Everyone else in it only has their counts moved. | |
| 11 | * - The author is never told of their own message; their own count for | |
| 12 | * the conversation goes to nothing (what you wrote, you have read). | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 13 | * - Only people are told. |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 14 | * |
| 15 | * `recipients` is pure, so the rules are tested apart from the service. | |
| 16 | */ | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 17 | import type { FeedDelivery, FeedNotification, MemberProfile, NotificationKind } from "@g1t/contracts"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 18 | |
| Merge main into Artifacts Phase 2 | 19 | import { plainText } from "@g1t/contracts/chat-markdown"; |
| 20 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 21 | import { tally, type UnreadRow } from "./unread.ts"; |
| 22 | ||
| 23 | export type Person = { key: string; user_id: string; username: string; muted: boolean }; | |
| 24 | ||
| 25 | export type Recipient = { user_id: string; kind: NotificationKind | null; mentioned: boolean; muted: boolean }; | |
| 26 | ||
| 27 | export function recipients(input: { | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 28 | /** `user:<id>`. */ |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 29 | author: string; |
| 30 | channelKind: "channel" | "dm"; | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 31 | /** The people in the conversation. */ |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 32 | people: Person[]; |
| 33 | /** Handles the message mentions, lowercased. */ | |
| 34 | mentioned: string[]; | |
| 35 | /** For a reply: the people in its thread, by key; null for a top-level message. */ | |
| 36 | thread: ReadonlySet<string> | null; | |
| 37 | }): Recipient[] { | |
| 38 | const named = new Set(input.mentioned.map((h) => h.toLowerCase())); | |
| 39 | const out: Recipient[] = []; | |
| 40 | for (const person of input.people) { | |
| 41 | if (person.key === input.author) continue; | |
| 42 | const mentioned = named.has(person.username.toLowerCase()); | |
| 43 | let kind: NotificationKind | null = null; | |
| 44 | if (input.channelKind === "dm") kind = "dm"; | |
| 45 | else if (mentioned) kind = "mention"; | |
| 46 | else if (input.thread?.has(person.key) && !person.muted) kind = "thread_reply"; | |
| 47 | out.push({ user_id: person.user_id, kind, mentioned, muted: person.muted }); | |
| 48 | } | |
| 49 | return out; | |
| 50 | } | |
| 51 | ||
| Merge main into Artifacts Phase 2 | 52 | /** A message's text as a notification shows it: one line, the Markdown's marks gone, short. */ |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 53 | export function preview(body: string, max = 140): string { |
| Merge main into Artifacts Phase 2 | 54 | return plainText(String(body ?? ""), max); |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 55 | } |
| 56 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 57 | /** Where a conversation, or a thread in it, is on the site. */ |
| 58 | export function conversationHref(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }, threadRoot: string | null): string { | |
| 59 | const base = channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; | |
| 60 | return threadRoot ? `${base}?thread=${encodeURIComponent(threadRoot)}` : base; | |
| 61 | } | |
| 62 | ||
| 63 | /** The deliveries for one new message. */ | |
| 64 | export function messageDeliveries(input: { | |
| 65 | slug: string; | |
| 66 | channel: { id: string; kind: "channel" | "dm"; name: string | null }; | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 67 | message: { id: string; author: string; body: string; thread_root: string | null; created_at: string }; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 68 | author: MemberProfile; |
| 69 | recipients: Recipient[]; | |
| 70 | }): FeedDelivery[] { | |
| 71 | const { slug, channel, message, author } = input; | |
| 72 | const where = channel.kind === "dm" ? "" : ` in #${channel.name}`; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 73 | // The service sets `display_name` by `memberName`'s rule (display name, |
| 74 | // else the username in its chosen case), so pushes match the chat. | |
| 75 | const shown = author.display_name.trim() || author.display_username || author.name; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 76 | const deliveries: FeedDelivery[] = input.recipients.map((r) => { |
| 77 | const notification: FeedNotification | null = r.kind | |
| 78 | ? { | |
| 79 | id: message.id, | |
| 80 | kind: r.kind, | |
| 81 | workspace: slug, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 82 | title: r.kind === "thread_reply" ? `${shown} replied${where}` : `${shown}${where}`, |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 83 | body: preview(message.body), |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 84 | href: conversationHref(slug, channel, message.thread_root), |
| 85 | actor: { | |
| 86 | kind: author.kind, | |
| 87 | id: author.id, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 88 | name: shown, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 89 | avatar: author.avatar, |
| 90 | avatar_seed: author.avatar_seed ?? null, | |
| 91 | }, | |
| 92 | channel_id: channel.id, | |
| 93 | thread_root: message.thread_root, | |
| 94 | created_at: message.created_at, | |
| 95 | } | |
| 96 | : null; | |
| 97 | return { | |
| 98 | user_id: r.user_id, | |
| 99 | workspace: slug, | |
| 100 | counts: { channel_id: channel.id, unread: 1, mentions: r.mentioned ? 1 : 0, muted: r.muted }, | |
| 101 | notification, | |
| 102 | }; | |
| 103 | }); | |
| 104 | // The author has read the conversation by writing in it. | |
| 105 | if (message.author.startsWith("user:")) { | |
| 106 | deliveries.push({ | |
| 107 | user_id: message.author.slice("user:".length), | |
| 108 | workspace: slug, | |
| 109 | counts: { channel_id: channel.id, unread: 0, mentions: 0, set: true }, | |
| 110 | notification: null, | |
| 111 | }); | |
| 112 | } | |
| 113 | return deliveries; | |
| 114 | } | |
| 115 | ||
| 116 | /** A person's counts for one conversation after they read up to `lastReadId`, from the rows after it. */ | |
| 117 | export function countsAfterRead(rows: UnreadRow[], channelId: string, member: string, username: string, lastReadId: string): { unread: number; mentions: number } { | |
| 118 | return tally(rows, member, username, new Map([[channelId, lastReadId]])).get(channelId) ?? { unread: 0, mentions: 0 }; | |
| 119 | } | |
| 120 | ||
| 121 | ||
| 122 | // ── Wiring, used by src/index.ts ───────────────────────────────────────── | |
| 123 | ||
| 124 | type Db = D1Database; | |
| 125 | type Notify = { fetch(input: string, init?: RequestInit): Promise<Response> }; | |
| 126 | ||
| 127 | async function deliver(notify: Notify, items: FeedDelivery[]): Promise<void> { | |
| 128 | if (!items.length) return; | |
| 129 | const response = await notify.fetch("https://service/rpc/deliver", { | |
| 130 | method: "POST", | |
| 131 | headers: { "content-type": "application/json" }, | |
| 132 | body: JSON.stringify({ items }), | |
| 133 | }); | |
| 134 | if (!response.ok) throw new Error(`deliver failed with status ${response.status}`); | |
| 135 | } | |
| 136 | ||
| 137 | /** Tells notify of a new message: one batched call for everyone in the conversation. */ | |
| 138 | export async function notifyMessage( | |
| 139 | db: Db, | |
| 140 | notify: Notify | undefined, | |
| 141 | profiles: (keys: string[]) => Promise<Map<string, MemberProfile>>, | |
| 142 | input: { | |
| 143 | slug: string; | |
| 144 | channel: { id: string; kind: "channel" | "dm"; name: string | null }; | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 145 | row: { id: string; author: string; body: string; thread_root: string | null; created_at: string }; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 146 | handles: string[]; |
| 147 | }, | |
| 148 | ): Promise<void> { | |
| 149 | if (!notify) return; | |
| 150 | const { channel, row } = input; | |
| 151 | const [members, thread] = await Promise.all([ | |
| 152 | db | |
| 153 | .prepare("SELECT principal, muted FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'") | |
| 154 | .bind(channel.id) | |
| 155 | .all<{ principal: string; muted: number }>(), | |
| 156 | row.thread_root | |
| 157 | ? db | |
| 158 | .prepare("SELECT DISTINCT author FROM messages WHERE channel_id = ?1 AND (id = ?2 OR thread_root = ?2) AND author LIKE 'user:%'") | |
| 159 | .bind(channel.id, row.thread_root) | |
| 160 | .all<{ author: string }>() | |
| 161 | : Promise.resolve(null), | |
| 162 | ]); | |
| 163 | const keys = members.results.map((m) => m.principal); | |
| 164 | const found = await profiles([...keys, row.author]); | |
| 165 | const people: Person[] = members.results.map((m) => ({ | |
| 166 | key: m.principal, | |
| 167 | user_id: m.principal.slice("user:".length), | |
| 168 | username: found.get(m.principal)?.name ?? "", | |
| 169 | muted: !!m.muted, | |
| 170 | })); | |
| 171 | const author = found.get(row.author); | |
| 172 | if (!author) return; | |
| 173 | await deliver( | |
| 174 | notify, | |
| 175 | messageDeliveries({ | |
| 176 | slug: input.slug, | |
| 177 | channel, | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 178 | message: { id: row.id, author: row.author, body: row.body, thread_root: row.thread_root, created_at: row.created_at }, |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 179 | author, |
| 180 | recipients: recipients({ | |
| 181 | author: row.author, | |
| 182 | channelKind: channel.kind, | |
| 183 | people, | |
| 184 | mentioned: input.handles, | |
| 185 | thread: thread ? new Set(thread.results.map((r) => r.author)) : null, | |
| 186 | }), | |
| 187 | }), | |
| 188 | ); | |
| 189 | } | |
| 190 | ||
| 191 | /** After a read: the person's counts for the conversation, as they now are, in every tab. */ | |
| 192 | export async function notifyRead( | |
| 193 | db: Db, | |
| 194 | notify: Notify | undefined, | |
| 195 | input: { slug: string; channel_id: string; user_id: string; username: string; last_read_id: string }, | |
| 196 | ): Promise<void> { | |
| 197 | if (!notify) return; | |
| 198 | const member = `user:${input.user_id}`; | |
| 199 | const rows = await db | |
| 200 | .prepare( | |
| 201 | "SELECT channel_id, id, author, mentions FROM messages WHERE channel_id = ? AND id > ? AND deleted_at IS NULL AND author != ? LIMIT 5000", | |
| 202 | ) | |
| 203 | .bind(input.channel_id, input.last_read_id, member) | |
| 204 | .all<UnreadRow>(); | |
| 205 | const counts = countsAfterRead(rows.results, input.channel_id, member, input.username, input.last_read_id); | |
| 206 | await deliver(notify, [ | |
| 207 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, ...counts, set: true }, notification: null }, | |
| 208 | ]); | |
| 209 | } | |
| 210 | ||
| 211 | /** After muting or unmuting: the badge counts the conversation, or leaves it out, in every tab. */ | |
| 212 | export async function notifyMuted(notify: Notify | undefined, input: { slug: string; channel_id: string; user_id: string; muted: boolean }): Promise<void> { | |
| 213 | if (!notify) return; | |
| 214 | await deliver(notify, [ | |
| 215 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, unread: 0, mentions: 0, muted: input.muted }, notification: null }, | |
| 216 | ]); | |
| 217 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.