hivecode-mcp 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HIVE_RULES.md +11 -40
- package/LICENSE +21 -21
- package/README.md +421 -167
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-activity.js +336 -310
- package/hive-await.js +207 -0
- package/hive-branch.js +686 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +1904 -0
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +520 -386
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +579 -137
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-notify.js +233 -0
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +350 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +230 -225
- package/hive-room-key.js +209 -0
- package/hive-secrets.js +89 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +558 -445
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +293 -241
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -0
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/hive-webhook.js +54 -0
- package/icr-merge.js +58 -58
- package/icr.js +851 -745
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +189 -164
- package/reconcile.cjs +614 -477
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/substrate.js +252 -252
- package/sync.js +1956 -177
- package/grm-link.cjs +0 -141
package/hive-mailbox.js
CHANGED
|
@@ -1,386 +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
|
-
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
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
|
+
}
|