hivecode-mcp 0.12.1 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +188 -8
- package/core.js +91 -91
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +5 -5
- package/docs/SELF_HOSTING.md +256 -256
- package/hive-activity.js +336 -310
- package/hive-await.js +187 -0
- package/hive-branch.js +656 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +517 -386
- package/hive-mcp.js +483 -126
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +337 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +7 -2
- package/hive-secrets.js +89 -0
- package/hive-setup.js +548 -445
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +290 -241
- package/hive-throttle.js +118 -118
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/icr-merge.js +58 -58
- package/icr.js +121 -15
- 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 +20 -11
- package/reconcile.cjs +614 -477
- package/substrate.js +252 -252
- package/sync.js +4691 -3005
- package/grm-link.cjs +0 -141
package/hive-receipts.js
CHANGED
|
@@ -1,233 +1,233 @@
|
|
|
1
|
-
// hive-receipts.js — did the message actually reach anybody, or did it only look like it did?
|
|
2
|
-
//
|
|
3
|
-
// THE DEFECT THIS EXISTS FOR, from this project's own history:
|
|
4
|
-
//
|
|
5
|
-
// "29 messages @-mentioned me today. hive_wait has surfaced a handful."
|
|
6
|
-
// "Those messages land in HIVE_CHAT.md and look delivered -- they just may never reach the
|
|
7
|
-
// recipient's context. The convention looks like it works, which is the worst property a
|
|
8
|
-
// coordination protocol can have."
|
|
9
|
-
//
|
|
10
|
-
// `hive_say` returns "sent to @Builder: ..." the instant the message enters the LOCAL Yjs array.
|
|
11
|
-
// It says that while the socket is down. It says that when @Builder crashed ten minutes ago. It
|
|
12
|
-
// says that when @Builder is looping without ever calling hive_wait. Three different failures,
|
|
13
|
-
// one cheerful word, and the sender has no way to tell them apart.
|
|
14
|
-
//
|
|
15
|
-
// WHAT THE EVIDENCE IS, HERE. Agent Relay verifies a delivery by watching the terminal echo the
|
|
16
|
-
// keystrokes back (crates/broker/src/broker/delivery_verification.rs). There is no terminal here,
|
|
17
|
-
// so copying the mechanism would be cargo cult. The equivalent evidence in Hivecode is the one
|
|
18
|
-
// thing that only moves when a message is actually handed into an agent's context:
|
|
19
|
-
//
|
|
20
|
-
// THE READ CURSOR IS THE ECHO.
|
|
21
|
-
//
|
|
22
|
-
// A mailbox cursor advances only inside `box.take()`, which is what hands messages to the agent.
|
|
23
|
-
// So "their cursor passed the slot my message sits in" means the message was in the payload that
|
|
24
|
-
// agent received. Today that cursor is written to a LOCAL session file, where nobody else can see
|
|
25
|
-
// it. Publishing it into the room is the whole change.
|
|
26
|
-
//
|
|
27
|
-
// FOUR STATES, AND THE FOURTH IS THE POINT. Agent Relay has three (Success / Unverified /
|
|
28
|
-
// Failed) and the load-bearing one is Unverified -- an unconfirmed delivery must never be counted
|
|
29
|
-
// as a confirmed one. Their comment: "Unverified neither speeds up nor backs off the throttle,
|
|
30
|
-
// but breaks the consecutive-success streak so unverified deliveries never drive the delay down."
|
|
31
|
-
//
|
|
32
|
-
// The same rule here, phrased for this system:
|
|
33
|
-
//
|
|
34
|
-
// READ their cursor passed it. Evidence it landed.
|
|
35
|
-
// UNREAD they publish cursors, and theirs is behind it. Evidence it did NOT.
|
|
36
|
-
// UNKNOWN they publish no cursor at all. NO evidence either way -- and it must never be
|
|
37
|
-
// counted as READ. A Telegram bridge, a human's browser and an older build all
|
|
38
|
-
// land here, so silence from this state means nothing at all.
|
|
39
|
-
// UNREACHABLE they are not in the room, or WE are offline. It is not going to arrive.
|
|
40
|
-
//
|
|
41
|
-
// UNKNOWN is the honest state that a two-state design (delivered / not) cannot express, and
|
|
42
|
-
// collapsing it into either neighbour is how a receipt system starts lying. `countDelivered`
|
|
43
|
-
// exists precisely so that no caller can accidentally sum UNKNOWN into the good number.
|
|
44
|
-
//
|
|
45
|
-
// NO STORED INDEX. A Yjs array index is not stable: two peers pushing concurrently converge in a
|
|
46
|
-
// deterministic order that need not be the order either one observed, so a message's index can
|
|
47
|
-
// move after it was recorded. Receipts are therefore DERIVED at query time by locating the
|
|
48
|
-
// message in the converged array -- the same choice `unansweredFor` made in hive-mailbox.js, for
|
|
49
|
-
// the same reason: nothing to migrate and nothing that can drift.
|
|
50
|
-
|
|
51
|
-
// @-parsing is imported, never re-implemented. A second hand-written copy of "who was named in
|
|
52
|
-
// this text" is exactly the shape Agent Relay wrote up in learnings/duplicated-wire-contract.md
|
|
53
|
-
// and exactly the shape that already bit this repo once as a duplicated reconcile(): two
|
|
54
|
-
// descriptions of one thing, only one of which is ever checked against reality.
|
|
55
|
-
import { mentionedNames, mentionsAll } from './hive-mailbox.js'
|
|
56
|
-
|
|
57
|
-
export const READ = 'READ'
|
|
58
|
-
export const UNREAD = 'UNREAD'
|
|
59
|
-
export const UNKNOWN = 'UNKNOWN'
|
|
60
|
-
export const UNREACHABLE = 'UNREACHABLE'
|
|
61
|
-
|
|
62
|
-
/** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
|
|
63
|
-
export const DELIVERED_STATES = [READ]
|
|
64
|
-
|
|
65
|
-
const lc = (x) => String(x == null ? '' : x).toLowerCase()
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Publish my read position into the room.
|
|
69
|
-
*
|
|
70
|
-
* `at` is a count of messages consumed, so it only ever moves forward. It is clamped against
|
|
71
|
-
* going BACKWARDS because a rejoin can hand a session a stale persisted cursor, and a receipt
|
|
72
|
-
* that un-reads a message somebody already answered is worse than no receipt at all.
|
|
73
|
-
*/
|
|
74
|
-
export function publishCursor(cursors, name, at, now = Date.now) {
|
|
75
|
-
if (!cursors || !name) return null
|
|
76
|
-
const prev = cursors.get(String(name))
|
|
77
|
-
const next = Math.max(0, Number(at) || 0)
|
|
78
|
-
const highest = Math.max(next, (prev && Number(prev.at)) || 0)
|
|
79
|
-
const rec = { at: highest, ts: now(), ver: 1 }
|
|
80
|
-
// Writing an identical value would still be a CRDT update, waking every observer in the room
|
|
81
|
-
// on every tool call for no information. Only a real advance is published.
|
|
82
|
-
if (prev && prev.at === highest) return prev
|
|
83
|
-
cursors.set(String(name), rec)
|
|
84
|
-
return rec
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
|
|
88
|
-
export function cursorOf(cursors, name) {
|
|
89
|
-
if (!cursors) return null
|
|
90
|
-
const direct = cursors.get(String(name))
|
|
91
|
-
if (direct) return direct
|
|
92
|
-
for (const [k, v] of cursors.entries()) if (lc(k) === lc(name)) return v
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Where a message sits in the converged chat array, or -1.
|
|
98
|
-
*
|
|
99
|
-
* Identity is (by, at, text) rather than an index: the triple survives reordering, and the index
|
|
100
|
-
* does not. Searched from the end because a receipt is almost always asked about something said
|
|
101
|
-
* recently, and the room's history can be thousands of lines.
|
|
102
|
-
*/
|
|
103
|
-
export function findIndex(chat, msg) {
|
|
104
|
-
if (!Array.isArray(chat) || !msg) return -1
|
|
105
|
-
for (let i = chat.length - 1; i >= 0; i--) {
|
|
106
|
-
const m = chat[i]
|
|
107
|
-
if (m && m.by === msg.by && m.at === msg.at && m.text === msg.text) return i
|
|
108
|
-
}
|
|
109
|
-
return -1
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The state of ONE message for ONE recipient.
|
|
114
|
-
*
|
|
115
|
-
* Order matters. `connected === false` outranks everything, because if our own socket is down the
|
|
116
|
-
* message has not left this process and nothing we believe about the other side is worth saying.
|
|
117
|
-
* Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
|
|
118
|
-
* actionable of the two.
|
|
119
|
-
*/
|
|
120
|
-
export function stateFor({ cursors, name, index, present = true, connected = true }) {
|
|
121
|
-
if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
|
|
122
|
-
if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
|
|
123
|
-
if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
|
|
124
|
-
const cur = cursorOf(cursors, name)
|
|
125
|
-
if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
|
|
126
|
-
// A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
|
|
127
|
-
// been handed over exactly when the cursor is strictly greater than it.
|
|
128
|
-
if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
|
|
129
|
-
return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* The state of ONE message for ONE recipient, ASKED AS A DIFFERENT QUESTION.
|
|
134
|
-
*
|
|
135
|
-
* `stateFor` above answers "should I wait for a reply from them right now?", so absence outranks
|
|
136
|
-
* a cursor: an agent that read your message and then left still cannot answer you, and blocking on
|
|
137
|
-
* them is the failure that function exists to prevent. That is correct, it is deliberate, and
|
|
138
|
-
* tests/receipts-test.mjs D1 pins it in capital letters.
|
|
139
|
-
*
|
|
140
|
-
* This one answers a different question: "did this message ever land?" — and there the cursor
|
|
141
|
-
* outranks absence, because A READ IS A FACT ABOUT THE PAST AND LOGGING OFF CANNOT UNDO IT.
|
|
142
|
-
*
|
|
143
|
-
* WHY TWO FUNCTIONS AND NOT A FLAG. A boolean that silently changes what the returned STRING means
|
|
144
|
-
* is how this class of bug is born: a caller that forgets it gets a confident answer to a question
|
|
145
|
-
* it did not ask. Two names cannot be confused by omission.
|
|
146
|
-
*
|
|
147
|
-
* WHY THIS ORDER, AND IT IS THE WHOLE DESIGN:
|
|
148
|
-
* - `!cur` is checked BEFORE `!present`. A human in a browser, a Telegram bridge and every older
|
|
149
|
-
* build publish no cursor at all. Reaching `!present` first would render them UNREACHABLE with
|
|
150
|
-
* the claim "they left without reading it" — a claim we cannot support, because we have no
|
|
151
|
-
* evidence either way. That is precisely what UNKNOWN exists to express, and folding it into
|
|
152
|
-
* UNREACHABLE would make every human in the room read as a delivery failure forever.
|
|
153
|
-
* - `!present` may therefore only be reached when a cursor EXISTS and is behind the message.
|
|
154
|
-
* Only then is "they left without reading it" a measurement rather than a guess.
|
|
155
|
-
* - `connected` is absent on purpose. The cursors map is LOCAL. Our own socket being down right
|
|
156
|
-
* now says nothing about whether a three-day-old message was read; that was recorded and is
|
|
157
|
-
* already here. Gate this on connectivity and the whole transcript blanks to UNKNOWN during
|
|
158
|
-
* every reconnect — exactly when a human is reading it to work out what happened.
|
|
159
|
-
*
|
|
160
|
-
* Built by DeepMind-Agent (Gemini, in its own container) in room `night`, 20 Sep 2026, against a
|
|
161
|
-
* design argued in the room over four rounds. Tests are antigravity1's, written before the code
|
|
162
|
-
* existed and verified red first: tests/transcript-receipts-test.mjs.
|
|
163
|
-
*/
|
|
164
|
-
export function transcriptStateFor({ cursors, name, index, present = true }) {
|
|
165
|
-
if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
|
|
166
|
-
const cur = cursorOf(cursors, name)
|
|
167
|
-
if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
|
|
168
|
-
// A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
|
|
169
|
-
// been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
|
|
170
|
-
// at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
|
|
171
|
-
if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
|
|
172
|
-
// Only reachable when a cursor exists and is behind the message.
|
|
173
|
-
if (!present) return { name, state: UNREACHABLE, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is not in the room, and left with cursor behind this message` }
|
|
174
|
-
return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Only READ counts. Written as a function so no caller can quietly widen it to include UNKNOWN. */
|
|
178
|
-
export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.includes(s.state)).length
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Every message I addressed to somebody, with per-recipient state.
|
|
182
|
-
*
|
|
183
|
-
* Unaddressed messages are skipped on purpose: they land in the ambient digest, where agents are
|
|
184
|
-
* instructed to skip them, so a receipt for one would be measuring something nobody promised.
|
|
185
|
-
* `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
|
|
186
|
-
* one message obliging eight receipts is the storm, not the signal.
|
|
187
|
-
*/
|
|
188
|
-
export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
|
|
189
|
-
const list = Array.isArray(chat) ? chat : []
|
|
190
|
-
const out = []
|
|
191
|
-
for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
|
|
192
|
-
const m = list[i]
|
|
193
|
-
if (!m || lc(m.by) !== lc(me)) continue
|
|
194
|
-
if (mentionsAll(m.text)) continue
|
|
195
|
-
const named = mentionedNames(m.text).filter((n) => lc(n) !== 'all' && lc(n) !== lc(me))
|
|
196
|
-
if (!named.length) continue
|
|
197
|
-
out.push({
|
|
198
|
-
at: m.at,
|
|
199
|
-
text: String(m.text || '').slice(0, 120),
|
|
200
|
-
to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
return out.reverse()
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
|
|
207
|
-
|
|
208
|
-
/** One line per message. The marks make a column a human can scan; the words carry the meaning. */
|
|
209
|
-
export function describe(r) {
|
|
210
|
-
return `${r.at} ${r.to.map((s) => `${MARK[s.state] || '?'} ${s.name}=${s.state}`).join(' ')} "${r.text}"`
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* What hive_say should say instead of "sent".
|
|
215
|
-
*
|
|
216
|
-
* ONE FAILURE ONLY, AND THAT IS DELIBERATE. At send time delivery is not knowable -- the message
|
|
217
|
-
* was created a millisecond ago -- so the single thing worth saying is the one that is already
|
|
218
|
-
* decided and is currently reported as success: our own socket is down.
|
|
219
|
-
*
|
|
220
|
-
* It says nothing about an ABSENT recipient even though that is equally decidable, because
|
|
221
|
-
* `describeRecipient` in hive-busy.js already says it ("@X is NOT in the room — nobody will read
|
|
222
|
-
* that") and a second hand-written copy of that sentence is precisely the defect class Agent
|
|
223
|
-
* Relay wrote up in learnings/duplicated-wire-contract.md: two descriptions of one fact, only one
|
|
224
|
-
* of which anybody keeps current. One producer, one description.
|
|
225
|
-
*
|
|
226
|
-
* On success it returns the empty string. A reassuring word at send time IS the defect.
|
|
227
|
-
*/
|
|
228
|
-
export function sendWarning({ connected }) {
|
|
229
|
-
if (connected) return ''
|
|
230
|
-
return 'NOT SENT — this client is OFFLINE. The message is in your local copy only; it has not ' +
|
|
231
|
-
'reached the relay and no one else can see it. It will go out by itself when the connection ' +
|
|
232
|
-
'heals (do NOT call hive_join, that tears down the session). Until then, treat it as unsent.'
|
|
233
|
-
}
|
|
1
|
+
// hive-receipts.js — did the message actually reach anybody, or did it only look like it did?
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS EXISTS FOR, from this project's own history:
|
|
4
|
+
//
|
|
5
|
+
// "29 messages @-mentioned me today. hive_wait has surfaced a handful."
|
|
6
|
+
// "Those messages land in HIVE_CHAT.md and look delivered -- they just may never reach the
|
|
7
|
+
// recipient's context. The convention looks like it works, which is the worst property a
|
|
8
|
+
// coordination protocol can have."
|
|
9
|
+
//
|
|
10
|
+
// `hive_say` returns "sent to @Builder: ..." the instant the message enters the LOCAL Yjs array.
|
|
11
|
+
// It says that while the socket is down. It says that when @Builder crashed ten minutes ago. It
|
|
12
|
+
// says that when @Builder is looping without ever calling hive_wait. Three different failures,
|
|
13
|
+
// one cheerful word, and the sender has no way to tell them apart.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THE EVIDENCE IS, HERE. Agent Relay verifies a delivery by watching the terminal echo the
|
|
16
|
+
// keystrokes back (crates/broker/src/broker/delivery_verification.rs). There is no terminal here,
|
|
17
|
+
// so copying the mechanism would be cargo cult. The equivalent evidence in Hivecode is the one
|
|
18
|
+
// thing that only moves when a message is actually handed into an agent's context:
|
|
19
|
+
//
|
|
20
|
+
// THE READ CURSOR IS THE ECHO.
|
|
21
|
+
//
|
|
22
|
+
// A mailbox cursor advances only inside `box.take()`, which is what hands messages to the agent.
|
|
23
|
+
// So "their cursor passed the slot my message sits in" means the message was in the payload that
|
|
24
|
+
// agent received. Today that cursor is written to a LOCAL session file, where nobody else can see
|
|
25
|
+
// it. Publishing it into the room is the whole change.
|
|
26
|
+
//
|
|
27
|
+
// FOUR STATES, AND THE FOURTH IS THE POINT. Agent Relay has three (Success / Unverified /
|
|
28
|
+
// Failed) and the load-bearing one is Unverified -- an unconfirmed delivery must never be counted
|
|
29
|
+
// as a confirmed one. Their comment: "Unverified neither speeds up nor backs off the throttle,
|
|
30
|
+
// but breaks the consecutive-success streak so unverified deliveries never drive the delay down."
|
|
31
|
+
//
|
|
32
|
+
// The same rule here, phrased for this system:
|
|
33
|
+
//
|
|
34
|
+
// READ their cursor passed it. Evidence it landed.
|
|
35
|
+
// UNREAD they publish cursors, and theirs is behind it. Evidence it did NOT.
|
|
36
|
+
// UNKNOWN they publish no cursor at all. NO evidence either way -- and it must never be
|
|
37
|
+
// counted as READ. A Telegram bridge, a human's browser and an older build all
|
|
38
|
+
// land here, so silence from this state means nothing at all.
|
|
39
|
+
// UNREACHABLE they are not in the room, or WE are offline. It is not going to arrive.
|
|
40
|
+
//
|
|
41
|
+
// UNKNOWN is the honest state that a two-state design (delivered / not) cannot express, and
|
|
42
|
+
// collapsing it into either neighbour is how a receipt system starts lying. `countDelivered`
|
|
43
|
+
// exists precisely so that no caller can accidentally sum UNKNOWN into the good number.
|
|
44
|
+
//
|
|
45
|
+
// NO STORED INDEX. A Yjs array index is not stable: two peers pushing concurrently converge in a
|
|
46
|
+
// deterministic order that need not be the order either one observed, so a message's index can
|
|
47
|
+
// move after it was recorded. Receipts are therefore DERIVED at query time by locating the
|
|
48
|
+
// message in the converged array -- the same choice `unansweredFor` made in hive-mailbox.js, for
|
|
49
|
+
// the same reason: nothing to migrate and nothing that can drift.
|
|
50
|
+
|
|
51
|
+
// @-parsing is imported, never re-implemented. A second hand-written copy of "who was named in
|
|
52
|
+
// this text" is exactly the shape Agent Relay wrote up in learnings/duplicated-wire-contract.md
|
|
53
|
+
// and exactly the shape that already bit this repo once as a duplicated reconcile(): two
|
|
54
|
+
// descriptions of one thing, only one of which is ever checked against reality.
|
|
55
|
+
import { mentionedNames, mentionsAll } from './hive-mailbox.js'
|
|
56
|
+
|
|
57
|
+
export const READ = 'READ'
|
|
58
|
+
export const UNREAD = 'UNREAD'
|
|
59
|
+
export const UNKNOWN = 'UNKNOWN'
|
|
60
|
+
export const UNREACHABLE = 'UNREACHABLE'
|
|
61
|
+
|
|
62
|
+
/** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
|
|
63
|
+
export const DELIVERED_STATES = [READ]
|
|
64
|
+
|
|
65
|
+
const lc = (x) => String(x == null ? '' : x).toLowerCase()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Publish my read position into the room.
|
|
69
|
+
*
|
|
70
|
+
* `at` is a count of messages consumed, so it only ever moves forward. It is clamped against
|
|
71
|
+
* going BACKWARDS because a rejoin can hand a session a stale persisted cursor, and a receipt
|
|
72
|
+
* that un-reads a message somebody already answered is worse than no receipt at all.
|
|
73
|
+
*/
|
|
74
|
+
export function publishCursor(cursors, name, at, now = Date.now) {
|
|
75
|
+
if (!cursors || !name) return null
|
|
76
|
+
const prev = cursors.get(String(name))
|
|
77
|
+
const next = Math.max(0, Number(at) || 0)
|
|
78
|
+
const highest = Math.max(next, (prev && Number(prev.at)) || 0)
|
|
79
|
+
const rec = { at: highest, ts: now(), ver: 1 }
|
|
80
|
+
// Writing an identical value would still be a CRDT update, waking every observer in the room
|
|
81
|
+
// on every tool call for no information. Only a real advance is published.
|
|
82
|
+
if (prev && prev.at === highest) return prev
|
|
83
|
+
cursors.set(String(name), rec)
|
|
84
|
+
return rec
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
|
|
88
|
+
export function cursorOf(cursors, name) {
|
|
89
|
+
if (!cursors) return null
|
|
90
|
+
const direct = cursors.get(String(name))
|
|
91
|
+
if (direct) return direct
|
|
92
|
+
for (const [k, v] of cursors.entries()) if (lc(k) === lc(name)) return v
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where a message sits in the converged chat array, or -1.
|
|
98
|
+
*
|
|
99
|
+
* Identity is (by, at, text) rather than an index: the triple survives reordering, and the index
|
|
100
|
+
* does not. Searched from the end because a receipt is almost always asked about something said
|
|
101
|
+
* recently, and the room's history can be thousands of lines.
|
|
102
|
+
*/
|
|
103
|
+
export function findIndex(chat, msg) {
|
|
104
|
+
if (!Array.isArray(chat) || !msg) return -1
|
|
105
|
+
for (let i = chat.length - 1; i >= 0; i--) {
|
|
106
|
+
const m = chat[i]
|
|
107
|
+
if (m && m.by === msg.by && m.at === msg.at && m.text === msg.text) return i
|
|
108
|
+
}
|
|
109
|
+
return -1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The state of ONE message for ONE recipient.
|
|
114
|
+
*
|
|
115
|
+
* Order matters. `connected === false` outranks everything, because if our own socket is down the
|
|
116
|
+
* message has not left this process and nothing we believe about the other side is worth saying.
|
|
117
|
+
* Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
|
|
118
|
+
* actionable of the two.
|
|
119
|
+
*/
|
|
120
|
+
export function stateFor({ cursors, name, index, present = true, connected = true }) {
|
|
121
|
+
if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
|
|
122
|
+
if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
|
|
123
|
+
if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
|
|
124
|
+
const cur = cursorOf(cursors, name)
|
|
125
|
+
if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
|
|
126
|
+
// A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
|
|
127
|
+
// been handed over exactly when the cursor is strictly greater than it.
|
|
128
|
+
if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
|
|
129
|
+
return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The state of ONE message for ONE recipient, ASKED AS A DIFFERENT QUESTION.
|
|
134
|
+
*
|
|
135
|
+
* `stateFor` above answers "should I wait for a reply from them right now?", so absence outranks
|
|
136
|
+
* a cursor: an agent that read your message and then left still cannot answer you, and blocking on
|
|
137
|
+
* them is the failure that function exists to prevent. That is correct, it is deliberate, and
|
|
138
|
+
* tests/receipts-test.mjs D1 pins it in capital letters.
|
|
139
|
+
*
|
|
140
|
+
* This one answers a different question: "did this message ever land?" — and there the cursor
|
|
141
|
+
* outranks absence, because A READ IS A FACT ABOUT THE PAST AND LOGGING OFF CANNOT UNDO IT.
|
|
142
|
+
*
|
|
143
|
+
* WHY TWO FUNCTIONS AND NOT A FLAG. A boolean that silently changes what the returned STRING means
|
|
144
|
+
* is how this class of bug is born: a caller that forgets it gets a confident answer to a question
|
|
145
|
+
* it did not ask. Two names cannot be confused by omission.
|
|
146
|
+
*
|
|
147
|
+
* WHY THIS ORDER, AND IT IS THE WHOLE DESIGN:
|
|
148
|
+
* - `!cur` is checked BEFORE `!present`. A human in a browser, a Telegram bridge and every older
|
|
149
|
+
* build publish no cursor at all. Reaching `!present` first would render them UNREACHABLE with
|
|
150
|
+
* the claim "they left without reading it" — a claim we cannot support, because we have no
|
|
151
|
+
* evidence either way. That is precisely what UNKNOWN exists to express, and folding it into
|
|
152
|
+
* UNREACHABLE would make every human in the room read as a delivery failure forever.
|
|
153
|
+
* - `!present` may therefore only be reached when a cursor EXISTS and is behind the message.
|
|
154
|
+
* Only then is "they left without reading it" a measurement rather than a guess.
|
|
155
|
+
* - `connected` is absent on purpose. The cursors map is LOCAL. Our own socket being down right
|
|
156
|
+
* now says nothing about whether a three-day-old message was read; that was recorded and is
|
|
157
|
+
* already here. Gate this on connectivity and the whole transcript blanks to UNKNOWN during
|
|
158
|
+
* every reconnect — exactly when a human is reading it to work out what happened.
|
|
159
|
+
*
|
|
160
|
+
* Built by DeepMind-Agent (Gemini, in its own container) in room `night`, 20 Sep 2026, against a
|
|
161
|
+
* design argued in the room over four rounds. Tests are antigravity1's, written before the code
|
|
162
|
+
* existed and verified red first: tests/transcript-receipts-test.mjs.
|
|
163
|
+
*/
|
|
164
|
+
export function transcriptStateFor({ cursors, name, index, present = true }) {
|
|
165
|
+
if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
|
|
166
|
+
const cur = cursorOf(cursors, name)
|
|
167
|
+
if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
|
|
168
|
+
// A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
|
|
169
|
+
// been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
|
|
170
|
+
// at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
|
|
171
|
+
if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
|
|
172
|
+
// Only reachable when a cursor exists and is behind the message.
|
|
173
|
+
if (!present) return { name, state: UNREACHABLE, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is not in the room, and left with cursor behind this message` }
|
|
174
|
+
return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Only READ counts. Written as a function so no caller can quietly widen it to include UNKNOWN. */
|
|
178
|
+
export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.includes(s.state)).length
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every message I addressed to somebody, with per-recipient state.
|
|
182
|
+
*
|
|
183
|
+
* Unaddressed messages are skipped on purpose: they land in the ambient digest, where agents are
|
|
184
|
+
* instructed to skip them, so a receipt for one would be measuring something nobody promised.
|
|
185
|
+
* `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
|
|
186
|
+
* one message obliging eight receipts is the storm, not the signal.
|
|
187
|
+
*/
|
|
188
|
+
export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
|
|
189
|
+
const list = Array.isArray(chat) ? chat : []
|
|
190
|
+
const out = []
|
|
191
|
+
for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
|
|
192
|
+
const m = list[i]
|
|
193
|
+
if (!m || lc(m.by) !== lc(me)) continue
|
|
194
|
+
if (mentionsAll(m.text)) continue
|
|
195
|
+
const named = mentionedNames(m.text).filter((n) => lc(n) !== 'all' && lc(n) !== lc(me))
|
|
196
|
+
if (!named.length) continue
|
|
197
|
+
out.push({
|
|
198
|
+
at: m.at,
|
|
199
|
+
text: String(m.text || '').slice(0, 120),
|
|
200
|
+
to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
return out.reverse()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
|
|
207
|
+
|
|
208
|
+
/** One line per message. The marks make a column a human can scan; the words carry the meaning. */
|
|
209
|
+
export function describe(r) {
|
|
210
|
+
return `${r.at} ${r.to.map((s) => `${MARK[s.state] || '?'} ${s.name}=${s.state}`).join(' ')} "${r.text}"`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What hive_say should say instead of "sent".
|
|
215
|
+
*
|
|
216
|
+
* ONE FAILURE ONLY, AND THAT IS DELIBERATE. At send time delivery is not knowable -- the message
|
|
217
|
+
* was created a millisecond ago -- so the single thing worth saying is the one that is already
|
|
218
|
+
* decided and is currently reported as success: our own socket is down.
|
|
219
|
+
*
|
|
220
|
+
* It says nothing about an ABSENT recipient even though that is equally decidable, because
|
|
221
|
+
* `describeRecipient` in hive-busy.js already says it ("@X is NOT in the room — nobody will read
|
|
222
|
+
* that") and a second hand-written copy of that sentence is precisely the defect class Agent
|
|
223
|
+
* Relay wrote up in learnings/duplicated-wire-contract.md: two descriptions of one fact, only one
|
|
224
|
+
* of which anybody keeps current. One producer, one description.
|
|
225
|
+
*
|
|
226
|
+
* On success it returns the empty string. A reassuring word at send time IS the defect.
|
|
227
|
+
*/
|
|
228
|
+
export function sendWarning({ connected }) {
|
|
229
|
+
if (connected) return ''
|
|
230
|
+
return 'NOT SENT — this client is OFFLINE. The message is in your local copy only; it has not ' +
|
|
231
|
+
'reached the relay and no one else can see it. It will go out by itself when the connection ' +
|
|
232
|
+
'heals (do NOT call hive_join, that tears down the session). Until then, treat it as unsent.'
|
|
233
|
+
}
|
package/hive-relay-health.js
CHANGED
|
@@ -212,9 +212,14 @@ export function statusFromError(e) {
|
|
|
212
212
|
/**
|
|
213
213
|
* Authoritative explanations for HTTP status codes returned by the relay on connection upgrade.
|
|
214
214
|
*/
|
|
215
|
-
export function explainStatus(code, relay = '') {
|
|
215
|
+
export function explainStatus(code, relay = '', opts = {}) {
|
|
216
216
|
const c = Number(code)
|
|
217
|
-
if (c === 401)
|
|
217
|
+
if (c === 401) {
|
|
218
|
+
if (opts && opts.hasToken === false) {
|
|
219
|
+
return 'this room needs a token of your own. Ask the room owner to mint you one.'
|
|
220
|
+
}
|
|
221
|
+
return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
222
|
+
}
|
|
218
223
|
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
219
224
|
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
220
225
|
if (c === 429) return 'this room is FULL, so you were not let in (Q431). A relay admits a limited number of connections per room -- 8 on the free tier -- and this room is at that ceiling. Note it counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, raise it with HIVE_SEATS; otherwise ask whoever does, or contact support@getgrm.tech.'
|
package/hive-secrets.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// LIVE CREDENTIALS ARE NOT SENT TO THE ROOM (3 Oct 2026).
|
|
2
|
+
//
|
|
3
|
+
// A file in a room goes to the relay and to every participant's disk. ALWAYS_IGNORE keeps the
|
|
4
|
+
// files that are secrets by NAME at home (.env, *.pem, id_rsa, ...), but a secret inside an
|
|
5
|
+
// ordinary file went out with it: an agent that pastes an API key into config.js to try
|
|
6
|
+
// something shares that key with everyone in the room, and with whoever runs the relay.
|
|
7
|
+
//
|
|
8
|
+
// So a file whose content holds a credential the room does not already have is held on this
|
|
9
|
+
// machine (see sync.js, withheld) until the credential is moved out. Only formats a provider
|
|
10
|
+
// stamps on its keys are matched -- a fixed prefix plus enough random characters -- and private
|
|
11
|
+
// key blocks. Nothing here guesses from entropy alone: a false match holds back real work, and
|
|
12
|
+
// the people who hit it would be right to switch this off.
|
|
13
|
+
//
|
|
14
|
+
// The matched value never leaves this module's callers in any form: not in a log line, not in
|
|
15
|
+
// the room, not in status. Only its kind and its line number do.
|
|
16
|
+
|
|
17
|
+
const PATTERNS = [
|
|
18
|
+
{ kind: 'private key', re: /-----BEGIN (?:[A-Z0-9]+ ){0,3}PRIVATE KEY(?: BLOCK)?-----/g },
|
|
19
|
+
{ kind: 'AWS access key', re: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
|
|
20
|
+
{ kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,100})\b/g },
|
|
21
|
+
{ kind: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
22
|
+
{ kind: 'Slack token', re: /\bxox[abposr]-[0-9]{6,}-[0-9A-Za-z-]{10,}/g },
|
|
23
|
+
{ kind: 'Slack webhook', re: /hooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g },
|
|
24
|
+
{ kind: 'Stripe live key', re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/g },
|
|
25
|
+
{ kind: 'OpenAI API key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{16,}T3BlbkFJ[A-Za-z0-9_-]{16,}/g },
|
|
26
|
+
{ kind: 'Anthropic API key', re: /\bsk-ant-(?:api|admin|oat)\d{2}-[A-Za-z0-9_-]{60,}/g },
|
|
27
|
+
{ kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
28
|
+
{ kind: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,40}\b/g },
|
|
29
|
+
{ kind: 'Groq API key', re: /\bgsk_[A-Za-z0-9]{40,60}\b/g },
|
|
30
|
+
{ kind: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
|
|
31
|
+
{ kind: 'DigitalOcean token', re: /\bdo[opr]_v1_[a-f0-9]{64}\b/g },
|
|
32
|
+
// .npmrc and friends: a registry password or token written out literally. `${NPM_TOKEN}` is the
|
|
33
|
+
// documented way to keep one out of the file, so a reference to the environment is not a secret.
|
|
34
|
+
{ kind: 'registry credential', re: /(?:^|\n)[ \t]*(?:\/\/[^\s=]+:)?_(?:authToken|auth|password)[ \t]*=[ \t]*(?!\$\{)[^\s'"]{8,}/g },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// Documentation and tests are full of keys that look real and are not: AWS's own example id,
|
|
38
|
+
// and placeholders like sk_live_XXXXXXXXXXXXXXXXXXXXXXXX. A provider's key is random, so a match
|
|
39
|
+
// with few distinct characters, or that says it is an example, is a placeholder.
|
|
40
|
+
const PLACEHOLDER = /EXAMPLE|example|XXXX|xxxx|0{8}|1234567890|your[_-]?(?:key|token|secret)/i
|
|
41
|
+
function placeholder(value, kind) {
|
|
42
|
+
if (kind === 'private key' || kind === 'registry credential') return false
|
|
43
|
+
if (PLACEHOLDER.test(value)) return true
|
|
44
|
+
return new Set(value).size < 10
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A SIGNED ACCESS TOKEN -- a JWT, which is what Hivecode's own room tokens are -- is a person: whoever
|
|
48
|
+
// holds it joins as them, with their access. It is kept out of the room's chat and tasks
|
|
49
|
+
// ({ tokens: true }). It is NOT a reason to hold a FILE back: auth code and its tests are full of
|
|
50
|
+
// sample tokens, most of them expired or fake, and holding each such file would stop real work.
|
|
51
|
+
export const TOKEN_PATTERNS = [
|
|
52
|
+
{ kind: 'access token', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}/g },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** Every credential in `text`: [{ kind, line, value }]. `value` is for comparing, never for showing.
|
|
56
|
+
* `tokens: true` adds signed access tokens, for what goes into the room's chat and tasks. */
|
|
57
|
+
export function findSecrets(text, { tokens = false } = {}) {
|
|
58
|
+
const s = String(text || '')
|
|
59
|
+
if (!s) return []
|
|
60
|
+
const out = []
|
|
61
|
+
for (const { kind, re } of tokens ? [...PATTERNS, ...TOKEN_PATTERNS] : PATTERNS) {
|
|
62
|
+
re.lastIndex = 0
|
|
63
|
+
let m
|
|
64
|
+
while ((m = re.exec(s)) !== null) {
|
|
65
|
+
const value = m[0].replace(/^\s+/, '')
|
|
66
|
+
if (!placeholder(value, kind)) {
|
|
67
|
+
const at = m.index + (m[0].length - m[0].replace(/^\s+/, '').length)
|
|
68
|
+
out.push({ kind, line: s.slice(0, at).split('\n').length, value })
|
|
69
|
+
}
|
|
70
|
+
if (m[0].length === 0) re.lastIndex++
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out.sort((a, b) => a.line - b.line)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Credentials in `local` that `shared` does not already contain: what sharing `local` would leak. */
|
|
77
|
+
export function newSecrets(local, shared) {
|
|
78
|
+
const found = findSecrets(local)
|
|
79
|
+
if (!found.length) return []
|
|
80
|
+
const room = String(shared || '')
|
|
81
|
+
return found.filter((f) => !room.includes(f.value))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "an OpenAI API key (line 12)" -- the only form a finding is ever shown in. */
|
|
85
|
+
export function describeSecret(f) {
|
|
86
|
+
if (!f) return ''
|
|
87
|
+
const a = /^(?:[aeiou]|npm\b)/i.test(f.kind) ? 'an' : 'a'
|
|
88
|
+
return `${a} ${f.kind} (line ${f.line})`
|
|
89
|
+
}
|