hivecode-mcp 0.11.5 → 0.12.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.
@@ -0,0 +1,263 @@
1
+ // WHICH RELAY IS THIS, AND DID ANYBODY CHOOSE IT?
2
+ //
3
+ // Every byte of a team's source leaves their machines through one URL: the relay. The
4
+ // client has always printed that URL and stopped there -- a bare `relay: wss://...` line
5
+ // that says nothing about what it MEANS. That is the whole defect this file exists for,
6
+ // and it is the product's highest-stakes false success:
7
+ //
8
+ // An agent starts with no link and no .hive.json. resolveRoom() falls through to the
9
+ // shipped default -- a relay the team does not run. The agent joins. hive_status says
10
+ // connected. filesSynced climbs. Every signal is green, and the company's source code
11
+ // is now on a stranger's server. Nothing crashed, so nothing reports it.
12
+ //
13
+ // Two separate things are needed and they are NOT the same thing:
14
+ //
15
+ // 1. A VERDICT on every join, so "am I private?" is answered without being asked.
16
+ // Printing the URL is not a verdict. A person who has never run a relay cannot tell
17
+ // wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
18
+ //
19
+ // 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
20
+ // an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
21
+ // ever talk to these relays" -- set once in the MCP config, and no stale link, no
22
+ // typo'd host and no forgotten env var can send a file anywhere else. The refusal
23
+ // happens BEFORE the socket opens, so a refused join publishes nothing.
24
+ //
25
+ // Comparison is on ORIGIN, never on the raw string. wss://r.co, wss://r.co/ and
26
+ // wss://R.co:443 are one relay; a string compare would refuse a correct configuration,
27
+ // and a guard that fires on the healthy path is a guard people disable.
28
+
29
+ // The relay this build falls back to when nobody chose one -- THE ONE PLACE IT IS WRITTEN.
30
+ //
31
+ // It used to be written four times: here, hive-mcp.js, hive-agent.js and hive-say.js, each a
32
+ // literal. On 23 Sep 2026 the hosted relay's Render account was suspended and a new one stood
33
+ // up at a new address, and moving meant hunting down every copy -- any one missed would have
34
+ // sent that client to a dead host, or made this module call the live default "YOUR RELAY".
35
+ // Every client now imports it from here. The extension's JSON setting cannot import, so
36
+ // extension-privacy-parity-test pins it equal to this value instead.
37
+ export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
38
+
39
+ // Relays this project has run publicly and retired. A .hive.json written before a move still
40
+ // names the old one, and if that host ever answers again (a reactivated account, a squatter
41
+ // on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
42
+ // outside this list is called.
43
+ export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
44
+
45
+ const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
46
+
47
+ /**
48
+ * Normalise a relay URL to a comparable origin: scheme://host:port, lowercased, with the
49
+ * scheme's default port made explicit. Returns null for anything that is not a URL --
50
+ * callers must treat null as "unknown", never as "fine".
51
+ */
52
+ export function relayOrigin(url) {
53
+ const s = String(url || '').trim()
54
+ if (!s) return null
55
+ let u
56
+ try { u = new URL(s) } catch { return null }
57
+ const proto = u.protocol.toLowerCase()
58
+ if (!DEFAULT_PORT[proto]) return null
59
+ const port = u.port || DEFAULT_PORT[proto]
60
+ // ws:// and wss:// to the same host:port are the SAME relay reached two ways, and a pin
61
+ // written as https:// (which is what a person copies out of a browser) must match the
62
+ // wss:// the client actually dials. Scheme is reported separately by classifyRelay so
63
+ // the plaintext warning still fires; it deliberately does not participate in identity.
64
+ const secureScheme = proto === 'wss:' || proto === 'https:'
65
+ return `${secureScheme ? 'wss' : 'ws'}://${u.hostname.toLowerCase()}:${port}`
66
+ }
67
+
68
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'])
69
+
70
+ function isLoopback(host) {
71
+ const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
72
+ if (LOOPBACK.has(h)) return true
73
+ return /^127\./.test(h)
74
+ }
75
+
76
+ function isPrivateLan(host) {
77
+ const h = String(host || '').toLowerCase()
78
+ if (/^10\./.test(h)) return true
79
+ if (/^192\.168\./.test(h)) return true
80
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
81
+ if (/\.local$/.test(h) || /\.lan$/.test(h) || /\.internal$/.test(h)) return true
82
+ // A PRIVATE MESH (Tailscale and friends) IS A PRIVATE NETWORK, and the first version of this
83
+ // file called it "the public internet in plaintext". 100.64.0.0/10 is the shared address space
84
+ // Tailscale hands out and *.ts.net is its MagicDNS; the transport underneath is WireGuard,
85
+ // end-to-end encrypted between the two devices, and nothing outside the tailnet can even
86
+ // route to the relay. Warning a remote team that chose the most private setup available
87
+ // that their source was travelling in the clear would have taught them to ignore the verdict.
88
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true
89
+ if (/\.ts\.net$/.test(h)) return true
90
+ return false
91
+ }
92
+
93
+ // PUBLIC TUNNELS decrypt at their own edge. A relay on a laptop behind cloudflared or ngrok is
94
+ // the team's relay -- nobody else runs it -- but the tunnel provider terminates TLS and can read
95
+ // every frame, and anyone holding the URL can reach the port. Both facts matter to a team that
96
+ // picked a tunnel *for privacy*, and neither is visible in a URL that starts with wss://.
97
+ const PUBLIC_TUNNEL = /\.(trycloudflare\.com|ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok-free\.dev|loca\.lt|serveo\.net|localhost\.run)$/
98
+
99
+ function isPublicTunnel(host) {
100
+ return PUBLIC_TUNNEL.test(String(host || '').toLowerCase())
101
+ }
102
+
103
+ /**
104
+ * What kind of relay is this, and does traffic to it leave the machine in the clear?
105
+ *
106
+ * kind: 'public-default' | 'loopback' | 'private-lan' | 'tunnel' | 'own' | 'unknown'
107
+ *
108
+ * 'own' does not mean audited -- it means "not the relay we ship". A team that pastes a
109
+ * link from a blog post lands in 'own' too, which is exactly why the pin exists.
110
+ */
111
+ export function classifyRelay(url) {
112
+ const origin = relayOrigin(url)
113
+ if (!origin) return { kind: 'unknown', origin: null, host: null, encrypted: false, url: String(url || '') }
114
+ const u = new URL(String(url).trim())
115
+ const host = u.hostname.toLowerCase()
116
+ const encrypted = u.protocol === 'wss:' || u.protocol === 'https:'
117
+ let kind
118
+ if (origin === relayOrigin(PUBLIC_DEFAULT_RELAY) ||
119
+ RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) kind = 'public-default'
120
+ else if (isLoopback(host)) kind = 'loopback'
121
+ else if (isPrivateLan(host)) kind = 'private-lan'
122
+ else if (isPublicTunnel(host)) kind = 'tunnel'
123
+ else kind = 'own'
124
+ return { kind, origin, host, encrypted, url: String(url).trim() }
125
+ }
126
+
127
+ /**
128
+ * The pin, from the environment or from <dir>/.hive.json's `pinnedRelay`. Comma-separated,
129
+ * because a team legitimately has a staging relay and a production one.
130
+ *
131
+ * A pin that cannot be parsed is an ERROR, never an empty pin. That distinction is the
132
+ * whole value of the feature: a typo silently collapsing to "no pin" leaves somebody
133
+ * believing they are locked to their own relay while the client dials the public one --
134
+ * the same absence-rendered-as-a-value shape the room has been bitten by before.
135
+ */
136
+ export function readPin({ env = process.env, dir = null, fs = null } = {}) {
137
+ let raw = String((env && env.HIVE_RELAY_PIN) || '').trim()
138
+ let from = 'HIVE_RELAY_PIN'
139
+ if (!raw && dir && fs) {
140
+ try {
141
+ const cfg = JSON.parse(fs.readFileSync(`${dir}/.hive.json`, 'utf8'))
142
+ if (cfg && cfg.pinnedRelay) { raw = String(cfg.pinnedRelay).trim(); from = '.hive.json pinnedRelay' }
143
+ } catch { /* no config, or not JSON -- that is "no pin", not "bad pin" */ }
144
+ }
145
+ if (!raw) return { pinned: false, origins: [], from: null, raw: '' }
146
+ const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
147
+ const origins = []
148
+ const bad = []
149
+ for (const p of parts) {
150
+ const o = relayOrigin(p)
151
+ if (o) origins.push(o); else bad.push(p)
152
+ }
153
+ if (bad.length || !origins.length) {
154
+ return { pinned: true, origins, from, raw, error: `not a relay URL: ${bad.join(', ') || raw}` }
155
+ }
156
+ return { pinned: true, origins, from, raw }
157
+ }
158
+
159
+ const HEADLINE = {
160
+ 'public-default': 'PUBLIC RELAY — not yours',
161
+ loopback: 'PRIVATE — relay on this machine',
162
+ 'private-lan': 'PRIVATE — relay on your network',
163
+ tunnel: 'YOUR RELAY — through a public tunnel',
164
+ own: 'YOUR RELAY',
165
+ unknown: 'UNKNOWN RELAY',
166
+ }
167
+
168
+ /**
169
+ * The one call a client makes before opening a socket.
170
+ *
171
+ * Returns { ok, code, headline, detail, classification, pin }. `ok:false` means DO NOT
172
+ * CONNECT -- the caller must not open the socket and must not publish anything, because
173
+ * publishing is the leak this prevents. A warning (ok:true with `warn`) is printed and
174
+ * proceeds: a team on a plaintext LAN relay has made a choice, and refusing it would be
175
+ * this guard deciding policy it was not given.
176
+ *
177
+ * Q550 the relay is not one this machine is pinned to
178
+ * Q551 HIVE_PRIVATE is set and this is the public relay we ship
179
+ * Q552 the pin itself is malformed, so nothing is actually pinned
180
+ */
181
+ export function checkRelay({ relay, env = process.env, dir = null, fs = null } = {}) {
182
+ const c = classifyRelay(relay)
183
+ const pin = readPin({ env, dir, fs })
184
+ const strict = String((env && env.HIVE_PRIVATE) || '') === '1'
185
+
186
+ if (pin.pinned && pin.error) {
187
+ return {
188
+ ok: false, code: 'Q552', classification: c, pin,
189
+ headline: 'Q552 THE RELAY PIN IS MALFORMED — REFUSING TO CONNECT',
190
+ detail: [
191
+ `${pin.from} = ${pin.raw}`,
192
+ `problem: ${pin.error}`,
193
+ '',
194
+ 'A pin that cannot be parsed is refused rather than ignored. Ignoring it would leave',
195
+ 'you believing this machine is locked to your relay while it dials whatever it likes.',
196
+ 'Write it as a full URL, e.g. HIVE_RELAY_PIN=wss://relay.yourcompany.com',
197
+ ].join('\n'),
198
+ }
199
+ }
200
+
201
+ if (pin.pinned && c.origin && !pin.origins.includes(c.origin)) {
202
+ return {
203
+ ok: false, code: 'Q550', classification: c, pin,
204
+ headline: 'Q550 THIS RELAY IS NOT THE ONE THIS MACHINE IS PINNED TO — REFUSING TO CONNECT',
205
+ detail: [
206
+ `asked to connect to : ${c.url} (${c.origin})`,
207
+ `pinned to : ${pin.origins.join(', ')} (via ${pin.from})`,
208
+ '',
209
+ 'Nothing was opened and nothing was published, so no file has left this machine.',
210
+ c.kind === 'public-default'
211
+ ? 'The relay asked for is the PUBLIC one this build ships as a fallback — which is what\nyou get when a link is missing, a .hive.json was not found, or HIVE_RELAY is unset.'
212
+ : 'Check the join link you were given, and HIVE_RELAY in this process\'s environment.',
213
+ ].join('\n'),
214
+ }
215
+ }
216
+
217
+ if (!pin.pinned && strict && c.kind === 'public-default') {
218
+ return {
219
+ ok: false, code: 'Q551', classification: c, pin,
220
+ headline: 'Q551 HIVE_PRIVATE=1 AND THIS IS THE PUBLIC RELAY — REFUSING TO CONNECT',
221
+ detail: [
222
+ `relay: ${c.url}`,
223
+ '',
224
+ 'This is the relay this build falls back to when nobody chose one. Under HIVE_PRIVATE',
225
+ 'that fallback is an error rather than a default, because it is reached by omission —',
226
+ 'a missing link or an unset HIVE_RELAY — and never by anybody deciding on it.',
227
+ '',
228
+ 'Point HIVE_RELAY at your own relay, or unset HIVE_PRIVATE if you meant to use ours.',
229
+ ].join('\n'),
230
+ }
231
+ }
232
+
233
+ // Connecting. Say what that means, in one line, every time.
234
+ let warn = null
235
+ if (c.kind === 'public-default') {
236
+ warn = 'Your files are being sent to a relay run by the Hivecode author, not by you. ' +
237
+ 'Anyone who learns this room id can read them. For private work run your own relay ' +
238
+ '(npx hivecode-relay) and set HIVE_RELAY.'
239
+ } else if (c.kind === 'tunnel') {
240
+ warn = `${c.host} is a public tunnel. The relay is yours, but the tunnel provider decrypts ` +
241
+ 'traffic at its edge and can read it, and anyone with this URL can reach the relay. Fine for ' +
242
+ 'a trial with --secret set; for private remote work use a private network (e.g. Tailscale) ' +
243
+ 'or a server you control.'
244
+ } else if (c.kind === 'own' && !c.encrypted) {
245
+ warn = `ws:// to ${c.host} is NOT encrypted — your source travels the public internet in ` +
246
+ 'plaintext. Put TLS in front of the relay and use wss://.'
247
+ } else if (c.kind === 'unknown') {
248
+ warn = `"${c.url}" does not parse as a relay URL — the client will almost certainly fail ` +
249
+ 'to connect, and if it does connect you cannot tell where to.'
250
+ }
251
+
252
+ return {
253
+ ok: true, code: null, classification: c, pin, warn,
254
+ headline: `${HEADLINE[c.kind]}${c.encrypted ? '' : c.kind === 'loopback' ? '' : ' (UNENCRYPTED)'}`,
255
+ detail: warn || '',
256
+ }
257
+ }
258
+
259
+ /** One line for a join banner / status block: the URL plus what it means. */
260
+ export function privacyLine(relay, opts = {}) {
261
+ const v = checkRelay({ relay, ...opts })
262
+ return `relay: ${relay} [${v.headline}]`
263
+ }
@@ -0,0 +1,233 @@
1
+ // hive-receipts.js — did the message actually reach anybody, or did it only look like it did?
2
+ //
3
+ // THE DEFECT THIS EXISTS FOR, from this project's own history:
4
+ //
5
+ // "29 messages @-mentioned me today. hive_wait has surfaced a handful."
6
+ // "Those messages land in HIVE_CHAT.md and look delivered -- they just may never reach the
7
+ // recipient's context. The convention looks like it works, which is the worst property a
8
+ // coordination protocol can have."
9
+ //
10
+ // `hive_say` returns "sent to @Builder: ..." the instant the message enters the LOCAL Yjs array.
11
+ // It says that while the socket is down. It says that when @Builder crashed ten minutes ago. It
12
+ // says that when @Builder is looping without ever calling hive_wait. Three different failures,
13
+ // one cheerful word, and the sender has no way to tell them apart.
14
+ //
15
+ // WHAT THE EVIDENCE IS, HERE. Agent Relay verifies a delivery by watching the terminal echo the
16
+ // keystrokes back (crates/broker/src/broker/delivery_verification.rs). There is no terminal here,
17
+ // so copying the mechanism would be cargo cult. The equivalent evidence in Hivecode is the one
18
+ // thing that only moves when a message is actually handed into an agent's context:
19
+ //
20
+ // THE READ CURSOR IS THE ECHO.
21
+ //
22
+ // A mailbox cursor advances only inside `box.take()`, which is what hands messages to the agent.
23
+ // So "their cursor passed the slot my message sits in" means the message was in the payload that
24
+ // agent received. Today that cursor is written to a LOCAL session file, where nobody else can see
25
+ // it. Publishing it into the room is the whole change.
26
+ //
27
+ // FOUR STATES, AND THE FOURTH IS THE POINT. Agent Relay has three (Success / Unverified /
28
+ // Failed) and the load-bearing one is Unverified -- an unconfirmed delivery must never be counted
29
+ // as a confirmed one. Their comment: "Unverified neither speeds up nor backs off the throttle,
30
+ // but breaks the consecutive-success streak so unverified deliveries never drive the delay down."
31
+ //
32
+ // The same rule here, phrased for this system:
33
+ //
34
+ // READ their cursor passed it. Evidence it landed.
35
+ // UNREAD they publish cursors, and theirs is behind it. Evidence it did NOT.
36
+ // UNKNOWN they publish no cursor at all. NO evidence either way -- and it must never be
37
+ // counted as READ. A Telegram bridge, a human's browser and an older build all
38
+ // land here, so silence from this state means nothing at all.
39
+ // UNREACHABLE they are not in the room, or WE are offline. It is not going to arrive.
40
+ //
41
+ // UNKNOWN is the honest state that a two-state design (delivered / not) cannot express, and
42
+ // collapsing it into either neighbour is how a receipt system starts lying. `countDelivered`
43
+ // exists precisely so that no caller can accidentally sum UNKNOWN into the good number.
44
+ //
45
+ // NO STORED INDEX. A Yjs array index is not stable: two peers pushing concurrently converge in a
46
+ // deterministic order that need not be the order either one observed, so a message's index can
47
+ // move after it was recorded. Receipts are therefore DERIVED at query time by locating the
48
+ // message in the converged array -- the same choice `unansweredFor` made in hive-mailbox.js, for
49
+ // the same reason: nothing to migrate and nothing that can drift.
50
+
51
+ // @-parsing is imported, never re-implemented. A second hand-written copy of "who was named in
52
+ // this text" is exactly the shape Agent Relay wrote up in learnings/duplicated-wire-contract.md
53
+ // and exactly the shape that already bit this repo once as a duplicated reconcile(): two
54
+ // descriptions of one thing, only one of which is ever checked against reality.
55
+ import { mentionedNames, mentionsAll } from './hive-mailbox.js'
56
+
57
+ export const READ = 'READ'
58
+ export const UNREAD = 'UNREAD'
59
+ export const UNKNOWN = 'UNKNOWN'
60
+ export const UNREACHABLE = 'UNREACHABLE'
61
+
62
+ /** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
63
+ export const DELIVERED_STATES = [READ]
64
+
65
+ const lc = (x) => String(x == null ? '' : x).toLowerCase()
66
+
67
+ /**
68
+ * Publish my read position into the room.
69
+ *
70
+ * `at` is a count of messages consumed, so it only ever moves forward. It is clamped against
71
+ * going BACKWARDS because a rejoin can hand a session a stale persisted cursor, and a receipt
72
+ * that un-reads a message somebody already answered is worse than no receipt at all.
73
+ */
74
+ export function publishCursor(cursors, name, at, now = Date.now) {
75
+ if (!cursors || !name) return null
76
+ const prev = cursors.get(String(name))
77
+ const next = Math.max(0, Number(at) || 0)
78
+ const highest = Math.max(next, (prev && Number(prev.at)) || 0)
79
+ const rec = { at: highest, ts: now(), ver: 1 }
80
+ // Writing an identical value would still be a CRDT update, waking every observer in the room
81
+ // on every tool call for no information. Only a real advance is published.
82
+ if (prev && prev.at === highest) return prev
83
+ cursors.set(String(name), rec)
84
+ return rec
85
+ }
86
+
87
+ /** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
88
+ export function cursorOf(cursors, name) {
89
+ if (!cursors) return null
90
+ const direct = cursors.get(String(name))
91
+ if (direct) return direct
92
+ for (const [k, v] of cursors.entries()) if (lc(k) === lc(name)) return v
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * Where a message sits in the converged chat array, or -1.
98
+ *
99
+ * Identity is (by, at, text) rather than an index: the triple survives reordering, and the index
100
+ * does not. Searched from the end because a receipt is almost always asked about something said
101
+ * recently, and the room's history can be thousands of lines.
102
+ */
103
+ export function findIndex(chat, msg) {
104
+ if (!Array.isArray(chat) || !msg) return -1
105
+ for (let i = chat.length - 1; i >= 0; i--) {
106
+ const m = chat[i]
107
+ if (m && m.by === msg.by && m.at === msg.at && m.text === msg.text) return i
108
+ }
109
+ return -1
110
+ }
111
+
112
+ /**
113
+ * The state of ONE message for ONE recipient.
114
+ *
115
+ * Order matters. `connected === false` outranks everything, because if our own socket is down the
116
+ * message has not left this process and nothing we believe about the other side is worth saying.
117
+ * Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
118
+ * actionable of the two.
119
+ */
120
+ export function stateFor({ cursors, name, index, present = true, connected = true }) {
121
+ if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
122
+ if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
123
+ if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
124
+ const cur = cursorOf(cursors, name)
125
+ if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
126
+ // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
127
+ // been handed over exactly when the cursor is strictly greater than it.
128
+ if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
129
+ return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
130
+ }
131
+
132
+ /**
133
+ * The state of ONE message for ONE recipient, ASKED AS A DIFFERENT QUESTION.
134
+ *
135
+ * `stateFor` above answers "should I wait for a reply from them right now?", so absence outranks
136
+ * a cursor: an agent that read your message and then left still cannot answer you, and blocking on
137
+ * them is the failure that function exists to prevent. That is correct, it is deliberate, and
138
+ * tests/receipts-test.mjs D1 pins it in capital letters.
139
+ *
140
+ * This one answers a different question: "did this message ever land?" — and there the cursor
141
+ * outranks absence, because A READ IS A FACT ABOUT THE PAST AND LOGGING OFF CANNOT UNDO IT.
142
+ *
143
+ * WHY TWO FUNCTIONS AND NOT A FLAG. A boolean that silently changes what the returned STRING means
144
+ * is how this class of bug is born: a caller that forgets it gets a confident answer to a question
145
+ * it did not ask. Two names cannot be confused by omission.
146
+ *
147
+ * WHY THIS ORDER, AND IT IS THE WHOLE DESIGN:
148
+ * - `!cur` is checked BEFORE `!present`. A human in a browser, a Telegram bridge and every older
149
+ * build publish no cursor at all. Reaching `!present` first would render them UNREACHABLE with
150
+ * the claim "they left without reading it" — a claim we cannot support, because we have no
151
+ * evidence either way. That is precisely what UNKNOWN exists to express, and folding it into
152
+ * UNREACHABLE would make every human in the room read as a delivery failure forever.
153
+ * - `!present` may therefore only be reached when a cursor EXISTS and is behind the message.
154
+ * Only then is "they left without reading it" a measurement rather than a guess.
155
+ * - `connected` is absent on purpose. The cursors map is LOCAL. Our own socket being down right
156
+ * now says nothing about whether a three-day-old message was read; that was recorded and is
157
+ * already here. Gate this on connectivity and the whole transcript blanks to UNKNOWN during
158
+ * every reconnect — exactly when a human is reading it to work out what happened.
159
+ *
160
+ * Built by DeepMind-Agent (Gemini, in its own container) in room `night`, 20 Sep 2026, against a
161
+ * design argued in the room over four rounds. Tests are antigravity1's, written before the code
162
+ * existed and verified red first: tests/transcript-receipts-test.mjs.
163
+ */
164
+ export function transcriptStateFor({ cursors, name, index, present = true }) {
165
+ if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
166
+ const cur = cursorOf(cursors, name)
167
+ if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
168
+ // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
169
+ // been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
170
+ // at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
171
+ if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
172
+ // Only reachable when a cursor exists and is behind the message.
173
+ if (!present) return { name, state: UNREACHABLE, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is not in the room, and left with cursor behind this message` }
174
+ return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
175
+ }
176
+
177
+ /** Only READ counts. Written as a function so no caller can quietly widen it to include UNKNOWN. */
178
+ export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.includes(s.state)).length
179
+
180
+ /**
181
+ * Every message I addressed to somebody, with per-recipient state.
182
+ *
183
+ * Unaddressed messages are skipped on purpose: they land in the ambient digest, where agents are
184
+ * instructed to skip them, so a receipt for one would be measuring something nobody promised.
185
+ * `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
186
+ * one message obliging eight receipts is the storm, not the signal.
187
+ */
188
+ export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
189
+ const list = Array.isArray(chat) ? chat : []
190
+ const out = []
191
+ for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
192
+ const m = list[i]
193
+ if (!m || lc(m.by) !== lc(me)) continue
194
+ if (mentionsAll(m.text)) continue
195
+ const named = mentionedNames(m.text).filter((n) => lc(n) !== 'all' && lc(n) !== lc(me))
196
+ if (!named.length) continue
197
+ out.push({
198
+ at: m.at,
199
+ text: String(m.text || '').slice(0, 120),
200
+ to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
201
+ })
202
+ }
203
+ return out.reverse()
204
+ }
205
+
206
+ const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
207
+
208
+ /** One line per message. The marks make a column a human can scan; the words carry the meaning. */
209
+ export function describe(r) {
210
+ return `${r.at} ${r.to.map((s) => `${MARK[s.state] || '?'} ${s.name}=${s.state}`).join(' ')} "${r.text}"`
211
+ }
212
+
213
+ /**
214
+ * What hive_say should say instead of "sent".
215
+ *
216
+ * ONE FAILURE ONLY, AND THAT IS DELIBERATE. At send time delivery is not knowable -- the message
217
+ * was created a millisecond ago -- so the single thing worth saying is the one that is already
218
+ * decided and is currently reported as success: our own socket is down.
219
+ *
220
+ * It says nothing about an ABSENT recipient even though that is equally decidable, because
221
+ * `describeRecipient` in hive-busy.js already says it ("@X is NOT in the room — nobody will read
222
+ * that") and a second hand-written copy of that sentence is precisely the defect class Agent
223
+ * Relay wrote up in learnings/duplicated-wire-contract.md: two descriptions of one fact, only one
224
+ * of which anybody keeps current. One producer, one description.
225
+ *
226
+ * On success it returns the empty string. A reassuring word at send time IS the defect.
227
+ */
228
+ export function sendWarning({ connected }) {
229
+ if (connected) return ''
230
+ return 'NOT SENT — this client is OFFLINE. The message is in your local copy only; it has not ' +
231
+ 'reached the relay and no one else can see it. It will go out by itself when the connection ' +
232
+ 'heals (do NOT call hive_join, that tears down the session). Until then, treat it as unsent.'
233
+ }