hivecode-mcp 0.11.5 → 0.12.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 +127 -41
- package/README.md +250 -13
- package/core.js +14 -146
- package/docs/MCP.md +34 -1
- package/docs/SELF_HOSTING.md +253 -0
- package/hive-activity.js +310 -0
- package/hive-busy.js +88 -0
- package/hive-coord.js +14 -3
- package/hive-gate.js +135 -0
- package/hive-hook-install.js +103 -0
- package/hive-instructions.js +66 -0
- package/hive-mailbox.js +216 -3
- package/hive-mcp.js +760 -45
- package/hive-obligations.js +247 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +263 -0
- package/hive-receipts.js +233 -0
- package/hive-relay-health.js +205 -0
- package/hive-stop-hook.js +241 -0
- package/hive-throttle.js +118 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/package.json +42 -6
- package/reconcile.cjs +477 -0
- package/sync.js +940 -30
package/hive-privacy.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// WHICH RELAY IS THIS, AND DID ANYBODY CHOOSE IT?
|
|
2
|
+
//
|
|
3
|
+
// Every byte of a team's source leaves their machines through one URL: the relay. The
|
|
4
|
+
// client has always printed that URL and stopped there -- a bare `relay: wss://...` line
|
|
5
|
+
// that says nothing about what it MEANS. That is the whole defect this file exists for,
|
|
6
|
+
// and it is the product's highest-stakes false success:
|
|
7
|
+
//
|
|
8
|
+
// An agent starts with no link and no .hive.json. resolveRoom() falls through to the
|
|
9
|
+
// shipped default -- a relay the team does not run. The agent joins. hive_status says
|
|
10
|
+
// connected. filesSynced climbs. Every signal is green, and the company's source code
|
|
11
|
+
// is now on a stranger's server. Nothing crashed, so nothing reports it.
|
|
12
|
+
//
|
|
13
|
+
// Two separate things are needed and they are NOT the same thing:
|
|
14
|
+
//
|
|
15
|
+
// 1. A VERDICT on every join, so "am I private?" is answered without being asked.
|
|
16
|
+
// Printing the URL is not a verdict. A person who has never run a relay cannot tell
|
|
17
|
+
// wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
|
|
18
|
+
//
|
|
19
|
+
// 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
|
|
20
|
+
// an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
|
|
21
|
+
// ever talk to these relays" -- set once in the MCP config, and no stale link, no
|
|
22
|
+
// typo'd host and no forgotten env var can send a file anywhere else. The refusal
|
|
23
|
+
// happens BEFORE the socket opens, so a refused join publishes nothing.
|
|
24
|
+
//
|
|
25
|
+
// Comparison is on ORIGIN, never on the raw string. wss://r.co, wss://r.co/ and
|
|
26
|
+
// wss://R.co:443 are one relay; a string compare would refuse a correct configuration,
|
|
27
|
+
// and a guard that fires on the healthy path is a guard people disable.
|
|
28
|
+
|
|
29
|
+
// The relay this build falls back to when nobody chose one -- THE ONE PLACE IT IS WRITTEN.
|
|
30
|
+
//
|
|
31
|
+
// It used to be written four times: here, hive-mcp.js, hive-agent.js and hive-say.js, each a
|
|
32
|
+
// literal. On 23 Sep 2026 the hosted relay's Render account was suspended and a new one stood
|
|
33
|
+
// up at a new address, and moving meant hunting down every copy -- any one missed would have
|
|
34
|
+
// sent that client to a dead host, or made this module call the live default "YOUR RELAY".
|
|
35
|
+
// Every client now imports it from here. The extension's JSON setting cannot import, so
|
|
36
|
+
// extension-privacy-parity-test pins it equal to this value instead.
|
|
37
|
+
export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
|
|
38
|
+
|
|
39
|
+
// Relays this project has run publicly and retired. A .hive.json written before a move still
|
|
40
|
+
// names the old one, and if that host ever answers again (a reactivated account, a squatter
|
|
41
|
+
// on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
|
|
42
|
+
// outside this list is called.
|
|
43
|
+
export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
|
|
44
|
+
|
|
45
|
+
const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalise a relay URL to a comparable origin: scheme://host:port, lowercased, with the
|
|
49
|
+
* scheme's default port made explicit. Returns null for anything that is not a URL --
|
|
50
|
+
* callers must treat null as "unknown", never as "fine".
|
|
51
|
+
*/
|
|
52
|
+
export function relayOrigin(url) {
|
|
53
|
+
const s = String(url || '').trim()
|
|
54
|
+
if (!s) return null
|
|
55
|
+
let u
|
|
56
|
+
try { u = new URL(s) } catch { return null }
|
|
57
|
+
const proto = u.protocol.toLowerCase()
|
|
58
|
+
if (!DEFAULT_PORT[proto]) return null
|
|
59
|
+
const port = u.port || DEFAULT_PORT[proto]
|
|
60
|
+
// ws:// and wss:// to the same host:port are the SAME relay reached two ways, and a pin
|
|
61
|
+
// written as https:// (which is what a person copies out of a browser) must match the
|
|
62
|
+
// wss:// the client actually dials. Scheme is reported separately by classifyRelay so
|
|
63
|
+
// the plaintext warning still fires; it deliberately does not participate in identity.
|
|
64
|
+
const secureScheme = proto === 'wss:' || proto === 'https:'
|
|
65
|
+
return `${secureScheme ? 'wss' : 'ws'}://${u.hostname.toLowerCase()}:${port}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'])
|
|
69
|
+
|
|
70
|
+
function isLoopback(host) {
|
|
71
|
+
const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
72
|
+
if (LOOPBACK.has(h)) return true
|
|
73
|
+
return /^127\./.test(h)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPrivateLan(host) {
|
|
77
|
+
const h = String(host || '').toLowerCase()
|
|
78
|
+
if (/^10\./.test(h)) return true
|
|
79
|
+
if (/^192\.168\./.test(h)) return true
|
|
80
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
|
|
81
|
+
if (/\.local$/.test(h) || /\.lan$/.test(h) || /\.internal$/.test(h)) return true
|
|
82
|
+
// A PRIVATE MESH (Tailscale and friends) IS A PRIVATE NETWORK, and the first version of this
|
|
83
|
+
// file called it "the public internet in plaintext". 100.64.0.0/10 is the shared address space
|
|
84
|
+
// Tailscale hands out and *.ts.net is its MagicDNS; the transport underneath is WireGuard,
|
|
85
|
+
// end-to-end encrypted between the two devices, and nothing outside the tailnet can even
|
|
86
|
+
// route to the relay. Warning a remote team that chose the most private setup available
|
|
87
|
+
// that their source was travelling in the clear would have taught them to ignore the verdict.
|
|
88
|
+
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true
|
|
89
|
+
if (/\.ts\.net$/.test(h)) return true
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// PUBLIC TUNNELS decrypt at their own edge. A relay on a laptop behind cloudflared or ngrok is
|
|
94
|
+
// the team's relay -- nobody else runs it -- but the tunnel provider terminates TLS and can read
|
|
95
|
+
// every frame, and anyone holding the URL can reach the port. Both facts matter to a team that
|
|
96
|
+
// picked a tunnel *for privacy*, and neither is visible in a URL that starts with wss://.
|
|
97
|
+
const PUBLIC_TUNNEL = /\.(trycloudflare\.com|ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok-free\.dev|loca\.lt|serveo\.net|localhost\.run)$/
|
|
98
|
+
|
|
99
|
+
function isPublicTunnel(host) {
|
|
100
|
+
return PUBLIC_TUNNEL.test(String(host || '').toLowerCase())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What kind of relay is this, and does traffic to it leave the machine in the clear?
|
|
105
|
+
*
|
|
106
|
+
* kind: 'public-default' | 'loopback' | 'private-lan' | 'tunnel' | 'own' | 'unknown'
|
|
107
|
+
*
|
|
108
|
+
* 'own' does not mean audited -- it means "not the relay we ship". A team that pastes a
|
|
109
|
+
* link from a blog post lands in 'own' too, which is exactly why the pin exists.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyRelay(url) {
|
|
112
|
+
const origin = relayOrigin(url)
|
|
113
|
+
if (!origin) return { kind: 'unknown', origin: null, host: null, encrypted: false, url: String(url || '') }
|
|
114
|
+
const u = new URL(String(url).trim())
|
|
115
|
+
const host = u.hostname.toLowerCase()
|
|
116
|
+
const encrypted = u.protocol === 'wss:' || u.protocol === 'https:'
|
|
117
|
+
let kind
|
|
118
|
+
if (origin === relayOrigin(PUBLIC_DEFAULT_RELAY) ||
|
|
119
|
+
RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) kind = 'public-default'
|
|
120
|
+
else if (isLoopback(host)) kind = 'loopback'
|
|
121
|
+
else if (isPrivateLan(host)) kind = 'private-lan'
|
|
122
|
+
else if (isPublicTunnel(host)) kind = 'tunnel'
|
|
123
|
+
else kind = 'own'
|
|
124
|
+
return { kind, origin, host, encrypted, url: String(url).trim() }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The pin, from the environment or from <dir>/.hive.json's `pinnedRelay`. Comma-separated,
|
|
129
|
+
* because a team legitimately has a staging relay and a production one.
|
|
130
|
+
*
|
|
131
|
+
* A pin that cannot be parsed is an ERROR, never an empty pin. That distinction is the
|
|
132
|
+
* whole value of the feature: a typo silently collapsing to "no pin" leaves somebody
|
|
133
|
+
* believing they are locked to their own relay while the client dials the public one --
|
|
134
|
+
* the same absence-rendered-as-a-value shape the room has been bitten by before.
|
|
135
|
+
*/
|
|
136
|
+
export function readPin({ env = process.env, dir = null, fs = null } = {}) {
|
|
137
|
+
let raw = String((env && env.HIVE_RELAY_PIN) || '').trim()
|
|
138
|
+
let from = 'HIVE_RELAY_PIN'
|
|
139
|
+
if (!raw && dir && fs) {
|
|
140
|
+
try {
|
|
141
|
+
const cfg = JSON.parse(fs.readFileSync(`${dir}/.hive.json`, 'utf8'))
|
|
142
|
+
if (cfg && cfg.pinnedRelay) { raw = String(cfg.pinnedRelay).trim(); from = '.hive.json pinnedRelay' }
|
|
143
|
+
} catch { /* no config, or not JSON -- that is "no pin", not "bad pin" */ }
|
|
144
|
+
}
|
|
145
|
+
if (!raw) return { pinned: false, origins: [], from: null, raw: '' }
|
|
146
|
+
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
147
|
+
const origins = []
|
|
148
|
+
const bad = []
|
|
149
|
+
for (const p of parts) {
|
|
150
|
+
const o = relayOrigin(p)
|
|
151
|
+
if (o) origins.push(o); else bad.push(p)
|
|
152
|
+
}
|
|
153
|
+
if (bad.length || !origins.length) {
|
|
154
|
+
return { pinned: true, origins, from, raw, error: `not a relay URL: ${bad.join(', ') || raw}` }
|
|
155
|
+
}
|
|
156
|
+
return { pinned: true, origins, from, raw }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const HEADLINE = {
|
|
160
|
+
'public-default': 'PUBLIC RELAY — not yours',
|
|
161
|
+
loopback: 'PRIVATE — relay on this machine',
|
|
162
|
+
'private-lan': 'PRIVATE — relay on your network',
|
|
163
|
+
tunnel: 'YOUR RELAY — through a public tunnel',
|
|
164
|
+
own: 'YOUR RELAY',
|
|
165
|
+
unknown: 'UNKNOWN RELAY',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The one call a client makes before opening a socket.
|
|
170
|
+
*
|
|
171
|
+
* Returns { ok, code, headline, detail, classification, pin }. `ok:false` means DO NOT
|
|
172
|
+
* CONNECT -- the caller must not open the socket and must not publish anything, because
|
|
173
|
+
* publishing is the leak this prevents. A warning (ok:true with `warn`) is printed and
|
|
174
|
+
* proceeds: a team on a plaintext LAN relay has made a choice, and refusing it would be
|
|
175
|
+
* this guard deciding policy it was not given.
|
|
176
|
+
*
|
|
177
|
+
* Q550 the relay is not one this machine is pinned to
|
|
178
|
+
* Q551 HIVE_PRIVATE is set and this is the public relay we ship
|
|
179
|
+
* Q552 the pin itself is malformed, so nothing is actually pinned
|
|
180
|
+
*/
|
|
181
|
+
export function checkRelay({ relay, env = process.env, dir = null, fs = null } = {}) {
|
|
182
|
+
const c = classifyRelay(relay)
|
|
183
|
+
const pin = readPin({ env, dir, fs })
|
|
184
|
+
const strict = String((env && env.HIVE_PRIVATE) || '') === '1'
|
|
185
|
+
|
|
186
|
+
if (pin.pinned && pin.error) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false, code: 'Q552', classification: c, pin,
|
|
189
|
+
headline: 'Q552 THE RELAY PIN IS MALFORMED — REFUSING TO CONNECT',
|
|
190
|
+
detail: [
|
|
191
|
+
`${pin.from} = ${pin.raw}`,
|
|
192
|
+
`problem: ${pin.error}`,
|
|
193
|
+
'',
|
|
194
|
+
'A pin that cannot be parsed is refused rather than ignored. Ignoring it would leave',
|
|
195
|
+
'you believing this machine is locked to your relay while it dials whatever it likes.',
|
|
196
|
+
'Write it as a full URL, e.g. HIVE_RELAY_PIN=wss://relay.yourcompany.com',
|
|
197
|
+
].join('\n'),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pin.pinned && c.origin && !pin.origins.includes(c.origin)) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false, code: 'Q550', classification: c, pin,
|
|
204
|
+
headline: 'Q550 THIS RELAY IS NOT THE ONE THIS MACHINE IS PINNED TO — REFUSING TO CONNECT',
|
|
205
|
+
detail: [
|
|
206
|
+
`asked to connect to : ${c.url} (${c.origin})`,
|
|
207
|
+
`pinned to : ${pin.origins.join(', ')} (via ${pin.from})`,
|
|
208
|
+
'',
|
|
209
|
+
'Nothing was opened and nothing was published, so no file has left this machine.',
|
|
210
|
+
c.kind === 'public-default'
|
|
211
|
+
? 'The relay asked for is the PUBLIC one this build ships as a fallback — which is what\nyou get when a link is missing, a .hive.json was not found, or HIVE_RELAY is unset.'
|
|
212
|
+
: 'Check the join link you were given, and HIVE_RELAY in this process\'s environment.',
|
|
213
|
+
].join('\n'),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!pin.pinned && strict && c.kind === 'public-default') {
|
|
218
|
+
return {
|
|
219
|
+
ok: false, code: 'Q551', classification: c, pin,
|
|
220
|
+
headline: 'Q551 HIVE_PRIVATE=1 AND THIS IS THE PUBLIC RELAY — REFUSING TO CONNECT',
|
|
221
|
+
detail: [
|
|
222
|
+
`relay: ${c.url}`,
|
|
223
|
+
'',
|
|
224
|
+
'This is the relay this build falls back to when nobody chose one. Under HIVE_PRIVATE',
|
|
225
|
+
'that fallback is an error rather than a default, because it is reached by omission —',
|
|
226
|
+
'a missing link or an unset HIVE_RELAY — and never by anybody deciding on it.',
|
|
227
|
+
'',
|
|
228
|
+
'Point HIVE_RELAY at your own relay, or unset HIVE_PRIVATE if you meant to use ours.',
|
|
229
|
+
].join('\n'),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Connecting. Say what that means, in one line, every time.
|
|
234
|
+
let warn = null
|
|
235
|
+
if (c.kind === 'public-default') {
|
|
236
|
+
warn = 'Your files are being sent to a relay run by the Hivecode author, not by you. ' +
|
|
237
|
+
'Anyone who learns this room id can read them. For private work run your own relay ' +
|
|
238
|
+
'(npx hivecode-relay) and set HIVE_RELAY.'
|
|
239
|
+
} else if (c.kind === 'tunnel') {
|
|
240
|
+
warn = `${c.host} is a public tunnel. The relay is yours, but the tunnel provider decrypts ` +
|
|
241
|
+
'traffic at its edge and can read it, and anyone with this URL can reach the relay. Fine for ' +
|
|
242
|
+
'a trial with --secret set; for private remote work use a private network (e.g. Tailscale) ' +
|
|
243
|
+
'or a server you control.'
|
|
244
|
+
} else if (c.kind === 'own' && !c.encrypted) {
|
|
245
|
+
warn = `ws:// to ${c.host} is NOT encrypted — your source travels the public internet in ` +
|
|
246
|
+
'plaintext. Put TLS in front of the relay and use wss://.'
|
|
247
|
+
} else if (c.kind === 'unknown') {
|
|
248
|
+
warn = `"${c.url}" does not parse as a relay URL — the client will almost certainly fail ` +
|
|
249
|
+
'to connect, and if it does connect you cannot tell where to.'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: true, code: null, classification: c, pin, warn,
|
|
254
|
+
headline: `${HEADLINE[c.kind]}${c.encrypted ? '' : c.kind === 'loopback' ? '' : ' (UNENCRYPTED)'}`,
|
|
255
|
+
detail: warn || '',
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** One line for a join banner / status block: the URL plus what it means. */
|
|
260
|
+
export function privacyLine(relay, opts = {}) {
|
|
261
|
+
const v = checkRelay({ relay, ...opts })
|
|
262
|
+
return `relay: ${relay} [${v.headline}]`
|
|
263
|
+
}
|
package/hive-receipts.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// hive-receipts.js — did the message actually reach anybody, or did it only look like it did?
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS EXISTS FOR, from this project's own history:
|
|
4
|
+
//
|
|
5
|
+
// "29 messages @-mentioned me today. hive_wait has surfaced a handful."
|
|
6
|
+
// "Those messages land in HIVE_CHAT.md and look delivered -- they just may never reach the
|
|
7
|
+
// recipient's context. The convention looks like it works, which is the worst property a
|
|
8
|
+
// coordination protocol can have."
|
|
9
|
+
//
|
|
10
|
+
// `hive_say` returns "sent to @Builder: ..." the instant the message enters the LOCAL Yjs array.
|
|
11
|
+
// It says that while the socket is down. It says that when @Builder crashed ten minutes ago. It
|
|
12
|
+
// says that when @Builder is looping without ever calling hive_wait. Three different failures,
|
|
13
|
+
// one cheerful word, and the sender has no way to tell them apart.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THE EVIDENCE IS, HERE. Agent Relay verifies a delivery by watching the terminal echo the
|
|
16
|
+
// keystrokes back (crates/broker/src/broker/delivery_verification.rs). There is no terminal here,
|
|
17
|
+
// so copying the mechanism would be cargo cult. The equivalent evidence in Hivecode is the one
|
|
18
|
+
// thing that only moves when a message is actually handed into an agent's context:
|
|
19
|
+
//
|
|
20
|
+
// THE READ CURSOR IS THE ECHO.
|
|
21
|
+
//
|
|
22
|
+
// A mailbox cursor advances only inside `box.take()`, which is what hands messages to the agent.
|
|
23
|
+
// So "their cursor passed the slot my message sits in" means the message was in the payload that
|
|
24
|
+
// agent received. Today that cursor is written to a LOCAL session file, where nobody else can see
|
|
25
|
+
// it. Publishing it into the room is the whole change.
|
|
26
|
+
//
|
|
27
|
+
// FOUR STATES, AND THE FOURTH IS THE POINT. Agent Relay has three (Success / Unverified /
|
|
28
|
+
// Failed) and the load-bearing one is Unverified -- an unconfirmed delivery must never be counted
|
|
29
|
+
// as a confirmed one. Their comment: "Unverified neither speeds up nor backs off the throttle,
|
|
30
|
+
// but breaks the consecutive-success streak so unverified deliveries never drive the delay down."
|
|
31
|
+
//
|
|
32
|
+
// The same rule here, phrased for this system:
|
|
33
|
+
//
|
|
34
|
+
// READ their cursor passed it. Evidence it landed.
|
|
35
|
+
// UNREAD they publish cursors, and theirs is behind it. Evidence it did NOT.
|
|
36
|
+
// UNKNOWN they publish no cursor at all. NO evidence either way -- and it must never be
|
|
37
|
+
// counted as READ. A Telegram bridge, a human's browser and an older build all
|
|
38
|
+
// land here, so silence from this state means nothing at all.
|
|
39
|
+
// UNREACHABLE they are not in the room, or WE are offline. It is not going to arrive.
|
|
40
|
+
//
|
|
41
|
+
// UNKNOWN is the honest state that a two-state design (delivered / not) cannot express, and
|
|
42
|
+
// collapsing it into either neighbour is how a receipt system starts lying. `countDelivered`
|
|
43
|
+
// exists precisely so that no caller can accidentally sum UNKNOWN into the good number.
|
|
44
|
+
//
|
|
45
|
+
// NO STORED INDEX. A Yjs array index is not stable: two peers pushing concurrently converge in a
|
|
46
|
+
// deterministic order that need not be the order either one observed, so a message's index can
|
|
47
|
+
// move after it was recorded. Receipts are therefore DERIVED at query time by locating the
|
|
48
|
+
// message in the converged array -- the same choice `unansweredFor` made in hive-mailbox.js, for
|
|
49
|
+
// the same reason: nothing to migrate and nothing that can drift.
|
|
50
|
+
|
|
51
|
+
// @-parsing is imported, never re-implemented. A second hand-written copy of "who was named in
|
|
52
|
+
// this text" is exactly the shape Agent Relay wrote up in learnings/duplicated-wire-contract.md
|
|
53
|
+
// and exactly the shape that already bit this repo once as a duplicated reconcile(): two
|
|
54
|
+
// descriptions of one thing, only one of which is ever checked against reality.
|
|
55
|
+
import { mentionedNames, mentionsAll } from './hive-mailbox.js'
|
|
56
|
+
|
|
57
|
+
export const READ = 'READ'
|
|
58
|
+
export const UNREAD = 'UNREAD'
|
|
59
|
+
export const UNKNOWN = 'UNKNOWN'
|
|
60
|
+
export const UNREACHABLE = 'UNREACHABLE'
|
|
61
|
+
|
|
62
|
+
/** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
|
|
63
|
+
export const DELIVERED_STATES = [READ]
|
|
64
|
+
|
|
65
|
+
const lc = (x) => String(x == null ? '' : x).toLowerCase()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Publish my read position into the room.
|
|
69
|
+
*
|
|
70
|
+
* `at` is a count of messages consumed, so it only ever moves forward. It is clamped against
|
|
71
|
+
* going BACKWARDS because a rejoin can hand a session a stale persisted cursor, and a receipt
|
|
72
|
+
* that un-reads a message somebody already answered is worse than no receipt at all.
|
|
73
|
+
*/
|
|
74
|
+
export function publishCursor(cursors, name, at, now = Date.now) {
|
|
75
|
+
if (!cursors || !name) return null
|
|
76
|
+
const prev = cursors.get(String(name))
|
|
77
|
+
const next = Math.max(0, Number(at) || 0)
|
|
78
|
+
const highest = Math.max(next, (prev && Number(prev.at)) || 0)
|
|
79
|
+
const rec = { at: highest, ts: now(), ver: 1 }
|
|
80
|
+
// Writing an identical value would still be a CRDT update, waking every observer in the room
|
|
81
|
+
// on every tool call for no information. Only a real advance is published.
|
|
82
|
+
if (prev && prev.at === highest) return prev
|
|
83
|
+
cursors.set(String(name), rec)
|
|
84
|
+
return rec
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
|
|
88
|
+
export function cursorOf(cursors, name) {
|
|
89
|
+
if (!cursors) return null
|
|
90
|
+
const direct = cursors.get(String(name))
|
|
91
|
+
if (direct) return direct
|
|
92
|
+
for (const [k, v] of cursors.entries()) if (lc(k) === lc(name)) return v
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where a message sits in the converged chat array, or -1.
|
|
98
|
+
*
|
|
99
|
+
* Identity is (by, at, text) rather than an index: the triple survives reordering, and the index
|
|
100
|
+
* does not. Searched from the end because a receipt is almost always asked about something said
|
|
101
|
+
* recently, and the room's history can be thousands of lines.
|
|
102
|
+
*/
|
|
103
|
+
export function findIndex(chat, msg) {
|
|
104
|
+
if (!Array.isArray(chat) || !msg) return -1
|
|
105
|
+
for (let i = chat.length - 1; i >= 0; i--) {
|
|
106
|
+
const m = chat[i]
|
|
107
|
+
if (m && m.by === msg.by && m.at === msg.at && m.text === msg.text) return i
|
|
108
|
+
}
|
|
109
|
+
return -1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The state of ONE message for ONE recipient.
|
|
114
|
+
*
|
|
115
|
+
* Order matters. `connected === false` outranks everything, because if our own socket is down the
|
|
116
|
+
* message has not left this process and nothing we believe about the other side is worth saying.
|
|
117
|
+
* Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
|
|
118
|
+
* actionable of the two.
|
|
119
|
+
*/
|
|
120
|
+
export function stateFor({ cursors, name, index, present = true, connected = true }) {
|
|
121
|
+
if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
|
|
122
|
+
if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
|
|
123
|
+
if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
|
|
124
|
+
const cur = cursorOf(cursors, name)
|
|
125
|
+
if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
|
|
126
|
+
// A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
|
|
127
|
+
// been handed over exactly when the cursor is strictly greater than it.
|
|
128
|
+
if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
|
|
129
|
+
return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The state of ONE message for ONE recipient, ASKED AS A DIFFERENT QUESTION.
|
|
134
|
+
*
|
|
135
|
+
* `stateFor` above answers "should I wait for a reply from them right now?", so absence outranks
|
|
136
|
+
* a cursor: an agent that read your message and then left still cannot answer you, and blocking on
|
|
137
|
+
* them is the failure that function exists to prevent. That is correct, it is deliberate, and
|
|
138
|
+
* tests/receipts-test.mjs D1 pins it in capital letters.
|
|
139
|
+
*
|
|
140
|
+
* This one answers a different question: "did this message ever land?" — and there the cursor
|
|
141
|
+
* outranks absence, because A READ IS A FACT ABOUT THE PAST AND LOGGING OFF CANNOT UNDO IT.
|
|
142
|
+
*
|
|
143
|
+
* WHY TWO FUNCTIONS AND NOT A FLAG. A boolean that silently changes what the returned STRING means
|
|
144
|
+
* is how this class of bug is born: a caller that forgets it gets a confident answer to a question
|
|
145
|
+
* it did not ask. Two names cannot be confused by omission.
|
|
146
|
+
*
|
|
147
|
+
* WHY THIS ORDER, AND IT IS THE WHOLE DESIGN:
|
|
148
|
+
* - `!cur` is checked BEFORE `!present`. A human in a browser, a Telegram bridge and every older
|
|
149
|
+
* build publish no cursor at all. Reaching `!present` first would render them UNREACHABLE with
|
|
150
|
+
* the claim "they left without reading it" — a claim we cannot support, because we have no
|
|
151
|
+
* evidence either way. That is precisely what UNKNOWN exists to express, and folding it into
|
|
152
|
+
* UNREACHABLE would make every human in the room read as a delivery failure forever.
|
|
153
|
+
* - `!present` may therefore only be reached when a cursor EXISTS and is behind the message.
|
|
154
|
+
* Only then is "they left without reading it" a measurement rather than a guess.
|
|
155
|
+
* - `connected` is absent on purpose. The cursors map is LOCAL. Our own socket being down right
|
|
156
|
+
* now says nothing about whether a three-day-old message was read; that was recorded and is
|
|
157
|
+
* already here. Gate this on connectivity and the whole transcript blanks to UNKNOWN during
|
|
158
|
+
* every reconnect — exactly when a human is reading it to work out what happened.
|
|
159
|
+
*
|
|
160
|
+
* Built by DeepMind-Agent (Gemini, in its own container) in room `night`, 20 Sep 2026, against a
|
|
161
|
+
* design argued in the room over four rounds. Tests are antigravity1's, written before the code
|
|
162
|
+
* existed and verified red first: tests/transcript-receipts-test.mjs.
|
|
163
|
+
*/
|
|
164
|
+
export function transcriptStateFor({ cursors, name, index, present = true }) {
|
|
165
|
+
if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
|
|
166
|
+
const cur = cursorOf(cursors, name)
|
|
167
|
+
if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
|
|
168
|
+
// A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
|
|
169
|
+
// been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
|
|
170
|
+
// at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
|
|
171
|
+
if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
|
|
172
|
+
// Only reachable when a cursor exists and is behind the message.
|
|
173
|
+
if (!present) return { name, state: UNREACHABLE, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is not in the room, and left with cursor behind this message` }
|
|
174
|
+
return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Only READ counts. Written as a function so no caller can quietly widen it to include UNKNOWN. */
|
|
178
|
+
export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.includes(s.state)).length
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every message I addressed to somebody, with per-recipient state.
|
|
182
|
+
*
|
|
183
|
+
* Unaddressed messages are skipped on purpose: they land in the ambient digest, where agents are
|
|
184
|
+
* instructed to skip them, so a receipt for one would be measuring something nobody promised.
|
|
185
|
+
* `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
|
|
186
|
+
* one message obliging eight receipts is the storm, not the signal.
|
|
187
|
+
*/
|
|
188
|
+
export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
|
|
189
|
+
const list = Array.isArray(chat) ? chat : []
|
|
190
|
+
const out = []
|
|
191
|
+
for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
|
|
192
|
+
const m = list[i]
|
|
193
|
+
if (!m || lc(m.by) !== lc(me)) continue
|
|
194
|
+
if (mentionsAll(m.text)) continue
|
|
195
|
+
const named = mentionedNames(m.text).filter((n) => lc(n) !== 'all' && lc(n) !== lc(me))
|
|
196
|
+
if (!named.length) continue
|
|
197
|
+
out.push({
|
|
198
|
+
at: m.at,
|
|
199
|
+
text: String(m.text || '').slice(0, 120),
|
|
200
|
+
to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
return out.reverse()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
|
|
207
|
+
|
|
208
|
+
/** One line per message. The marks make a column a human can scan; the words carry the meaning. */
|
|
209
|
+
export function describe(r) {
|
|
210
|
+
return `${r.at} ${r.to.map((s) => `${MARK[s.state] || '?'} ${s.name}=${s.state}`).join(' ')} "${r.text}"`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* What hive_say should say instead of "sent".
|
|
215
|
+
*
|
|
216
|
+
* ONE FAILURE ONLY, AND THAT IS DELIBERATE. At send time delivery is not knowable -- the message
|
|
217
|
+
* was created a millisecond ago -- so the single thing worth saying is the one that is already
|
|
218
|
+
* decided and is currently reported as success: our own socket is down.
|
|
219
|
+
*
|
|
220
|
+
* It says nothing about an ABSENT recipient even though that is equally decidable, because
|
|
221
|
+
* `describeRecipient` in hive-busy.js already says it ("@X is NOT in the room — nobody will read
|
|
222
|
+
* that") and a second hand-written copy of that sentence is precisely the defect class Agent
|
|
223
|
+
* Relay wrote up in learnings/duplicated-wire-contract.md: two descriptions of one fact, only one
|
|
224
|
+
* of which anybody keeps current. One producer, one description.
|
|
225
|
+
*
|
|
226
|
+
* On success it returns the empty string. A reassuring word at send time IS the defect.
|
|
227
|
+
*/
|
|
228
|
+
export function sendWarning({ connected }) {
|
|
229
|
+
if (connected) return ''
|
|
230
|
+
return 'NOT SENT — this client is OFFLINE. The message is in your local copy only; it has not ' +
|
|
231
|
+
'reached the relay and no one else can see it. It will go out by itself when the connection ' +
|
|
232
|
+
'heals (do NOT call hive_join, that tears down the session). Until then, treat it as unsent.'
|
|
233
|
+
}
|