hivecode-mcp 0.11.5 → 0.11.6

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,247 @@
1
+ // hive-obligations.js — a question you asked stays open until YOU say it was answered.
2
+ //
3
+ // THE PROBLEM THIS EXISTS FOR, observed live on 19 Sep 2026.
4
+ //
5
+ // Tester posted one message at 12:29 addressed to @Reviewer, an agent that was never in the
6
+ // room, and then went silent for six hours. Nothing failed. No tool returned an error, no
7
+ // process died, no log line said anything was wrong. The room simply had a question in it that
8
+ // nobody would ever answer, and the only way to find that out was for a human to read the
9
+ // transcript and notice.
10
+ //
11
+ // Hivecode already carries the RULE -- the MCP instructions say "ANSWER EVERY QUESTION AIMED AT
12
+ // YOU: silence is the one thing the asker cannot act on". A rule an agent read once is a rule it
13
+ // forgets the moment its context fills. So the rule moves here, where it is a data structure
14
+ // instead of a paragraph.
15
+ //
16
+ // THE ONE LOAD-BEARING DECISION: ONLY THE ASKER DISCHARGES.
17
+ //
18
+ // The obvious design lets the answerer mark it answered. That design measures nothing. An agent
19
+ // that replies "I'll look into it" would clear its own obligation, and a question answered wrongly
20
+ // clears just as cleanly as one answered well. Both are exactly the failure -- everything green,
21
+ // the result wrong -- that every defect on this project has turned out to be.
22
+ //
23
+ // So: `from` opened it, and only `from` can close it. The debtor cannot mark its own homework.
24
+ // (Agent Relay reached the same conclusion independently in crates/broker/src/obligation.rs:
25
+ // "only the author can discharge an obligation... the broker checks reactor == author". Their
26
+ // broker re-injects a knock on a timer, which costs tokens on every knock. Ours rides hive_wait,
27
+ // which is already blocked and already free.)
28
+ //
29
+ // ESCALATION IS TO AN AUDIENCE, NOT A FREQUENCY. A knock that repeats every minute forever is
30
+ // noise, and a room learns to ignore noise. So the interval backs OFF (10min, 20min, 40min...)
31
+ // while the visibility goes UP: after `STALE_AFTER` knocks the record is stale, and a stale
32
+ // obligation is the room's problem rather than one agent's.
33
+ //
34
+ // PURE ON PURPOSE. Every function here takes the store and the clock. The store needs only
35
+ // get/set/delete/entries, which a Y.Map and a plain Map both satisfy -- so the tests run against
36
+ // a Map with an injected clock and no relay, and the production path passes the Y.Map. That is
37
+ // the same shape as governor.js, and it is what let the governor's 10x minutes bug be caught by
38
+ // a test instead of by a customer.
39
+
40
+ /** Default time before the first knock. Ten minutes: long enough that an agent mid-task is not
41
+ * nagged for thinking, short enough that a stall is caught inside one working stretch. */
42
+ export const DUE_MS = 10 * 60000
43
+
44
+ /** Each knock doubles the wait, so a genuinely long job is not pestered... */
45
+ export const BACKOFF = 2
46
+
47
+ /** ...but never past this, or a forgotten question goes quiet for a whole shift. */
48
+ export const MAX_DUE_MS = 60 * 60000
49
+
50
+ /** Knocks before the room is told. Three because two is a coincidence and four is too late. */
51
+ export const STALE_AFTER = 3
52
+
53
+ /** Chat text is a whole message; an obligation is an index entry. Keep it short enough that
54
+ * `hive_owed` on a busy room stays readable rather than becoming a second transcript. */
55
+ export const TEXT_MAX = 140
56
+
57
+ const clip = (s) => {
58
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
59
+ return t.length > TEXT_MAX ? t.slice(0, TEXT_MAX - 1) + '…' : t
60
+ }
61
+
62
+ /** Ids must not collide across peers. Two agents opening an obligation in the same millisecond is
63
+ * not hypothetical in a CRDT room -- every write lands, and a shared key would silently make one
64
+ * of the two questions disappear. Name + time + entropy: the name alone makes it readable in a
65
+ * log, which the entropy alone would not.
66
+ *
67
+ * THE TAG IS TWO DRAWS, NOT ONE, and that is not belt-and-braces. A single `rand() * 1e6` gives
68
+ * about 2^20 of space, and by the birthday bound 500 ids drawn from it collide roughly 12% of the
69
+ * time -- which is exactly how this was found: the suite passed, then failed, then passed again on
70
+ * unchanged code. A flaky test here is not test flakiness, it is the defect reporting itself. Two
71
+ * draws give ~2^40, where the same 500 ids collide about one run in five million. */
72
+ export function obligationId(from, at, rand = Math.random) {
73
+ const draw = () => Math.floor(rand() * 1e6).toString(36).padStart(4, '0')
74
+ return `ob_${String(from || '?').toLowerCase().replace(/[^a-z0-9]+/g, '')}_${at.toString(36)}_${draw()}${draw()}`
75
+ }
76
+
77
+ /**
78
+ * Open an obligation: `from` asked `to` something and is owed an answer.
79
+ *
80
+ * `absent` is recorded at OPEN time and never recomputed. That is deliberate -- it is the answer
81
+ * to "was anyone even there when this was asked", which is a fact about the past. Recomputing it
82
+ * later would let a question asked into an empty room look fine the moment someone walks in.
83
+ */
84
+ /**
85
+ * PRESENCE AND PROVENANCE ARE DIFFERENT QUESTIONS, AND `absent` ONLY ANSWERS THE FIRST.
86
+ *
87
+ * `absent` is stamped at send time and means "nobody by that name was connected at this instant".
88
+ * That is the right question for the warning — it is how the 19 Sep stall was caught — and it is
89
+ * the WRONG question for how long the record should live, because an agent mid-reconnect or
90
+ * mid-ratelimit is absent and is also a real teammate who really owes an answer.
91
+ *
92
+ * Measured in room `night`, 20 Sep 2026: an obligation against @DeepMind-Agent was stamped absent
93
+ * because it had dropped between turns. It came back ninety minutes later and answered. Expiring
94
+ * that record on a ten-minute timer would have deleted a genuine debt — and worse, it would have
95
+ * emptied the ledger precisely while an agent was silent, which is the exact inverse of what the
96
+ * ledger is for.
97
+ *
98
+ * So `neverSeen` is the separate fact: nobody by this name has ever joined or spoken here. That is
99
+ * the noise — @gmail, @modelcontextprotocol, a name quoted out of a test suite. It is safe to
100
+ * expire and safe for anyone to dismiss, because there is no debtor whose honesty the author-only
101
+ * rule exists to protect.
102
+ */
103
+ export function open(store, { from, to, text, dueMs = DUE_MS, absent = false, neverSeen = false }, now = Date.now, rand = Math.random) {
104
+ const at = now()
105
+ const rec = {
106
+ id: obligationId(from, at, rand),
107
+ from: String(from),
108
+ to: String(to),
109
+ text: clip(text),
110
+ at,
111
+ due: at + Math.max(1, dueMs),
112
+ dueMs: Math.max(1, dueMs),
113
+ knocks: 0,
114
+ absent: !!absent,
115
+ neverSeen: !!neverSeen,
116
+ done: false,
117
+ }
118
+ store.set(rec.id, rec)
119
+ return rec
120
+ }
121
+
122
+ const all = (store) => [...store.entries()].map(([, v]) => v).filter(Boolean)
123
+
124
+ /** Everything still open. */
125
+ /**
126
+ * Everything still owed — with never-seen records aging out on their own.
127
+ *
128
+ * EXPIRY IS MATERIALISED, NOT MERELY FILTERED, and that distinction is load-bearing. Filtering
129
+ * here would leave `done: false` on the record, and `isStale` reads `r.done` directly rather than
130
+ * going through this function — so an expired ghost would vanish from one view and linger in
131
+ * another. Two readers disagreeing about whether a thing is finished is the same defect this whole
132
+ * file exists to prevent, one level down.
133
+ *
134
+ * Written lazily on read, which is the pattern the claim layer already uses (`hive-coord.js`
135
+ * expires a lease the moment somebody looks). There is no timer to miss and nothing to schedule:
136
+ * the fact becomes true at `due`, and the first reader after that writes it down.
137
+ *
138
+ * NO RESURRECTION. `neverSeen` is stamped when the question is asked and never recomputed, so a
139
+ * name that finally joins hours later does not revive its expired debt. The question was asked
140
+ * into an empty room; somebody arriving afterwards does not change what was true then.
141
+ *
142
+ * Only never-seen records expire. A known teammate's debt survives any amount of silence, because
143
+ * silence is the thing it is there to make visible.
144
+ */
145
+ export const openList = (store, now = Date.now) => {
146
+ const out = []
147
+ for (const r of all(store)) {
148
+ if (r.done) continue
149
+ if (r.neverSeen && now() >= r.due) {
150
+ store.set(r.id, { ...r, done: true, doneAt: now(), doneBy: 'auto-expired (never seen here)' })
151
+ continue
152
+ }
153
+ out.push(r)
154
+ }
155
+ return out
156
+ }
157
+
158
+ /** What `name` OWES: questions aimed at them that their asker has not signed off. */
159
+ export const owedBy = (store, name) =>
160
+ openList(store).filter((r) => String(r.to).toLowerCase() === String(name).toLowerCase())
161
+
162
+ /** What `name` IS OWED: questions they asked that they have not signed off. */
163
+ export const owedTo = (store, name) =>
164
+ openList(store).filter((r) => String(r.from).toLowerCase() === String(name).toLowerCase())
165
+
166
+ /**
167
+ * Discharge. Returns `{ ok, reason }` and NEVER throws, because the interesting cases are all
168
+ * refusals and a refusal has to be readable by the agent that hit it.
169
+ *
170
+ * The `by !== from` refusal is the whole mechanism. It is phrased as instruction rather than
171
+ * error, because the agent reading it is usually the debtor trying to close its own ticket and
172
+ * the useful next action is "answer, then ask them to close it".
173
+ */
174
+ export function discharge(store, { id, by }, now = Date.now) {
175
+ const rec = store.get(id)
176
+ if (!rec) return { ok: false, reason: `no obligation ${id} -- it may already be discharged` }
177
+ if (rec.done) return { ok: true, already: true, rec }
178
+ // THE AUTHOR-ONLY RULE PROTECTS A DEBTOR FROM GRADING ITS OWN HOMEWORK. A never-seen record has
179
+ // no debtor: nobody by that name has ever been here, so there is nobody whose self-assessment
180
+ // the rule is guarding against. Holding the rule there protects nothing and costs the room an
181
+ // entry only the original asker can clear -- and tonight the original asker cleared forty of
182
+ // them by hand. Anyone may tidy these; real debts keep the rule untouched.
183
+ if (!rec.neverSeen && String(rec.by || rec.from).toLowerCase() !== String(by).toLowerCase()) {
184
+ return {
185
+ ok: false,
186
+ reason: `${by} cannot discharge this: ${rec.from} asked it, and only the asker can say it ` +
187
+ `was answered. If you are ${rec.to}, answer it in chat and ${rec.from} will close it -- ` +
188
+ `marking your own answer sufficient is exactly the check this exists to make.`,
189
+ }
190
+ }
191
+ const done = { ...rec, done: true, doneAt: now(), doneBy: String(by) }
192
+ store.set(id, done)
193
+ return { ok: true, rec: done }
194
+ }
195
+
196
+ /** Close everything `by` is owed from one agent. An asker who got a long reply covering four
197
+ * questions should not have to look up four ids to say so. */
198
+ export function dischargeFrom(store, { by, to }, now = Date.now) {
199
+ const hit = owedTo(store, by).filter((r) => String(r.to).toLowerCase() === String(to).toLowerCase())
200
+ for (const r of hit) discharge(store, { id: r.id, by }, now)
201
+ return hit
202
+ }
203
+
204
+ /** Open obligations whose knock time has passed. */
205
+ export const overdue = (store, name, now = Date.now) =>
206
+ owedBy(store, name).filter((r) => now() >= r.due)
207
+
208
+ /**
209
+ * Record that a knock was delivered, and push the next one out.
210
+ *
211
+ * Called only when the knock actually reached somebody -- if we advanced `due` on merely NOTICING
212
+ * an overdue record, a debtor that never calls hive_wait would have its deadline extended forever
213
+ * by an observer, which is precisely backwards.
214
+ */
215
+ export function knock(store, id, now = Date.now) {
216
+ const rec = store.get(id)
217
+ if (!rec || rec.done) return null
218
+ const nextDueMs = Math.min(MAX_DUE_MS, Math.max(1, rec.dueMs) * BACKOFF)
219
+ const next = { ...rec, knocks: rec.knocks + 1, dueMs: nextDueMs, due: now() + nextDueMs, lastKnockAt: now() }
220
+ store.set(id, next)
221
+ return next
222
+ }
223
+
224
+ /** Knocked enough times that this is the room's problem now, not one agent's. */
225
+ export const isStale = (r) => !r.done && r.knocks >= STALE_AFTER
226
+
227
+ /** Every stale obligation in the room, for the board and for the human. */
228
+ export const stale = (store) => openList(store).filter(isStale)
229
+
230
+ const mins = (ms) => Math.round(ms / 6000) / 10
231
+
232
+ /** One line, for chat and for tool output. Age is what makes it actionable: "Tester owes Claude
233
+ * an answer" is a fact, "for 361 minutes" is a decision. */
234
+ export function describe(r, now = Date.now) {
235
+ const age = mins(now() - r.at)
236
+ const flags = [
237
+ r.absent ? 'NOBODY BY THAT NAME WAS IN THE ROOM' : '',
238
+ isStale(r) ? `STALE after ${r.knocks} reminders` : '',
239
+ ].filter(Boolean)
240
+ return `${r.id} ${r.to} owes ${r.from} (${age}m) "${r.text}"${flags.length ? ' [' + flags.join('; ') + ']' : ''}`
241
+ }
242
+
243
+ /** The whole board, newest question last so it reads like a conversation. */
244
+ export function board(store, now = Date.now) {
245
+ const list = openList(store).sort((a, b) => a.at - b.at)
246
+ return { open: list.length, stale: list.filter(isStale).length, lines: list.map((r) => describe(r, now)) }
247
+ }
@@ -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
+ }