hivecode-mcp 0.12.1 → 0.12.4

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.
package/hive-mailbox.js CHANGED
@@ -1,386 +1,517 @@
1
- // The room mailbox: what has this participant actually read?
2
- //
3
- // WHY THIS EXISTS. `hive_wait` used to capture `baseChatLen = chat.length` at the moment it
4
- // was CALLED, and only ever returned `chat.slice(baseChatLen)`. So anything posted between one
5
- // wait returning and the next one starting — which is precisely the window where an agent is
6
- // doing its work — sat below the cursor and was returned by NO code path. Not delayed:
7
- // unreachable. Two further holes finished the job: a wait that had queued work returned
8
- // `newChat: []` hardcoded, so the busiest agent in the room saw no messages at all; and
9
- // mention detection existed only for PAUSED agents, so a working agent could not tell "stop,
10
- // you are editing the wrong file" from two other agents chatting.
11
- //
12
- // Measured on a real four-agent build (JARVIS, 2026-07-28): 345 @mentions in the room log, 13
13
- // acknowledgements. The PM agent ended up replacing delivery with a bash script that tails
14
- // HIVE_CHAT.md, which is the verdict on the old design.
15
- //
16
- // So the cursor is now a persistent per-participant position that only advances over messages
17
- // that were actually handed to the agent. This module is deliberately pure — plain arrays in,
18
- // plain objects out, no Yjs and no I/O — so the delivery rules are unit-testable without a
19
- // relay, a room, or a network.
20
-
21
- /** A chat message as stored in the room: { by, kind, at, text }. */
22
-
23
- /**
24
- * Does this text address `me`? Matches `@Name`, case-insensitively, at a word boundary so
25
- * `@PM` does not swallow `@PM-Claude` and `@Hermes` does not match `@Hermes-2`.
26
- * @param {string} text
27
- * @param {string} me
28
- */
29
- export function mentionsName(text, me) {
30
- if (!text || !me) return false
31
- if (mentionsAll(text)) return true // @all reaches everyone by definition
32
- const escaped = String(me).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
33
- // Both boundaries, and the same ones the extractor uses. If these two ever disagree, a message
34
- // can be extracted as addressing someone who does not match it, or match someone it was never
35
- // extracted for -- which is how a mailbox and a transcript come to tell different stories.
36
- return new RegExp(`${MENTION_START}@${escaped}${MENTION_END}`, 'i').test(String(text))
37
- }
38
-
39
- /**
40
- * `@all` — the deliberate broadcast.
41
- *
42
- * Before this there was no way to reach the whole room on purpose. An unaddressed message goes
43
- * into every agent's ambient digest, where the rules tell them to skip it -- correct for
44
- * chatter, useless for "everyone stop, the suite is red". So the only two options were name
45
- * eight agents by hand or be ignored.
46
- *
47
- * It wakes everyone. It deliberately does NOT create a reply debt (see unansweredFor): one
48
- * broadcast obliging eight replies is how a coordination channel becomes a spam channel, and
49
- * that is the exact failure this room already had once.
50
- */
51
- export function mentionsAll(text) {
52
- // CODE SPANS ARE STRIPPED FIRST, AND ONLY HERE. Every other false mention is handled by the
53
- // boundary rules below plus `obligationTargets`, which refuses to open a debt against somebody
54
- // who is not in the room. `@all` is the one case neither can save: it is not a member, so the
55
- // membership gate does not apply to it, and it wakes EVERYONE. So writing `@all` inside
56
- // backticks -- which is how anybody documents this feature -- paged the entire room, every time.
57
- //
58
- // This is deliberately NOT a Markdown parser. Nested spans, unclosed fences and indented blocks
59
- // are all out of scope, because the question is not "what is code here" but the much smaller
60
- // "is this one token wrapped in backticks". A tokenizer would be the wrong tool and a permanent
61
- // source of edge cases; one replace is the whole fix.
62
- return /@all(?![\w-])/i.test(stripCodeSpans(text))
63
- }
64
-
65
- /** Inline code spans blanked out, so a token quoted as code is not read as an address. */
66
- const stripCodeSpans = (text) => String(text || '').replace(/`[^`]*`/g, ' ')
67
-
68
- /**
69
- * WHERE A MENTION MAY BEGIN, AND WHERE IT MAY NOT.
70
- *
71
- * Measured in room `night` on 20 Sep 2026, against the old bare /@([\w-]+)/g:
72
- *
73
- * "email me at ganesh@gmail.com" -> ["gmail"] every email address
74
- * "blame line: author@example.org" -> ["example"] every commit trailer
75
- * "npm i @modelcontextprotocol/sdk" -> ["modelcontextprotocol"] every scoped package
76
- * "see `@all` in the docs" -> broadcast to everyone every doc about this feature
77
- *
78
- * After three hours the obligation ledger held 44 entries of which 32% were debts against members
79
- * who had never existed, and a debt can only be discharged by the asker, so nothing in the system
80
- * could ever have closed them. The ledger is what catches an agent going silent; it was becoming
81
- * unreadable through ordinary use.
82
- *
83
- * LEADING: start of string, whitespace, an opening bracket, a quote, a dash, or a colon. Quotes
84
- * are INCLUDED on purpose. A quoted example ("the parser reads "@antigravity"") is then extracted
85
- * for the transcript and creates no debt, because `obligationTargets` gates on membership. The
86
- * opposite choice would silently drop a real mention that opens a quoted sentence, and a dropped
87
- * mention is the worse failure: the sender believes it was delivered.
88
- *
89
- * TRAILING: not a word character, a hyphen, or a SLASH. The slash is what distinguishes
90
- * `@scope/package` from `@person`. It is written as one lookahead rather than a separate check so
91
- * the regex cannot backtrack into a shorter name to satisfy it -- `[\w-]+` followed by `(?!\/)`
92
- * alone would happily match "modelcontextprotoco" and declare victory.
93
- */
94
- const MENTION_START = '(?:^|[\\s(\\[{<"\'—–:])'
95
- const MENTION_END = '(?![\\w\\-/])'
96
-
97
- /** Everyone addressed in a message, so a broadcast can be told from a direct call. */
98
- export function mentionedNames(text) {
99
- const re = new RegExp(`${MENTION_START}@([\\w-]+)${MENTION_END}`, 'g')
100
- return [...new Set([...String(text || '').matchAll(re)].map((m) => m[1]))]
101
- }
102
-
103
- /**
104
- * WHO ACTUALLY OWES AN ANSWER — which is not the same question as who was named.
105
- *
106
- * Extraction and obligation were one decision and they are two. A transcript should show what the
107
- * text said, including a name that is merely quoted; a DEBT is a claim about the present, and
108
- * opening one against somebody who is not here produces an entry nobody can ever discharge, since
109
- * only the asker may close it and the debtor does not exist.
110
- *
111
- * The distinction that makes this safe is which way time points. Resolving a HISTORICAL mention
112
- * against who is present now would be the Historical Transcript Fallacy -- the past decided by the
113
- * present -- and that is why extraction deliberately does NOT do it. An obligation is not history:
114
- * it is live state about who owes me an answer right now, so checking present membership is
115
- * exactly the right question for it.
116
- *
117
- * @param {string} text
118
- * @param {{ me?: string, present?: Iterable<string> }} ctx `present` is lowercased names
119
- */
120
- export function obligationTargets(text, { me = '', present = [] } = {}) {
121
- const here = new Set([...present].map((n) => String(n).toLowerCase()))
122
- const self = String(me).toLowerCase()
123
- return mentionedNames(text).filter((n) => {
124
- const low = n.toLowerCase()
125
- // @all wakes the room and obliges nobody -- one broadcast owing eight replies is how a
126
- // coordination channel becomes a spam channel. Unchanged from the original behaviour.
127
- if (low === 'all' || low === self) return false
128
- return here.has(low)
129
- })
130
- }
131
-
132
- /**
133
- * A NAME THAT CANNOT BE ADDRESSED IS NOT A NAME. Refuse it at the door.
134
- *
135
- * On 20 Sep 2026 an agent joined room `night` as "antigravity 1". `mentionedNames` above splits
136
- * on /@([\w-]+)/g, so "@antigravity 1" reads as "@antigravity", and no such member existed. Its
137
- * mail, its obligations and its wake-on-mention were all silently dead for an hour. It saw
138
- * messages only because it happened to be tailing the chat log. Nothing errored; the room said
139
- * "@antigravity is NOT in the room", which was true and useless, because the member WAS present
140
- * under a name this parser cannot produce.
141
- *
142
- * WHY REFUSE RATHER THAN NORMALISE. Silently rewriting "coder 1" to "coder-1" collides with a
143
- * real "coder-1" and merges two processes into one participant — one awareness entry, one cursor,
144
- * one mailbox — with neither told. And the agent's own prompt still says its name is the original,
145
- * so it stops recognising messages about itself.
146
- *
147
- * WHY REFUSE RATHER THAN RESOLVE @mentions AGAINST THE MEMBER LIST. That would make a departed
148
- * member break the historical record: a fact about the past decided by who happens to be present
149
- * now. Same defect as the receipts work in HIV-P2.
150
- *
151
- * WHY THIS CHARSET AND NOT A NICER ONE. It is not taste, it is arithmetic. The extractor above
152
- * only recognises [\w-]. A name outside that set CANNOT be produced by it, however carefully the
153
- * sender types. Constraining names to the charset the parser already uses makes the two
154
- * consistent by construction instead of by coincidence.
155
- *
156
- * KNOWN LIMIT, stated because it is a product decision and not an oversight: `\w` is ASCII, so a
157
- * name in Devanagari, Han or Cyrillic is refused. That is honest rather than good — those names
158
- * are equally unaddressable today, and refusing at least says so out loud. Widening it means
159
- * widening the extractor first; the two move together or not at all.
160
- *
161
- * @param {any} name
162
- * @returns {{ ok: boolean, error?: string, suggested?: string }}
163
- */
164
- export function validateMemberName(name) {
165
- if (name == null || typeof name !== 'string' || name.length === 0) {
166
- return {
167
- ok: false,
168
- error: 'Member name must be a non-empty string so that the participant can be addressed via @mentions.',
169
- suggested: 'agent-1',
170
- }
171
- }
172
- // Whitespace is checked BEFORE the charset so the message names the actual problem. "contains
173
- // whitespace" tells you what to do; "contains invalid characters" makes you hunt for which one.
174
- if (/\s/.test(name)) {
175
- return {
176
- ok: false,
177
- error: `Member name "${name}" contains whitespace. Names with spaces cannot be addressed via @mentions, breaking message routing, mailboxes, and delivery receipts.`,
178
- suggested: suggestMemberName(name),
179
- }
180
- }
181
- if (!/^[\w-]+$/.test(name)) {
182
- return {
183
- ok: false,
184
- error: `Member name "${name}" contains invalid characters. Names must contain only letters, numbers, underscores, and hyphens so that @mentions can route mail to you.`,
185
- suggested: suggestMemberName(name),
186
- }
187
- }
188
- return { ok: true }
189
- }
190
-
191
- /**
192
- * A refusal nobody can act on gets worked around, so every refusal above carries one of these.
193
- *
194
- * It deliberately validates its own output before returning it: a suggestion that is itself
195
- * invalid is worse than no suggestion, because the caller retries, fails again, and now distrusts
196
- * the error message. Whitespace-only and non-ASCII names both collapse to nothing here, which is
197
- * why the fallback exists rather than being defensive padding.
198
- *
199
- * @param {string} name
200
- * @returns {string}
201
- */
202
- export function suggestMemberName(name) {
203
- if (!name || typeof name !== 'string') return 'agent-1'
204
- let s = name.replace(/[\s]+/g, '-').replace(/[^\w-]+/g, '-')
205
- s = s.replace(/-+/g, '-').replace(/^[-_]+|[-_]+$/g, '')
206
- return /^[\w-]+$/.test(s) ? s : 'agent-1'
207
- }
208
-
209
- /**
210
- * Who has directly addressed `me` and not been answered since?
211
- *
212
- * "Answered" is deliberately cheap to satisfy: any later message from me that names them. It is
213
- * not trying to judge whether the reply was any good -- only that the room never goes silent on
214
- * someone who asked a direct question, which is the failure that makes agents give up on the
215
- * channel and start tailing files instead.
216
- *
217
- * Derived from the chat log rather than stored, so it cannot drift out of sync with what was
218
- * actually said, and needs no new state to persist or migrate.
219
- *
220
- * THREE THINGS DO NOT COUNT AS A DEBT, all for the same reason -- an obligation that fires on
221
- * traffic nobody chose to send is noise wearing a rule's clothes:
222
- * - `@all` broadcasts (see mentionsAll)
223
- * - system messages, and the client's own auto-posted conflict/adopt notices
224
- * - my own messages, however many times I say my own name
225
- *
226
- * @param {Array<{by?: string, kind?: string, text?: string, at?: string}>} messages
227
- * @param {string} me
228
- * @returns {Array<{from: string, at: string, text: string}>} oldest first
229
- */
230
- export function unansweredFor(messages, me) {
231
- const list = Array.isArray(messages) ? messages : []
232
- if (!me) return []
233
- const low = (s) => String(s || '').toLowerCase()
234
- const mine = low(me)
235
- const AUTO = /^(⚠ MERGE CONFLICT|✓ conflict|↓ )/ // posted by the sync client, not by a person
236
-
237
- const owed = new Map() // sender (lowercased) -> the message I owe a reply to
238
- for (const m of list) {
239
- if (!m || m.kind === 'system' || AUTO.test(String(m.text || ''))) continue
240
- if (low(m.by) === mine) {
241
- // Anything I name is discharged. A broadcast from me discharges everything: I have
242
- // spoken to the room, and holding a debt open after that is pedantry, not coordination.
243
- if (mentionsAll(m.text)) { owed.clear(); continue }
244
- for (const n of mentionedNames(m.text)) owed.delete(low(n))
245
- continue
246
- }
247
- // Directly addressed, not merely broadcast to.
248
- if (mentionsAll(m.text)) continue
249
- if (mentionsName(m.text, me)) owed.set(low(m.by), { from: m.by, at: m.at || '', text: m.text || '' })
250
- }
251
- return [...owed.values()]
252
- }
253
-
254
- /**
255
- * How urgently does this message want attention?
256
- *
257
- * 'stop' — act on it before finishing what you are doing
258
- * 'normal' — read it at your next boundary
259
- *
260
- * A marker is an explicit prefix so it is unambiguous, plus a small set of words that mean
261
- * stop in every room whether or not the sender knew about markers. An agent about to
262
- * overwrite someone's work is not going to be helped by correct syntax.
263
- */
264
- export function priorityOf(text) {
265
- const s = String(text || '')
266
- if (/^\s*\[!\]|^\s*\[stop\]/i.test(s)) return 'stop'
267
- if (/\b(stop|halt|abort|don'?t\s+(?:edit|touch|write)|revert)\b/i.test(s)) return 'stop'
268
- return 'normal'
269
- }
270
-
271
- /**
272
- * Collapse runs of the identical message into one entry with a count.
273
- *
274
- * Not cosmetic. A conflict-notification feedback loop in the same live build put 56 copies of
275
- * one "⚠ MERGE CONFLICT in Shell.jsx" line into the room in seconds, and 306 of the room's
276
- * 2,314 chat lines were that loop. Handing an agent 56 identical lines costs it the context it
277
- * needs to fix the actual problem, and buries the one message that mattered.
278
- * @param {Array<{text?: string, by?: string}>} messages
279
- */
280
- export function collapseRepeats(messages) {
281
- const out = []
282
- for (const m of messages || []) {
283
- const last = out[out.length - 1]
284
- if (last && last.by === m.by && last.text === m.text) {
285
- last.repeated = (last.repeated || 1) + 1
286
- last.at = m.at ?? last.at // keep the most recent timestamp
287
- continue
288
- }
289
- out.push({ ...m })
290
- }
291
- return out
292
- }
293
-
294
- /**
295
- * A read cursor over the room chat.
296
- *
297
- * @param {{me: string, startAt?: number}} options
298
- * me this participant's display name
299
- * startAt where to begin. On a first join this is the current chat length, so an agent is
300
- * not handed the room's entire history; on a resume it is the persisted cursor, so
301
- * nothing posted while the process was down is lost.
302
- */
303
- export function createMailbox({ me, startAt = 0 }) {
304
- let cursor = Math.max(0, Number(startAt) || 0)
305
-
306
- /** Read without consuming. `advance` is what `take` does; kept separate so a peek is safe. */
307
- function collect(all, { advance }) {
308
- const list = Array.isArray(all) ? all : []
309
- // A cursor past the end means this position belongs to a different document — a room
310
- // rebuilt from scratch, which the recovery procedure for a poisoned room requires. Reset
311
- // to the beginning rather than clamping to the end: clamping would deliver nothing, for
312
- // ever, silently. Re-reading a short fresh chat costs an agent a few lines; going
313
- // permanently deaf costs it the room.
314
- if (cursor > list.length) cursor = 0
315
-
316
- const fresh = list.slice(cursor)
317
- const end = list.length
318
-
319
- // An agent's own messages are not mail. It used to be woken by its own hive_say, and in a
320
- // notification storm most of the flood was self-authored — an agent filling its own inbox.
321
- const incoming = fresh.filter((m) => m && m.by !== me)
322
- const messages = collapseRepeats(incoming)
323
- const mentions = messages.filter((m) => mentionsName(m.text, me))
324
- const urgent = mentions.filter((m) => priorityOf(m.text) === 'stop')
325
-
326
- if (advance) cursor = end
327
-
328
- return {
329
- messages,
330
- mentions,
331
- mentioned: mentions.length > 0,
332
- priority: urgent.length ? 'stop' : 'normal',
333
- unreadCount: incoming.length,
334
- cursor,
335
- }
336
- }
337
-
338
- return {
339
- /** Current position, for persisting across process restarts. */
340
- get cursor() { return cursor },
341
- /** Move the cursor without delivering anything (used at join). */
342
- seek(n) { cursor = Math.max(0, Number(n) || 0); return cursor },
343
- /** Deliver everything unread and mark it read. Every return path must go through this. */
344
- take(all) { return collect(all, { advance: true }) },
345
- /** Look at what is unread without marking it read. */
346
- peek(all) { return collect(all, { advance: false }) },
347
- /** Is there anything at all? Cheap enough to call in a wake predicate. */
348
- hasUnread(all) { return collect(all, { advance: false }).unreadCount > 0 },
349
- }
350
- }
351
-
352
- /** One-line rendering used in tool output, with the repeat count when a run was collapsed. */
353
- export function formatMessage(m) {
354
- const times = m.repeated > 1 ? ` (×${m.repeated})` : ''
355
- return `${m.at || ''} ${m.by}(${m.kind || 'ai'}): ${m.text}${times}`
356
- }
357
-
358
- /**
359
- * AMBIENT DIGEST -- what to do with room chatter nobody addressed to you.
360
- *
361
- * Un-addressed messages were handed to every agent in full, on every wake. In a room of N
362
- * agents that means each one pays for the other N-1's conversation every single turn: O(N^2)
363
- * context, so TOKEN COST -- not the relay -- becomes the real ceiling on room size. Measured:
364
- * the relay holds 8 clients per room, converging in 1.0s on 45 MB. What actually stops a big
365
- * room is this block of text.
366
- *
367
- * The COUNT stays exact and the newest messages still arrive in full. Only the older bodies
368
- * are withheld, and the agent is told the number and how to read them, so nothing is hidden --
369
- * it just stops being paid for unasked. An agent that wants the whole log calls hive_read_chat.
370
- *
371
- * Returns '' when there is nothing, so a caller can drop it with a falsy check.
372
- */
373
- export function ambientDigest(messages, { keep = 4 } = {}) {
374
- const list = Array.isArray(messages) ? messages : []
375
- if (!list.length) return ''
376
- const shown = keep > 0 ? list.slice(-keep) : []
377
- const hidden = list.length - shown.length
378
- const head = 'room activity (' + list.length +
379
- (hidden ? ', newest ' + shown.length + ' shown' : '') + '):'
380
- const tail = hidden
381
- ? '\n... ' + hidden + ' earlier message(s) withheld to keep your context small'
382
- + ' -- hive_read_chat for the full log'
383
- : ''
384
- if (!shown.length) return head + tail
385
- return head + '\n' + shown.map(formatMessage).join('\n') + tail
386
- }
1
+ // The room mailbox: what has this participant actually read?
2
+ //
3
+ // WHY THIS EXISTS. `hive_wait` used to capture `baseChatLen = chat.length` at the moment it
4
+ // was CALLED, and only ever returned `chat.slice(baseChatLen)`. So anything posted between one
5
+ // wait returning and the next one starting — which is precisely the window where an agent is
6
+ // doing its work — sat below the cursor and was returned by NO code path. Not delayed:
7
+ // unreachable. Two further holes finished the job: a wait that had queued work returned
8
+ // `newChat: []` hardcoded, so the busiest agent in the room saw no messages at all; and
9
+ // mention detection existed only for PAUSED agents, so a working agent could not tell "stop,
10
+ // you are editing the wrong file" from two other agents chatting.
11
+ //
12
+ // Measured on a real four-agent build (JARVIS, 2026-07-28): 345 @mentions in the room log, 13
13
+ // acknowledgements. The PM agent ended up replacing delivery with a bash script that tails
14
+ // HIVE_CHAT.md, which is the verdict on the old design.
15
+ //
16
+ // So the cursor is now a persistent per-participant position that only advances over messages
17
+ // that were actually handed to the agent. This module is deliberately pure — plain arrays in,
18
+ // plain objects out, no Yjs and no I/O — so the delivery rules are unit-testable without a
19
+ // relay, a room, or a network.
20
+
21
+ /** A chat message as stored in the room: { by, kind, at, text }. */
22
+
23
+ /**
24
+ * Does this text address `me`? Matches `@Name`, case-insensitively, at a word boundary so
25
+ * `@PM` does not swallow `@PM-Claude` and `@Hermes` does not match `@Hermes-2`.
26
+ * @param {string} text
27
+ * @param {string} me
28
+ */
29
+ export function mentionsName(text, me) {
30
+ if (!text || !me) return false
31
+ const clean = stripSpansAndQuotes(text)
32
+ if (mentionsAll(clean)) return true // @all reaches everyone by definition
33
+ const escaped = String(me).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
34
+ // Both boundaries, and the same ones the extractor uses. If these two ever disagree, a message
35
+ // can be extracted as addressing someone who does not match it, or match someone it was never
36
+ // extracted for -- which is how a mailbox and a transcript come to tell different stories.
37
+ return new RegExp(`${MENTION_START}@${escaped}${MENTION_END}`, 'i').test(clean)
38
+ }
39
+
40
+ /**
41
+ * `@all` — the deliberate broadcast.
42
+ *
43
+ * Before this there was no way to reach the whole room on purpose. An unaddressed message goes
44
+ * into every agent's ambient digest, where the rules tell them to skip it -- correct for
45
+ * chatter, useless for "everyone stop, the suite is red". So the only two options were name
46
+ * eight agents by hand or be ignored.
47
+ *
48
+ * It wakes everyone. It deliberately does NOT create a reply debt (see unansweredFor): one
49
+ * broadcast obliging eight replies is how a coordination channel becomes a spam channel, and
50
+ * that is the exact failure this room already had once.
51
+ */
52
+ export function mentionsAll(text) {
53
+ return /@all(?![\w-])/i.test(stripSpansAndQuotes(text))
54
+ }
55
+
56
+ /** Code spans and quoted text blanked out, so a token quoted as code or prose is not read as an address. */
57
+ export function stripSpansAndQuotes(text) {
58
+ const raw = String(text || '')
59
+ const trimmed = raw.trim()
60
+ const quotePairs = [
61
+ ['"', '"'],
62
+ ["'", "'"],
63
+ ['“', '”'],
64
+ ['‘', '’'],
65
+ ]
66
+ for (const [open, close] of quotePairs) {
67
+ if (trimmed.startsWith(open) && trimmed.endsWith(close) && trimmed.length >= 2) {
68
+ if (trimmed.indexOf(close, open.length) === trimmed.length - close.length) {
69
+ // A single quote pair spans the entire message: preserve inner content (stripping internal code spans)
70
+ const inner = trimmed.slice(open.length, trimmed.length - close.length)
71
+ return inner
72
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ')
73
+ .replace(/`[^`\n]*`/g, ' ')
74
+ }
75
+ }
76
+ }
77
+
78
+ return raw
79
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ') // fenced code blocks
80
+ .replace(/`[^`\n]*`/g, ' ') // inline code spans
81
+ .replace(/"[^"\n]*"/g, ' ') // double quoted text
82
+ .replace(/(?:^|[\s(\[{<])'[^'\n]*'(?=$|[\s)\]}>.,!?:;])/g, ' ') // single quoted text (preserving contractions)
83
+ .replace(/[“”][^“”\n]*[“”]/g, ' ') // curly double quotes
84
+ .replace(/[‘’][^‘’\n]*[‘’]/g, ' ') // curly single quotes
85
+ }
86
+
87
+ /** Inline code spans blanked out, kept for backward compatibility. */
88
+ const stripCodeSpans = stripSpansAndQuotes
89
+
90
+ /**
91
+ * WHERE A MENTION MAY BEGIN, AND WHERE IT MAY NOT.
92
+ *
93
+ * Measured in room `night` on 20 Sep 2026, against the old bare /@([\w-]+)/g:
94
+ *
95
+ * "email me at ganesh@gmail.com" -> ["gmail"] every email address
96
+ * "blame line: author@example.org" -> ["example"] every commit trailer
97
+ * "npm i @modelcontextprotocol/sdk" -> ["modelcontextprotocol"] every scoped package
98
+ * "see `@all` in the docs" -> broadcast to everyone every doc about this feature
99
+ *
100
+ * After three hours the obligation ledger held 44 entries of which 32% were debts against members
101
+ * who had never existed, and a debt can only be discharged by the asker, so nothing in the system
102
+ * could ever have closed them. The ledger is what catches an agent going silent; it was becoming
103
+ * unreadable through ordinary use.
104
+ *
105
+ * LEADING: start of string, whitespace, an opening bracket, a quote, a dash, or a colon.
106
+ *
107
+ * TRAILING: not a word character, a hyphen, or a SLASH. The slash is what distinguishes
108
+ * `@scope/package` from `@person`. It is written as one lookahead rather than a separate check so
109
+ * the regex cannot backtrack into a shorter name to satisfy it -- `[\w-]+` followed by `(?!\/)`
110
+ * alone would happily match "modelcontextprotoco" and declare victory.
111
+ */
112
+ const MENTION_START = '(?:^|[\\s(\\[{<"\'—–:])'
113
+ const MENTION_END = '(?![\\w\\-/])'
114
+
115
+ /** Everyone addressed in a message, so a broadcast can be told from a direct call. */
116
+ export function mentionedNames(text) {
117
+ const clean = stripSpansAndQuotes(text)
118
+ const re = new RegExp(`${MENTION_START}@([\\w-]+)${MENTION_END}`, 'g')
119
+ return [...new Set([...clean.matchAll(re)].map((m) => m[1]))]
120
+ }
121
+
122
+ /**
123
+ * WHO ACTUALLY OWES AN ANSWER — which is not the same question as who was named.
124
+ *
125
+ * Extraction and obligation were one decision and they are two. A transcript should show what the
126
+ * text said, including a name that is merely quoted; a DEBT is a claim about the present, and
127
+ * opening one against somebody who is not here produces an entry nobody can ever discharge, since
128
+ * only the asker may close it and the debtor does not exist.
129
+ *
130
+ * The distinction that makes this safe is which way time points. Resolving a HISTORICAL mention
131
+ * against who is present now would be the Historical Transcript Fallacy -- the past decided by the
132
+ * present -- and that is why extraction deliberately does NOT do it. An obligation is not history:
133
+ * it is live state about who owes me an answer right now, so checking present membership is
134
+ * exactly the right question for it.
135
+ *
136
+ * @param {string} text
137
+ * @param {{ me?: string, present?: Iterable<string> }} ctx `present` is lowercased names
138
+ */
139
+ export function obligationTargets(text, { me = '', present = [] } = {}) {
140
+ const here = new Set([...present].map((n) => String(n).toLowerCase()))
141
+ const self = String(me).toLowerCase()
142
+ return mentionedNames(text).filter((n) => {
143
+ const low = n.toLowerCase()
144
+ // @all wakes the room and obliges nobody -- one broadcast owing eight replies is how a
145
+ // coordination channel becomes a spam channel. Unchanged from the original behaviour.
146
+ if (low === 'all' || low === self) return false
147
+ return here.has(low)
148
+ })
149
+ }
150
+
151
+ /**
152
+ * A NAME THAT CANNOT BE ADDRESSED IS NOT A NAME. Refuse it at the door.
153
+ *
154
+ * On 20 Sep 2026 an agent joined room `night` as "antigravity 1". `mentionedNames` above splits
155
+ * on /@([\w-]+)/g, so "@antigravity 1" reads as "@antigravity", and no such member existed. Its
156
+ * mail, its obligations and its wake-on-mention were all silently dead for an hour. It saw
157
+ * messages only because it happened to be tailing the chat log. Nothing errored; the room said
158
+ * "@antigravity is NOT in the room", which was true and useless, because the member WAS present
159
+ * under a name this parser cannot produce.
160
+ *
161
+ * WHY REFUSE RATHER THAN NORMALISE. Silently rewriting "coder 1" to "coder-1" collides with a
162
+ * real "coder-1" and merges two processes into one participant — one awareness entry, one cursor,
163
+ * one mailbox — with neither told. And the agent's own prompt still says its name is the original,
164
+ * so it stops recognising messages about itself.
165
+ *
166
+ * WHY REFUSE RATHER THAN RESOLVE @mentions AGAINST THE MEMBER LIST. That would make a departed
167
+ * member break the historical record: a fact about the past decided by who happens to be present
168
+ * now. Same defect as the receipts work in HIV-P2.
169
+ *
170
+ * WHY THIS CHARSET AND NOT A NICER ONE. It is not taste, it is arithmetic. The extractor above
171
+ * only recognises [\w-]. A name outside that set CANNOT be produced by it, however carefully the
172
+ * sender types. Constraining names to the charset the parser already uses makes the two
173
+ * consistent by construction instead of by coincidence.
174
+ *
175
+ * KNOWN LIMIT, stated because it is a product decision and not an oversight: `\w` is ASCII, so a
176
+ * name in Devanagari, Han or Cyrillic is refused. That is honest rather than good — those names
177
+ * are equally unaddressable today, and refusing at least says so out loud. Widening it means
178
+ * widening the extractor first; the two move together or not at all.
179
+ *
180
+ * @param {any} name
181
+ * @returns {{ ok: boolean, error?: string, suggested?: string }}
182
+ */
183
+ export function validateMemberName(name) {
184
+ if (name == null || typeof name !== 'string' || name.length === 0) {
185
+ return {
186
+ ok: false,
187
+ error: 'Member name must be a non-empty string so that the participant can be addressed via @mentions.',
188
+ suggested: 'agent-1',
189
+ }
190
+ }
191
+ // Whitespace is checked BEFORE the charset so the message names the actual problem. "contains
192
+ // whitespace" tells you what to do; "contains invalid characters" makes you hunt for which one.
193
+ if (/\s/.test(name)) {
194
+ return {
195
+ ok: false,
196
+ error: `Member name "${name}" contains whitespace. Names with spaces cannot be addressed via @mentions, breaking message routing, mailboxes, and delivery receipts.`,
197
+ suggested: suggestMemberName(name),
198
+ }
199
+ }
200
+ if (!/^[\w-]+$/.test(name)) {
201
+ return {
202
+ ok: false,
203
+ error: `Member name "${name}" contains invalid characters. Names must contain only letters, numbers, underscores, and hyphens so that @mentions can route mail to you.`,
204
+ suggested: suggestMemberName(name),
205
+ }
206
+ }
207
+ return { ok: true }
208
+ }
209
+
210
+ /**
211
+ * A refusal nobody can act on gets worked around, so every refusal above carries one of these.
212
+ *
213
+ * It deliberately validates its own output before returning it: a suggestion that is itself
214
+ * invalid is worse than no suggestion, because the caller retries, fails again, and now distrusts
215
+ * the error message. Whitespace-only and non-ASCII names both collapse to nothing here, which is
216
+ * why the fallback exists rather than being defensive padding.
217
+ *
218
+ * @param {string} name
219
+ * @returns {string}
220
+ */
221
+ export function suggestMemberName(name) {
222
+ if (!name || typeof name !== 'string') return 'agent-1'
223
+ let s = name.replace(/[\s]+/g, '-').replace(/[^\w-]+/g, '-')
224
+ s = s.replace(/-+/g, '-').replace(/^[-_]+|[-_]+$/g, '')
225
+ return /^[\w-]+$/.test(s) ? s : 'agent-1'
226
+ }
227
+
228
+ /**
229
+ * Who has directly addressed `me` and not been answered since?
230
+ *
231
+ * "Answered" is deliberately cheap to satisfy: any later message from me that names them. It is
232
+ * not trying to judge whether the reply was any good -- only that the room never goes silent on
233
+ * someone who asked a direct question, which is the failure that makes agents give up on the
234
+ * channel and start tailing files instead.
235
+ *
236
+ * Derived from the chat log rather than stored, so it cannot drift out of sync with what was
237
+ * actually said, and needs no new state to persist or migrate.
238
+ /**
239
+ * Is this message a bare acknowledgement or thank-you?
240
+ *
241
+ * WHITELISTED ONLY (Item 5.1). An acknowledgement from anyone (human or AI) must never open a
242
+ * reply debt. To prevent dropping real instructions ("ok, go ahead" or "thanks, can you also check X"),
243
+ * the WHOLE message (after stripping @names, emoji and punctuation) must match the whitelist.
244
+ * Anything with additional words or directives still opens a debt.
245
+ */
246
+ export function isAcknowledgement(text) {
247
+ if (!text || typeof text !== 'string') return false
248
+ const noCode = stripSpansAndQuotes(text)
249
+ const noMentions = noCode.replace(/@[\w-]+/g, ' ')
250
+ const noEmoji = noMentions.replace(/\p{Extended_Pictographic}/gu, ' ')
251
+ const noPunct = noEmoji.replace(/[.,!?:;'"“”‘’/\\()[\]{}~*`_#^<>=+|–—\-]/g, ' ')
252
+ const s = noPunct.replace(/\s+/g, ' ').trim().toLowerCase()
253
+ if (!s) return false
254
+
255
+ const CORE = 'thanks|thank\\s+you|thx|ty|ok|okay|got\\s+it|lgtm|looks\\s+good|sounds\\s+good|great|nice|cool|perfect|noted|ack'
256
+ const MOD = 'bro|all|team'
257
+ const re = new RegExp(`^(?:(?:${CORE})(?:\\s+(?:${MOD}))?|(?:(?:${MOD})\\s+)?(?:${CORE}))$`, 'i')
258
+ return re.test(s)
259
+ }
260
+
261
+ /**
262
+ * THREE THINGS DO NOT COUNT AS A DEBT, all for the same reason -- an obligation that fires on
263
+ * traffic nobody chose to send is noise wearing a rule's clothes:
264
+ * - `@all` broadcasts (see mentionsAll)
265
+ * - system messages, and the client's own auto-posted conflict/adopt notices
266
+ * - my own messages, however many times I say my own name
267
+ * - bare acknowledgements ("thanks", "ok", "lgtm"), even from a human
268
+ *
269
+ * @param {Array<{by?: string, kind?: string, text?: string, at?: string}>} messages
270
+ * @param {string} me
271
+ * @returns {Array<{from: string, at: string, text: string}>} oldest first
272
+ */
273
+ export function unansweredFor(messages, me) {
274
+ const list = Array.isArray(messages) ? messages : []
275
+ if (!me) return []
276
+ const low = (s) => String(s || '').toLowerCase()
277
+ const mine = low(me)
278
+ const AUTO = /^(⚠ MERGE CONFLICT|✓ conflict|↓ )/ // posted by the sync client, not by a person
279
+
280
+ const owed = new Map() // sender (lowercased) -> the message I owe a reply to
281
+ // TURNS, NOT MENTIONS (27 Sep 2026). Every mention used to open a debt, so an ANSWER opened one
282
+ // against the person who asked: Ada asks Bo, Bo says "on it", and Ada is told on every hive_check
283
+ // that she owes Bo a reply -- which she pays ("thanks"), which opens one against Bo. A politeness
284
+ // loop, paid in tokens and chat by both. So it goes by whose turn it is between two people: a
285
+ // message from someone I spoke to last is their reply, and opens a debt only if it asks something
286
+ // back or is urgent. Anything else still counts, question mark or not -- "can you take it" has none.
287
+ // A PERSON is always answered (Antigravity1's review): humans type directives, not needsReply flags,
288
+ // and "deploy to staging and run the smoke tests" right after an agent's report is an order, not a
289
+ // reply. Any '?' counts, so 'the port?"' and 'ready? (check it)' are questions too.
290
+ const turn = new Map() // other person (lowercased) -> 'me' when I spoke to them last, else 'them'
291
+ const asks = (t) => /\?/.test(String(t || '')) || priorityOf(t) === 'stop'
292
+ for (const m of list) {
293
+ if (!m || m.kind === 'system' || AUTO.test(String(m.text || ''))) continue
294
+ if (low(m.by) === mine) {
295
+ // Anything I name is discharged. A broadcast from me discharges everything: I have
296
+ // spoken to the room, and holding a debt open after that is pedantry, not coordination.
297
+ if (mentionsAll(m.text)) { owed.clear(); continue }
298
+ for (const n of mentionedNames(m.text)) { const k = low(n); if (k === mine) continue; owed.delete(k); turn.set(k, 'me') }
299
+ continue
300
+ }
301
+ // Directly addressed, not merely broadcast to.
302
+ if (mentionsAll(m.text)) continue
303
+ if (mentionsName(m.text, me)) {
304
+ const k = low(m.by)
305
+ if (!isAcknowledgement(m.text)) {
306
+ if (turn.get(k) !== 'me' || m.kind === 'human' || asks(m.text)) owed.set(k, { from: m.by, at: m.at || '', text: m.text || '' })
307
+ }
308
+ turn.set(k, 'them')
309
+ }
310
+ }
311
+ return [...owed.values()]
312
+ }
313
+
314
+ /**
315
+ * How urgently does this message want attention?
316
+ *
317
+ * 'stop' — act on it before finishing what you are doing
318
+ * 'normal' — read it at your next boundary
319
+ *
320
+ * A marker is an explicit prefix so it is unambiguous, plus a small set of words that mean
321
+ * stop in every room whether or not the sender knew about markers. An agent about to
322
+ * overwrite someone's work is not going to be helped by correct syntax.
323
+ *
324
+ * THE WORDS COUNT ONLY WHERE AN ORDER WOULD BE (Item 5.4). Narrowed to explicit directives
325
+ * so routine prose ("reaching a stopping point", "commit c56214b: revert earlier broken test",
326
+ * "revert PR #12 merged") does not arrive under a false STOP banner.
327
+ */
328
+ const ORDER_WINDOW = 160
329
+ export function priorityOf(text) {
330
+ const s = String(text || '')
331
+ if (/^\s*\[!\]|^\s*\[stop\]/i.test(s)) return 'stop'
332
+ const head = s
333
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ') // fenced code, closed or not
334
+ .replace(/`[^`\n]*`/g, ' ') // inline code spans
335
+ .replace(/"[^"\n]*"/g, ' ') // double quotes
336
+ .replace(/(?:^|[\s(\[{<])'[^'\n]*'(?=$|[\s)\]}>.,!?:;])/g, ' ') // single quotes
337
+ .replace(/[“”][^“”\n]*[“”]/g, ' ') // curly double quotes
338
+ .replace(/^(?:\s*@[\w-]+[\s,:;.!-]*)+/, '') // the leading @names being addressed
339
+ .trimStart()
340
+ .slice(0, ORDER_WINDOW)
341
+
342
+ // Directives at start of head (immediate orders)
343
+ if (/^(?:please\s+)?(?:stop\b(?!\s*\(|\.\w)|halt\b|abort\b|revert\s+(?:that|this|it|now|[\w./-]+\.\w+)\b)/i.test(head)) return 'stop'
344
+ if (/^stop-ship\b/i.test(head)) return 'stop'
345
+
346
+ // Imperative directives within head window
347
+ if (/\b(?:please\s+)?don'?t\s+(?:edit|touch|write|modify)\b/i.test(head)) return 'stop'
348
+ if (/\bplease\s+stop\b/i.test(head)) return 'stop'
349
+ if (/\b(?:stop|revert)\s+(?:that|this|it\s+)?now\b/i.test(head)) return 'stop'
350
+ if (/\bstop\s+(?:editing|touching|writing)\b/i.test(head)) return 'stop'
351
+
352
+ return 'normal'
353
+ }
354
+
355
+ /** Standard Levenshtein distance between two strings, case-insensitively. */
356
+ export function levenshtein(a, b) {
357
+ const s1 = String(a || '').toLowerCase()
358
+ const s2 = String(b || '').toLowerCase()
359
+ const m = s1.length, n = s2.length
360
+ if (!m) return n
361
+ if (!n) return m
362
+ const d = Array.from({ length: m + 1 }, () => new Array(n + 1))
363
+ for (let i = 0; i <= m; i++) d[i][0] = i
364
+ for (let j = 0; j <= n; j++) d[0][j] = j
365
+ for (let i = 1; i <= m; i++) {
366
+ for (let j = 1; j <= n; j++) {
367
+ const cost = s1[i - 1] === s2[j - 1] ? 0 : 1
368
+ d[i][j] = Math.min(
369
+ d[i - 1][j] + 1,
370
+ d[i][j - 1] + 1,
371
+ d[i - 1][j - 1] + cost
372
+ )
373
+ }
374
+ }
375
+ return d[m][n]
376
+ }
377
+
378
+ /** Find closest known name within edit distance <= 2 (or <= 1 for names <= 4 chars) for typo detection. */
379
+ export function findClosestKnown(name, knownNames) {
380
+ const s = String(name || '').toLowerCase()
381
+ let best = null
382
+ let bestDist = Infinity
383
+ for (const k of knownNames || []) {
384
+ const orig = String(k)
385
+ const dist = levenshtein(s, orig.toLowerCase())
386
+ if (dist < bestDist) {
387
+ bestDist = dist
388
+ best = orig
389
+ }
390
+ }
391
+ const maxAllowed = (best && best.length <= 4) || s.length <= 4 ? 1 : 2
392
+ return bestDist <= maxAllowed ? { name: best, distance: bestDist } : null
393
+ }
394
+
395
+ /** Does this mention sit at the direct-address position (start of the message)? */
396
+ export function isDirectAddress(text, name) {
397
+ const s = String(text || '').trimStart()
398
+ const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
399
+ return new RegExp(`^@${escaped}(?![\\w\\-/])`, 'i').test(s)
400
+ }
401
+
402
+ /**
403
+ * Collapse runs of the identical message into one entry with a count.
404
+ *
405
+ * Not cosmetic. A conflict-notification feedback loop in the same live build put 56 copies of
406
+ * one "⚠ MERGE CONFLICT in Shell.jsx" line into the room in seconds, and 306 of the room's
407
+ * 2,314 chat lines were that loop. Handing an agent 56 identical lines costs it the context it
408
+ * needs to fix the actual problem, and buries the one message that mattered.
409
+ * @param {Array<{text?: string, by?: string}>} messages
410
+ */
411
+ export function collapseRepeats(messages) {
412
+ const out = []
413
+ for (const m of messages || []) {
414
+ const last = out[out.length - 1]
415
+ if (last && last.by === m.by && last.text === m.text) {
416
+ last.repeated = (last.repeated || 1) + 1
417
+ last.at = m.at ?? last.at // keep the most recent timestamp
418
+ continue
419
+ }
420
+ out.push({ ...m })
421
+ }
422
+ return out
423
+ }
424
+
425
+ /**
426
+ * A read cursor over the room chat.
427
+ *
428
+ * @param {{me: string, startAt?: number}} options
429
+ * me this participant's display name
430
+ * startAt where to begin. On a first join this is the current chat length, so an agent is
431
+ * not handed the room's entire history; on a resume it is the persisted cursor, so
432
+ * nothing posted while the process was down is lost.
433
+ */
434
+ export function createMailbox({ me, startAt = 0 }) {
435
+ let cursor = Math.max(0, Number(startAt) || 0)
436
+
437
+ /** Read without consuming. `advance` is what `take` does; kept separate so a peek is safe. */
438
+ function collect(all, { advance }) {
439
+ const list = Array.isArray(all) ? all : []
440
+ // A cursor past the end means this position belongs to a different document — a room
441
+ // rebuilt from scratch, which the recovery procedure for a poisoned room requires. Reset
442
+ // to the beginning rather than clamping to the end: clamping would deliver nothing, for
443
+ // ever, silently. Re-reading a short fresh chat costs an agent a few lines; going
444
+ // permanently deaf costs it the room.
445
+ if (cursor > list.length) cursor = 0
446
+
447
+ const fresh = list.slice(cursor)
448
+ const end = list.length
449
+
450
+ // An agent's own messages are not mail. It used to be woken by its own hive_say, and in a
451
+ // notification storm most of the flood was self-authored — an agent filling its own inbox.
452
+ const incoming = fresh.filter((m) => m && m.by !== me)
453
+ const messages = collapseRepeats(incoming)
454
+ const mentions = messages.filter((m) => mentionsName(m.text, me))
455
+ const urgent = mentions.filter((m) => priorityOf(m.text) === 'stop')
456
+
457
+ if (advance) cursor = end
458
+
459
+ return {
460
+ messages,
461
+ mentions,
462
+ mentioned: mentions.length > 0,
463
+ priority: urgent.length ? 'stop' : 'normal',
464
+ unreadCount: incoming.length,
465
+ cursor,
466
+ }
467
+ }
468
+
469
+ return {
470
+ /** Current position, for persisting across process restarts. */
471
+ get cursor() { return cursor },
472
+ /** Move the cursor without delivering anything (used at join). */
473
+ seek(n) { cursor = Math.max(0, Number(n) || 0); return cursor },
474
+ /** Deliver everything unread and mark it read. Every return path must go through this. */
475
+ take(all) { return collect(all, { advance: true }) },
476
+ /** Look at what is unread without marking it read. */
477
+ peek(all) { return collect(all, { advance: false }) },
478
+ /** Is there anything at all? Cheap enough to call in a wake predicate. */
479
+ hasUnread(all) { return collect(all, { advance: false }).unreadCount > 0 },
480
+ }
481
+ }
482
+
483
+ /** One-line rendering used in tool output, with the repeat count when a run was collapsed. */
484
+ export function formatMessage(m) {
485
+ const times = m.repeated > 1 ? ` (×${m.repeated})` : ''
486
+ return `${m.at || ''} ${m.by}(${m.kind || 'ai'}): ${m.text}${times}`
487
+ }
488
+
489
+ /**
490
+ * AMBIENT DIGEST -- what to do with room chatter nobody addressed to you.
491
+ *
492
+ * Un-addressed messages were handed to every agent in full, on every wake. In a room of N
493
+ * agents that means each one pays for the other N-1's conversation every single turn: O(N^2)
494
+ * context, so TOKEN COST -- not the relay -- becomes the real ceiling on room size. Measured:
495
+ * the relay holds 8 clients per room, converging in 1.0s on 45 MB. What actually stops a big
496
+ * room is this block of text.
497
+ *
498
+ * The COUNT stays exact and the newest messages still arrive in full. Only the older bodies
499
+ * are withheld, and the agent is told the number and how to read them, so nothing is hidden --
500
+ * it just stops being paid for unasked. An agent that wants the whole log calls hive_read_chat.
501
+ *
502
+ * Returns '' when there is nothing, so a caller can drop it with a falsy check.
503
+ */
504
+ export function ambientDigest(messages, { keep = 4 } = {}) {
505
+ const list = Array.isArray(messages) ? messages : []
506
+ if (!list.length) return ''
507
+ const shown = keep > 0 ? list.slice(-keep) : []
508
+ const hidden = list.length - shown.length
509
+ const head = 'room activity (' + list.length +
510
+ (hidden ? ', newest ' + shown.length + ' shown' : '') + '):'
511
+ const tail = hidden
512
+ ? '\n... ' + hidden + ' earlier message(s) withheld to keep your context small'
513
+ + ' -- hive_read_chat for the full log'
514
+ : ''
515
+ if (!shown.length) return head + tail
516
+ return head + '\n' + shown.map(formatMessage).join('\n') + tail
517
+ }