hivecode-mcp 0.14.0 → 0.14.2

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