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.
| Chat and workspace agents: channels, DMs and named agents you talk to | 1 | /** |
| 2 | * Where a conversation happens, as the reply loop sees it: read what was | |
| 3 | * said, show that the agent is typing, post its answer. The loop knows | |
| 4 | * nothing else about the chat it is in, so the same agent (its definition, | |
| 5 | * budget and replies) answers wherever it is reached: g1t's own chat today, | |
| 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. | 6 | * another chat app the workspace connected later. Only g1t's adapter exists. |
| Chat and workspace agents: channels, DMs and named agents you talk to | 7 | */ |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 8 | import type { AgentDelivery, ChatMessage, ConversationForAgent, MessageCard, ServiceBinding } from "@g1t/contracts"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 9 | |
| 10 | // By path, not the package: it imports only types, so the adapter is tested under Node. | |
| 11 | import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts"; | |
| 12 | ||
| 13 | /** One message, as the reply loop reads it, whatever the surface. */ | |
| 14 | export type SurfaceMessage = { | |
| 15 | id: string; | |
| 16 | author: { kind: "user" | "agent"; id: string; name: string; display_name: string }; | |
| 17 | body: string; | |
| 18 | /** A card's one-line summary, when the message is a card. */ | |
| 19 | card: string | null; | |
| 20 | created_at: string; | |
| 21 | }; | |
| 22 | ||
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 23 | /** One member of a conversation, as an agent is told about it. */ |
| 24 | export type ConversationMember = { | |
| 25 | kind: "user" | "agent"; | |
| 26 | id: string; | |
| 27 | /** What `@` mentions: a person's username, an agent's handle. */ | |
| 28 | name: string; | |
| 29 | display_name: string; | |
| 30 | /** An agent's title, or its role when it has none; null for a person. */ | |
| 31 | title: string | null; | |
| 32 | }; | |
| 33 | ||
| 34 | /** | |
| 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. | 35 | * Where an agent is answering and who is in it (docs.g1t.sh/guides/agents/, |
| 36 | * "Who is in the conversation"): only these members read what it says there. | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 37 | */ |
| 38 | export type Conversation = { | |
| 39 | kind: "dm" | "group_dm" | "private_channel" | "public_channel"; | |
| 40 | /** A channel's name; null for a direct message. */ | |
| 41 | name: string | null; | |
| 42 | /** Every agent, then people up to a cap. */ | |
| 43 | members: ConversationMember[]; | |
| 44 | people: number; | |
| 45 | agents: number; | |
| 46 | }; | |
| 47 | ||
| 48 | /** What handing work to a colleague did. */ | |
| 49 | export type HandedOff = { ok: true; where: "here" | "group_dm"; opened: boolean } | { ok: false; message: string }; | |
| 50 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 51 | export interface Surface { |
| 52 | /** The latest `limit` messages of the conversation (the thread, or the DM or channel), oldest first. */ | |
| 53 | history(limit: number): Promise<SurfaceMessage[]>; | |
| 54 | /** Shows the agent typing. Never throws: it is a courtesy. */ | |
| 55 | typing(): Promise<void>; | |
| 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) | 56 | /** |
| 57 | * Posts as the agent, in the conversation it was asked in, optionally as | |
| 58 | * a card (a consult, say). Returns the new message's id. | |
| 59 | */ | |
| 60 | post(body: string, card?: MessageCard | null): Promise<string>; | |
| 61 | /** | |
| 62 | * Reacts 👀 to the message that woke the agent, so people see it is on | |
| 63 | * it. Never throws, and skipped where it adds nothing (see `reactsIn`). | |
| 64 | */ | |
| 65 | acknowledge(): Promise<void>; | |
| 66 | /** | |
| 67 | * Once the reply has posted: 👀 becomes ✅ when it answered (`done`), or | |
| 68 | * is taken away when it posted a notice or an apology (`withdrawn`). | |
| 69 | * Never throws. | |
| 70 | */ | |
| 71 | settle(outcome: "done" | "withdrawn"): Promise<void>; | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 72 | /** The conversation and who is in it; null when it can't be read. Never throws. */ |
| 73 | conversation(): Promise<Conversation | null>; | |
| 74 | /** | |
| 75 | * Hands work to a colleague agent for the person who asked: posted here | |
| 76 | * when the colleague is in this channel or group DM, otherwise in the | |
| 77 | * group DM of that person, this agent and the colleague, with a card | |
| 78 | * here saying where it went. Wakes the colleague and nobody else. | |
| 79 | */ | |
| 80 | handOff(colleagueId: string, brief: string): Promise<HandedOff>; | |
| 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) | 81 | } |
| 82 | ||
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 83 | /** A conversation as the chat service describes it, as the reply loop reads it. */ |
| 84 | export function conversationFrom(value: ConversationForAgent): Conversation { | |
| 85 | const { channel } = value; | |
| 86 | const kind = | |
| 87 | channel.kind === "dm" ? (value.people + value.agents > 2 ? "group_dm" : "dm") : channel.private ? "private_channel" : "public_channel"; | |
| 88 | return { | |
| 89 | kind, | |
| 90 | name: channel.kind === "dm" ? null : channel.name, | |
| 91 | members: value.members.map((m) => ({ | |
| 92 | kind: m.kind, | |
| 93 | id: m.id, | |
| 94 | name: m.name, | |
| 95 | display_name: m.display_name, | |
| 96 | title: m.kind === "agent" ? m.title || m.role || null : null, | |
| 97 | })), | |
| 98 | people: value.people, | |
| 99 | agents: value.agents, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 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) | 103 | export const SEEN = "👀"; |
| 104 | export const DONE = "✅"; | |
| 105 | ||
| 106 | /** | |
| In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so. | 107 | * How often an agent still working says so again. Chat shows each call |
| 108 | * for `AGENT_TYPING_MS` (8 s, services/chat/src/messages.ts), so this stays | |
| 109 | * under it and the indicator never flickers off between beats. | |
| 110 | */ | |
| 111 | export const TYPING_HEARTBEAT_MS = 5_000; | |
| 112 | ||
| 113 | /** | |
| 114 | * Keeps the agent shown as typing while it works: `typing()` now and every | |
| 115 | * `everyMs` until the function it returns is called. A reply that reads | |
| 116 | * code, consults a colleague or starts a session takes a minute or more, | |
| 117 | * and one call alone would fade after seconds while the agent is still on | |
| 118 | * it. Never throws: a failed beat is simply the next one's turn. | |
| 119 | */ | |
| 120 | export function keepTyping(surface: Pick<Surface, "typing">, everyMs = TYPING_HEARTBEAT_MS): () => void { | |
| 121 | let on = true; | |
| 122 | const beat = () => { | |
| 123 | if (!on) return; | |
| 124 | try { | |
| 125 | void surface.typing().catch(() => undefined); | |
| 126 | } catch { | |
| 127 | // A courtesy only. | |
| 128 | } | |
| 129 | }; | |
| 130 | beat(); | |
| 131 | const timer = setInterval(beat, everyMs); | |
| 132 | return () => { | |
| 133 | on = false; | |
| 134 | clearInterval(timer); | |
| 135 | }; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 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) | 139 | * Whether an agent reacts in a conversation: in channels and in direct |
| 140 | * messages with more than one person. In a DM with one person, the typing | |
| 141 | * indicator says enough. | |
| 142 | */ | |
| 143 | export function reactsIn(kind: "channel" | "dm", people: number): boolean { | |
| 144 | return kind === "channel" || people > 1; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 145 | } |
| 146 | ||
| 147 | /** A g1t chat message as the loop reads it. */ | |
| 148 | export function fromChat(message: ChatMessage): SurfaceMessage { | |
| 149 | const card = message.card | |
| 150 | ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ") | |
| 151 | : null; | |
| 152 | return { | |
| 153 | id: message.id, | |
| 154 | author: { | |
| 155 | kind: message.author.kind, | |
| 156 | id: message.author.id, | |
| 157 | name: message.author.name, | |
| 158 | display_name: message.author.display_name, | |
| 159 | }, | |
| 160 | body: message.deleted_at ? "" : message.body, | |
| 161 | card, | |
| 162 | created_at: message.created_at, | |
| 163 | }; | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * g1t's own chat, over the chat service. Replies stay in the thread they | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 168 | * were asked in, and carry the delivery's hops, `asked_by` and `asker` on. |
| 169 | * A reply wakes nobody, @mentions or not; a hand-off wakes its colleague | |
| 170 | * one hop further along the same person's request, with that person's | |
| 171 | * access. | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 172 | */ |
| 173 | export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface { | |
| 174 | const client = chatClient(chat); | |
| 175 | const { workspace, channel_id: channel, agent_id: agent } = delivery; | |
| 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) | 176 | // Whether 👀 went on, so `settle` knows what to take back. |
| 177 | let acknowledged: Promise<boolean> = Promise.resolve(false); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 178 | return { |
| 179 | async history(limit) { | |
| 180 | const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit }); | |
| 181 | if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`); | |
| 182 | return page.value.filter((message) => !message.deleted_at).map(fromChat); | |
| 183 | }, | |
| 184 | async typing() { | |
| 185 | await client.agentTyping(workspace, channel, agent).catch(() => undefined); | |
| 186 | }, | |
| 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) | 187 | async acknowledge() { |
| 188 | acknowledged = (async () => { | |
| 189 | try { | |
| @g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so. | 190 | // A welcome (welcome.ts, `welcome:` ids) answers no message, so there is nothing to react to; a DM's people decide the rest. |
| 191 | if (delivery.message_id.startsWith("welcome:")) return false; | |
| 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) | 192 | if (delivery.channel_kind === "dm") { |
| 193 | const audience = await client.audience(workspace, channel); | |
| 194 | if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false; | |
| 195 | } | |
| 196 | const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN); | |
| 197 | return reacted.ok; | |
| 198 | } catch { | |
| 199 | return false; | |
| 200 | } | |
| 201 | })(); | |
| 202 | await acknowledged; | |
| 203 | }, | |
| 204 | async settle(outcome) { | |
| 205 | try { | |
| 206 | if (!(await acknowledged)) return; | |
| 207 | await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true); | |
| 208 | if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE); | |
| 209 | } catch { | |
| 210 | // A reaction is a courtesy: the reply stands without it. | |
| 211 | } | |
| 212 | }, | |
| 213 | async post(body, card = null) { | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 214 | const posted = await client.postAsAgent(workspace, channel, agent, { |
| 215 | 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) | 216 | card, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 217 | thread_root: delivery.thread_root, |
| 218 | hops: Math.min(delivery.hops, CHAT_MAX_HOPS), | |
| 219 | asked_by: delivery.asked_by, | |
| 220 | // Agents this reply wakes act for the same person, with their access. | |
| 221 | asker: delivery.asker ?? null, | |
| 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) | 222 | // Chat adds this agent, and never hands the post back to the one that sent it the work. |
| 223 | chain: delivery.chain ?? [], | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 224 | }); |
| 225 | if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`); | |
| 226 | return posted.value.id; | |
| 227 | }, | |
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 228 | async conversation() { |
| 229 | try { | |
| 230 | const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by); | |
| 231 | return found.ok ? conversationFrom(found.value) : null; | |
| 232 | } catch { | |
| 233 | return null; | |
| 234 | } | |
| 235 | }, | |
| 236 | async handOff(colleagueId, brief) { | |
| 237 | const done = await client.handOffAsAgent(workspace, channel, agent, { | |
| 238 | colleague_id: colleagueId, | |
| 239 | brief, | |
| 240 | thread_root: delivery.thread_root, | |
| 241 | hops: Math.min(delivery.hops, CHAT_MAX_HOPS), | |
| 242 | asked_by: delivery.asked_by, | |
| 243 | // The colleague works for the same person, with their access. | |
| 244 | asker: delivery.asker ?? null, | |
| 245 | chain: delivery.chain ?? [], | |
| 246 | }); | |
| 247 | return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message }; | |
| 248 | }, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 249 | }; |
| 250 | } | |
| 251 | ||
| 252 | /** The surface a delivery came from. Every delivery is g1t's today. */ | |
| 253 | export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface { | |
| 254 | switch (delivery.surface ?? "g1t") { | |
| 255 | case "g1t": | |
| 256 | default: | |
| 257 | return g1tSurface(chat, delivery); | |
| 258 | } | |
| 259 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.