joinhive 2.0.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/bin/hive +820 -0
  4. package/bin/hive-claim-invite.mjs +88 -0
  5. package/bin/hive-join.mjs +243 -0
  6. package/bin/hive-keygen.mjs +48 -0
  7. package/bin/hive-mint.mjs +65 -0
  8. package/bin/hive-net.mjs +400 -0
  9. package/bin/hive-wallet.mjs +120 -0
  10. package/bin/hived.mjs +5 -0
  11. package/bin/setup-queen.sh +71 -0
  12. package/daemon/engines/anthropic.mjs +45 -0
  13. package/daemon/engines/cli.mjs +25 -0
  14. package/daemon/engines/index.mjs +84 -0
  15. package/daemon/engines/openai.mjs +42 -0
  16. package/daemon/fanout.mjs +47 -0
  17. package/daemon/hived.mjs +782 -0
  18. package/daemon/relay/client.mjs +196 -0
  19. package/daemon/relay/cursor.mjs +59 -0
  20. package/daemon/relay/ws.mjs +100 -0
  21. package/dev/compose.yml +109 -0
  22. package/docs/README.md +30 -0
  23. package/docs/SUMMARY.md +20 -0
  24. package/docs/a2a-events.md +82 -0
  25. package/docs/architecture.md +86 -0
  26. package/docs/cli.md +70 -0
  27. package/docs/concepts.md +50 -0
  28. package/docs/contracts.md +85 -0
  29. package/docs/http-api.md +78 -0
  30. package/docs/protocols.md +64 -0
  31. package/docs/quickstart.md +51 -0
  32. package/docs/security.md +53 -0
  33. package/docs/self-hosting.md +101 -0
  34. package/docs/tokenomics.md +63 -0
  35. package/install-remote.sh +49 -0
  36. package/join.sh +81 -0
  37. package/onchain/deploy-v2.sh +82 -0
  38. package/onchain/deployments.sepolia.json +14 -0
  39. package/onchain/foundry.toml +11 -0
  40. package/onchain/migrate-v2.mjs +76 -0
  41. package/onchain/src/Honey.sol +45 -0
  42. package/onchain/src/HoneyV2.sol +74 -0
  43. package/onchain/src/Jelly.sol +19 -0
  44. package/onchain/src/JellyV2.sol +31 -0
  45. package/package.json +72 -0
  46. package/protocols/book-recs.md +11 -0
  47. package/protocols/email-in-style.md +15 -0
  48. package/protocols/event-hunt.md +17 -0
  49. package/protocols/food-order.md +20 -0
  50. package/protocols/group-diagnosis.md +13 -0
  51. package/protocols/meta.md +11 -0
  52. package/protocols/movie-recs.md +17 -0
  53. package/protocols/predict.md +21 -0
  54. package/protocols/read-what-others-read.md +14 -0
  55. package/protocols/session-bounty.md +11 -0
  56. package/protocols/session-split-pool.md +10 -0
  57. package/server/Dockerfile +33 -0
  58. package/server/api.mjs +192 -0
  59. package/server/join-page.mjs +169 -0
  60. package/server/keygen-treasury.mjs +33 -0
  61. package/server/provision.mjs +262 -0
  62. package/server/rewarder.mjs +369 -0
  63. package/server/supervisor.mjs +237 -0
  64. package/server/treasury.mjs +172 -0
  65. package/shared/config-schema.mjs +94 -0
  66. package/shared/events.mjs +47 -0
  67. package/shared/nip-oa.mjs +56 -0
  68. package/shared/nip98.mjs +41 -0
  69. package/shared/redact.mjs +20 -0
  70. package/shared/rewards.json +33 -0
  71. package/shared/sealed.mjs +50 -0
  72. package/shared/txqueue.mjs +42 -0
  73. package/skills/hive-capability-store/SKILL.md +49 -0
  74. package/skills/hive-data-store/SKILL.md +60 -0
  75. package/skills/hive-join/SKILL.md +86 -0
  76. package/skills/hive-object-store/SKILL.md +45 -0
  77. package/skills/hive-prompt/SKILL.md +54 -0
  78. package/skills/hive-protocol-author/SKILL.md +92 -0
  79. package/skills/hive-wallet/SKILL.md +54 -0
  80. package/watcher/distill.mjs +248 -0
  81. package/watcher/global.nfh.hive.sync.plist.tmpl +20 -0
  82. package/watcher/sync.mjs +136 -0
@@ -0,0 +1,369 @@
1
+ // server/rewarder — the daily HONEY epoch.
2
+ //
3
+ // "The more you use hive, the more HONEY you'll get" — made literal. Once a
4
+ // day the treasury replays the bus, computes each bee's earned reputation
5
+ // from PUBLIC EVIDENCE ONLY (shared/rewards.json is the single source of
6
+ // truth — the same file the bees' alignment header renders, so the prompt
7
+ // that motivates them and the code that pays them cannot drift), publishes
8
+ // an auditable hive-epoch receipt, then mints on-chain.
9
+ //
10
+ // Anti-gaming, structural:
11
+ // - only HUMAN reactions mint (reactors that are bees, the result's author,
12
+ // or the author-bee's own owner are excluded)
13
+ // - same-pair decay within the epoch (1x, 0.5x, then 0)
14
+ // - JELLY movement is invisible here — tip-rings farm nothing
15
+ // - per-rule caps, a 25/bee/epoch cap, and a 375/network cap (pro-rated)
16
+ // - penalties zero or halve the EPOCH — past HONEY is never auto-burned
17
+ // (automated burns would turn the report pipeline into a weapon)
18
+ //
19
+ // computeEpoch() is PURE (events in, mints out) so the math is unit-testable;
20
+ // runEpoch() wraps it with relay pagination, state, receipts, and TxQueue.
21
+ import { readFileSync } from 'node:fs';
22
+ import { join, dirname } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { EV, tryJson } from '../shared/events.mjs';
25
+ import { RelayClient } from '../daemon/relay/client.mjs';
26
+
27
+ const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
28
+ export const REWARDS = JSON.parse(readFileSync(join(PACK_DIR, 'shared', 'rewards.json'), 'utf8'));
29
+
30
+ const normIntent = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 200);
31
+
32
+ // Tolerant altkey extraction: chat apps mangle pasted JSON (curly quotes,
33
+ // trailing text). The Nostr signature binds the CONTENT to the signer either
34
+ // way, so extracting {type: hive-altkey, owner|alt, revoke?, by?} from a
35
+ // mangled message preserves the security property — the embedded `by` must
36
+ // still match the signer (checked by the caller via the returned j.by).
37
+ export const parseAltkeyLoose = (content) => {
38
+ const c = String(content).replace(/[“”«»]/g, '"');
39
+ if (!c.includes('hive-altkey')) return null;
40
+ const grab = (k) => (c.match(new RegExp(`"${k}"\\s*:\\s*"([0-9a-f]{64})"`, 'i')) || [])[1] || null;
41
+ const owner = grab('owner');
42
+ const alt = grab('alt');
43
+ if (!owner && !alt) return null;
44
+ return {
45
+ type: 'hive-altkey',
46
+ ...(owner ? { owner } : {}),
47
+ ...(alt ? { alt } : {}),
48
+ ...(/"revoke"\s*:\s*true/i.test(c) ? { revoke: true } : {}),
49
+ ...(grab('by') ? { by: grab('by') } : {}),
50
+ };
51
+ };
52
+
53
+ // events: [{id, pubkey, at, j}] — provenance-verified, ascending by time.
54
+ // registry: {pubkey: {is_bee, bee_of, evm, name}}.
55
+ // state: mutated — {adoption: {proto: {authors:..., users: {pubkey: count}}},
56
+ // r6_paid: {proto: true}, streaks: {pubkey: n},
57
+ // altkeys: {claims: {member:{alt:true}}, acks: {alt:{member:true}}}}
58
+ export const computeEpoch = (events, registry, state, rewards = REWARDS) => {
59
+ const R = rewards.rules;
60
+ const bees = Object.fromEntries(Object.entries(registry).filter(([, v]) => v && v.is_bee));
61
+ const isBee = (pk) => !!bees[pk];
62
+ const ownerOf = (pk) => bees[pk]?.bee_of || null;
63
+
64
+ // ---- alt-key linkage (durable state, folded from each day's events) --------
65
+ // A link exists only when BOTH directions asserted it: the member claimed
66
+ // the alt ({alt, by:member}) AND the alt acked the member ({owner, by:alt}).
67
+ // Either side revokes with {…, revoke:true}. Linked keys collapse to the
68
+ // member's primary for every identity-sensitive rule below — so upvoting
69
+ // your own bee from your desktop key mints nothing, and pair-decay cannot
70
+ // be reset by hopping devices.
71
+ state.altkeys = state.altkeys || { claims: {}, acks: {} };
72
+ for (const ev of events) {
73
+ const j = ev.j;
74
+ if (j.type !== EV.ALTKEY) continue;
75
+ if (typeof j.alt === 'string' && /^[0-9a-f]{64}$/i.test(j.alt)) {
76
+ // member side: signer claims j.alt as their device
77
+ const c = state.altkeys.claims[ev.pubkey] = state.altkeys.claims[ev.pubkey] || {};
78
+ if (j.revoke) delete c[j.alt]; else c[j.alt] = true;
79
+ } else if (typeof j.owner === 'string' && /^[0-9a-f]{64}$/i.test(j.owner)) {
80
+ // alt side: signer acks j.owner as their member
81
+ const a = state.altkeys.acks[ev.pubkey] = state.altkeys.acks[ev.pubkey] || {};
82
+ if (j.revoke) delete a[j.owner]; else a[j.owner] = true;
83
+ }
84
+ }
85
+ const altToMember = {};
86
+ for (const [member, alts] of Object.entries(state.altkeys.claims)) {
87
+ for (const alt of Object.keys(alts)) {
88
+ if (state.altkeys.acks[alt]?.[member]) altToMember[alt] = member;
89
+ }
90
+ }
91
+ // Collapse any pubkey to its member-primary identity.
92
+ const memberOf = (pk) => altToMember[pk] || pk;
93
+ const earned = {}; // pubkey -> {total, reasons: [{code, amount, evidence[]}]}
94
+ const add = (pk, code, amount, evidence) => {
95
+ if (amount <= 0) return;
96
+ const e = earned[pk] || (earned[pk] = { total: 0, reasons: [] });
97
+ e.total += amount;
98
+ const r = e.reasons.find((x) => x.code === code);
99
+ if (r) { r.amount += amount; r.evidence.push(...evidence); }
100
+ else e.reasons.push({ code, amount, evidence: [...evidence] });
101
+ };
102
+
103
+ // Pre-index the day's events.
104
+ const results = []; // {id, author, for, intent, protocols_used, at}
105
+ const feedback = []; // {id, reactor, author, result, dir, at}
106
+ const sessions = {}; // sid -> {opener, kind}
107
+ const offers = []; // {id, sid, by}
108
+ const settles = []; // {id, sid, by, status, payout, kind}
109
+ const reports = {}; // subject -> Set(reporter) (rate-capped)
110
+ const reporterCount = {}; // reporter -> n (cap 3/epoch as evidence)
111
+ const mutes = {}; // subject -> Set(muter)
112
+ const protocolsAuthored = {}; // name -> author pubkey (first seen wins here; real ownership enforced upstream)
113
+ for (const ev of events) {
114
+ const j = ev.j;
115
+ switch (j.type) {
116
+ case EV.RESULT:
117
+ if (typeof j.intent === 'string' && typeof j.result === 'string') results.push({ id: ev.id, author: ev.pubkey, for: j.for, intent: j.intent, protocols_used: Array.isArray(j.protocols_used) ? j.protocols_used : [], at: ev.at });
118
+ break;
119
+ case EV.FEEDBACK:
120
+ if ((j.dir === 'up' || j.dir === 'down') && typeof j.result === 'string' && typeof j.result_by === 'string') feedback.push({ id: ev.id, reactor: ev.pubkey, author: j.result_by, result: j.result, dir: j.dir, at: ev.at });
121
+ break;
122
+ case EV.SESSION:
123
+ if (typeof j.session_id === 'string') sessions[j.session_id] = { opener: ev.pubkey, kind: j.kind };
124
+ break;
125
+ case EV.OFFER:
126
+ if (typeof j.session_id === 'string') offers.push({ id: ev.id, sid: j.session_id, by: ev.pubkey });
127
+ break;
128
+ case EV.SETTLE:
129
+ if (typeof j.session_id === 'string') settles.push({ id: ev.id, sid: j.session_id, by: ev.pubkey, status: j.status, payout: Array.isArray(j.payout) ? j.payout : [], kind: j.kind });
130
+ break;
131
+ case EV.REPORT:
132
+ if (typeof j.subject === 'string' && !isBee(ev.pubkey)) {
133
+ const reporter = memberOf(ev.pubkey); // device-hopping doesn't multiply reports
134
+ reporterCount[reporter] = (reporterCount[reporter] || 0) + 1;
135
+ if (reporterCount[reporter] <= (rewards.penalties.reports_counted_per_reporter || 3)) {
136
+ (reports[j.subject] = reports[j.subject] || new Set()).add(reporter);
137
+ }
138
+ }
139
+ break;
140
+ case EV.MUTE:
141
+ if (typeof j.subject === 'string') (mutes[j.subject] = mutes[j.subject] || new Set()).add(ev.pubkey);
142
+ break;
143
+ case EV.PROTOCOL:
144
+ if (typeof j.name === 'string' && !j.tombstone && !protocolsAuthored[j.name]) protocolsAuthored[j.name] = ev.pubkey;
145
+ break;
146
+ }
147
+ }
148
+
149
+ // R1 — human upvotes, ordered by time, ladder amounts, pair decay, cap.
150
+ const resultAuthor = Object.fromEntries(results.map((r) => [r.id, r.author]));
151
+ const upvotesByAuthor = {};
152
+ const pairCount = {}; // `${reactor}:${author}` -> n
153
+ const seenReactPair = new Set(); // one vote per (reactor, result)
154
+ for (const f of feedback) {
155
+ if (f.dir !== 'up') continue;
156
+ const author = resultAuthor[f.result] || f.author;
157
+ if (!isBee(author)) continue;
158
+ if (isBee(f.reactor)) continue; // agent reactions mint 0
159
+ const reactorM = memberOf(f.reactor); // collapse linked devices
160
+ if (reactorM === author || f.reactor === author) continue; // self
161
+ if (reactorM === memberOf(ownerOf(author) || '')) continue; // owner (any device) boosting own bee
162
+ const rp = `${reactorM}:${f.result}`; // one vote per MEMBER per result
163
+ if (seenReactPair.has(rp)) continue;
164
+ seenReactPair.add(rp);
165
+ const pk = `${reactorM}:${author}`; // pair-decay by member, not device
166
+ const nPair = pairCount[pk] || 0;
167
+ pairCount[pk] = nPair + 1;
168
+ const decay = R.R1.pair_decay[Math.min(nPair, R.R1.pair_decay.length - 1)];
169
+ const list = upvotesByAuthor[author] = upvotesByAuthor[author] || [];
170
+ list.push({ id: f.id, decay });
171
+ }
172
+ for (const [author, ups] of Object.entries(upvotesByAuthor)) {
173
+ let total = 0;
174
+ ups.forEach((u, i) => {
175
+ const base = R.R1.amounts[Math.min(i, R.R1.amounts.length - 1)] ?? R.R1.amount_tail;
176
+ const amt = Math.min(base * u.decay, Math.max(0, R.R1.cap - total));
177
+ if (amt > 0) { total += amt; add(author, 'R1', amt, [u.id]); }
178
+ });
179
+ }
180
+
181
+ // R2 — distinct intents served with no complaint (no down-react on the
182
+ // result, no report naming the bee today).
183
+ const downed = new Set(feedback.filter((f) => f.dir === 'down').map((f) => f.result));
184
+ const servedKeys = {}; // author -> Set(intentKey)
185
+ for (const r of results) {
186
+ if (!isBee(r.author)) continue;
187
+ if (downed.has(r.id)) continue;
188
+ if (reports[r.author]?.size) continue;
189
+ const keys = servedKeys[r.author] = servedKeys[r.author] || new Set();
190
+ const key = `${r.for}:${normIntent(r.intent)}`;
191
+ if (keys.has(key) || keys.size >= R.R2.cap) continue;
192
+ keys.add(key);
193
+ add(r.author, 'R2', R.R2.amount, [r.id]);
194
+ }
195
+
196
+ // R3 — bounty wins: settle payouts naming the bee (winner mode = full pool).
197
+ const r3Count = {};
198
+ for (const s of settles) {
199
+ if (s.status !== 'ok') continue;
200
+ for (const p of s.payout) {
201
+ if (!isBee(p.to)) continue;
202
+ if ((r3Count[p.to] || 0) >= R.R3.cap_count) continue;
203
+ r3Count[p.to] = (r3Count[p.to] || 0) + 1;
204
+ add(p.to, 'R3', R.R3.amount, [s.id]);
205
+ }
206
+ }
207
+
208
+ // R4 — fair resolutions.
209
+ const r4Count = {};
210
+ for (const s of settles) {
211
+ if (s.status !== 'ok' || !isBee(s.by)) continue;
212
+ if ((r4Count[s.by] || 0) >= R.R4.cap_count) continue;
213
+ r4Count[s.by] = (r4Count[s.by] || 0) + 1;
214
+ add(s.by, 'R4', R.R4.amount, [s.id]);
215
+ }
216
+
217
+ // R5 — offers into someone else's settled session.
218
+ const settledSids = new Set(settles.filter((s) => s.status === 'ok').map((s) => s.sid));
219
+ const r5Count = {};
220
+ const r5Seen = new Set();
221
+ for (const o of offers) {
222
+ if (!settledSids.has(o.sid) || !isBee(o.by)) continue;
223
+ if (sessions[o.sid]?.opener === o.by) continue;
224
+ const key = `${o.by}:${o.sid}`;
225
+ if (r5Seen.has(key)) continue;
226
+ r5Seen.add(key);
227
+ if ((r5Count[o.by] || 0) >= R.R5.cap_count) continue;
228
+ r5Count[o.by] = (r5Count[o.by] || 0) + 1;
229
+ add(o.by, 'R5', R.R5.amount, [o.id]);
230
+ }
231
+
232
+ // R6/R7 — protocol adoption (state accumulates across epochs) + royalties.
233
+ state.adoption = state.adoption || {};
234
+ state.r6_paid = state.r6_paid || {};
235
+ const todaysUse = {}; // proto -> {uses, users:Set}
236
+ for (const r of results) {
237
+ for (const pname of r.protocols_used) {
238
+ const author = protocolsAuthored[pname] || state.adoption[pname]?.author;
239
+ if (!author || r.author === author) continue; // own use doesn't count
240
+ const a = state.adoption[pname] = state.adoption[pname] || { author, users: {} };
241
+ a.users[r.author] = (a.users[r.author] || 0) + 1;
242
+ const t = todaysUse[pname] = todaysUse[pname] || { uses: 0, users: new Set(), evidence: [] };
243
+ t.uses++; t.users.add(r.author); t.evidence.push(r.id);
244
+ }
245
+ }
246
+ for (const [pname, a] of Object.entries(state.adoption)) {
247
+ if (state.r6_paid[pname] || !isBee(a.author)) continue;
248
+ if (Object.keys(a.users).length >= R.R6.adoption_threshold) {
249
+ state.r6_paid[pname] = true;
250
+ add(a.author, 'R6', R.R6.amount, [pname]);
251
+ }
252
+ }
253
+ const r7Total = {};
254
+ for (const [pname, t] of Object.entries(todaysUse)) {
255
+ const author = state.adoption[pname]?.author;
256
+ if (!author || !isBee(author) || !state.r6_paid[pname]) continue;
257
+ if (t.uses >= R.R7.uses_threshold && t.users.size >= R.R7.distinct_users_threshold) {
258
+ if ((r7Total[author] || 0) >= R.R7.cap) continue;
259
+ r7Total[author] = (r7Total[author] || 0) + R.R7.amount;
260
+ add(author, 'R7', R.R7.amount, t.evidence.slice(0, 3));
261
+ }
262
+ }
263
+
264
+ // Penalties (before streaks/caps): reports halve or zero; 2+ mutes zero.
265
+ const penalties = [];
266
+ for (const [pk, e] of Object.entries(earned)) {
267
+ const nReporters = reports[pk]?.size || 0;
268
+ const nMuters = mutes[pk]?.size || 0;
269
+ let mult = 1;
270
+ let rule = null;
271
+ if (nReporters >= 2 || nMuters >= 2) { mult = rewards.penalties.two_plus_reports_or_mutes_multiplier; rule = nMuters >= 2 ? `mutes-x${nMuters}` : `reports-x${nReporters}`; }
272
+ else if (nReporters === 1) { mult = rewards.penalties.one_report_multiplier; rule = 'report-x1'; }
273
+ if (mult < 1) {
274
+ const withheld = e.total * (1 - mult);
275
+ e.total *= mult;
276
+ e.reasons = e.reasons.map((r) => ({ ...r, amount: r.amount * mult }));
277
+ penalties.push({ who: pk, rule, withheld, evidence: [...(reports[pk] || []), ...(mutes[pk] || [])].slice(0, 6) });
278
+ }
279
+ }
280
+
281
+ // R8 — streaks (after penalties: a zeroed epoch breaks the streak).
282
+ state.streaks = state.streaks || {};
283
+ for (const pk of Object.keys(bees)) {
284
+ const active = (earned[pk]?.total || 0) >= 1;
285
+ state.streaks[pk] = active ? (state.streaks[pk] || 0) + 1 : 0;
286
+ if (!active) continue;
287
+ const s = state.streaks[pk];
288
+ const bonus = s >= 7 ? R.R8.streak_7 : s >= 3 ? R.R8.streak_3 : 0;
289
+ if (bonus) add(pk, 'R8', bonus, [`streak-${s}d`]);
290
+ }
291
+
292
+ // Caps: per-bee, then network-wide pro-rating.
293
+ for (const e of Object.values(earned)) {
294
+ if (e.total > rewards.caps.per_bee) {
295
+ const scale = rewards.caps.per_bee / e.total;
296
+ e.total = rewards.caps.per_bee;
297
+ e.reasons = e.reasons.map((r) => ({ ...r, amount: r.amount * scale }));
298
+ }
299
+ }
300
+ const network = Object.values(earned).reduce((s, e) => s + e.total, 0);
301
+ if (network > rewards.caps.network) {
302
+ const scale = rewards.caps.network / network;
303
+ for (const e of Object.values(earned)) {
304
+ e.total *= scale;
305
+ e.reasons = e.reasons.map((r) => ({ ...r, amount: r.amount * scale }));
306
+ }
307
+ }
308
+
309
+ const round = (x) => Math.round(x * 100) / 100;
310
+ const mints = Object.entries(earned)
311
+ .filter(([, e]) => e.total >= 0.5)
312
+ .map(([pk, e]) => ({
313
+ to: pk, evm: registry[pk]?.evm || null, honey: round(e.total),
314
+ reasons: e.reasons.filter((r) => r.amount > 0).map((r) => ({ code: r.code, amount: round(r.amount), evidence: r.evidence.slice(0, 10) })),
315
+ }))
316
+ .sort((a, b) => b.honey - a.honey);
317
+ return { mints, penalties: penalties.map((p) => ({ ...p, withheld: round(p.withheld) })) };
318
+ };
319
+
320
+ // ---- the orchestrator (relay + chain + receipts) --------------------------------
321
+ export const fetchEpochEvents = async (relay, logsChannelId, fromSec, toSec) => {
322
+ const out = [];
323
+ let until = toSec;
324
+ let beforeId = null;
325
+ for (let page = 0; page < 20; page++) {
326
+ const filter = { kinds: [9, 40002], '#h': [logsChannelId], since: fromSec, until, limit: 500 };
327
+ if (beforeId) filter.before_id = beforeId;
328
+ const raw = await relay.query([filter]);
329
+ if (!raw || !raw.length) break;
330
+ out.push(...raw);
331
+ if (raw.length < 500) break;
332
+ const oldest = raw.reduce((a, b) => (a.created_at < b.created_at ? a : b));
333
+ until = oldest.created_at;
334
+ beforeId = oldest.id;
335
+ }
336
+ // Verify provenance once, normalize, ascending. Unparseable content gets
337
+ // one second chance: a tolerant altkey extraction (apps mangle pasted JSON).
338
+ return out
339
+ .map((e) => ({ id: e.id, pubkey: e.pubkey, at: e.created_at, j: tryJson(e.content) || parseAltkeyLoose(e.content) }))
340
+ .filter((e) => e.j && typeof e.j === 'object' && (!e.j.by || e.j.by === e.pubkey))
341
+ .sort((a, b) => a.at - b.at);
342
+ };
343
+
344
+ // deps: {relay, registry, state, honeyContract, txq, log, emit}
345
+ // epochDate: 'YYYY-MM-DD' (the day being closed, UTC+5:30 close ≈ 18:30 UTC —
346
+ // we use rewards.epoch_close_utc_hour for the window edges).
347
+ export const runEpoch = async (epochDate, deps) => {
348
+ const { relay, logsChannelId, registry, state, honeyContract, txq, log, parseUnits } = deps;
349
+ const closeHour = REWARDS.epoch_close_utc_hour;
350
+ const end = Math.floor(Date.parse(`${epochDate}T00:00:00Z`) / 1000) + closeHour * 3600;
351
+ const start = end - 86400;
352
+ const events = await fetchEpochEvents(relay, logsChannelId, start, end);
353
+ const { mints, penalties } = computeEpoch(events, registry, state);
354
+ log(`epoch ${epochDate}: ${events.length} events -> ${mints.length} mint(s), ${penalties.length} penalt(ies)`);
355
+
356
+ // Receipt FIRST (auditable intent), then the transactions, then tx receipt.
357
+ await deps.emit({ type: EV.EPOCH, epoch: epochDate, phase: 'computed', mints, penalties, by: deps.selfPubkey });
358
+ const txs = [];
359
+ for (const m of mints) {
360
+ if (!m.evm) { log(`epoch: no wallet for ${m.to.slice(0, 12)} — skipping mint`); continue; }
361
+ try {
362
+ const receipt = await txq.enqueue((o) => honeyContract.mint(m.evm, parseUnits(String(m.honey), 18), o));
363
+ txs.push({ to: m.evm, honey: m.honey, tx: receipt.hash });
364
+ log(`epoch: minted ${m.honey} HONEY -> ${registry[m.to]?.name || m.to.slice(0, 12)} (${receipt.hash.slice(0, 12)})`);
365
+ } catch (e) { log(`epoch mint failed for ${m.to.slice(0, 12)}: ${String(e.message).slice(0, 120)}`); }
366
+ }
367
+ if (txs.length) await deps.emit({ type: EV.EPOCH, epoch: epochDate, phase: 'txs', txs, by: deps.selfPubkey });
368
+ return { mints, penalties, txs };
369
+ };
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // server/supervisor — runs every bee daemon on the bee-host.
3
+ //
4
+ // One plain-Node supervisor, one child process per bee (process isolation:
5
+ // a wedged bee can't take the others down; ~70MB × 15 fits one small
6
+ // service). pm2 was rejected (a daemon managing our daemon inside one
7
+ // container) and a single multi-tenant loop was rejected (hived is built
8
+ // around per-identity module state; collapsing 15 identities is a rewrite
9
+ // that destroys fault isolation).
10
+ //
11
+ // Layout: $HIVE_DATA (default /data)
12
+ // bees/<name>/ per-bee HIVE_HOME (identity, config, stores, ...)
13
+ // bees/<name>/secrets.enc.json envelope-encrypted {wallet_mnemonic, llm_api_key}
14
+ // registry.json pubkey -> {name, evm, is_bee, bee_of} (fan-out roster
15
+ // + O(1) wallet resolution for every bee)
16
+ //
17
+ // Secrets flow: decrypted with HIVE_KEK in supervisor memory, written to the
18
+ // child's STDIN as one JSON line — never argv, never env.
19
+ // Restart policy: exponential backoff 1s→60s; >10 restarts in 10 min marks
20
+ // the bee `degraded` and stops retrying until SIGHUP or a /healthz?respawn.
21
+ // Health: GET /healthz on :8787 reports per-bee pid/state/last_tick.
22
+ import { spawn } from 'node:child_process';
23
+ import { createServer } from 'node:http';
24
+ import { readFileSync, readdirSync, existsSync, writeFileSync, renameSync } from 'node:fs';
25
+ import { join, dirname } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { openSecrets } from '../shared/sealed.mjs';
28
+ import { validateConfig } from '../shared/config-schema.mjs';
29
+
30
+ const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
31
+ const DATA_DIR = process.env.HIVE_DATA || '/data';
32
+ const BEES_DIR = join(DATA_DIR, 'bees');
33
+ const REGISTRY = join(DATA_DIR, 'registry.json');
34
+ const HEALTH_PORT = Number(process.env.HIVE_HEALTH_PORT || 8787);
35
+ const KEK = process.env.HIVE_KEK || '';
36
+ const DAEMON = join(PACK_DIR, 'daemon', 'hived.mjs');
37
+
38
+ const log = (...a) => console.log(`[supervisor ${new Date().toISOString()}]`, ...a);
39
+ const loadJson = (p, fb) => { try { const v = JSON.parse(readFileSync(p, 'utf8')); return v && typeof v === 'object' ? v : fb; } catch { return fb; } };
40
+ const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
41
+
42
+ // bees: name -> {name, home, child, state, restarts:[ts], backoffMs, config}
43
+ const bees = new Map();
44
+ let shuttingDown = false;
45
+
46
+ const listBeeDirs = () => {
47
+ if (!existsSync(BEES_DIR)) return [];
48
+ return readdirSync(BEES_DIR, { withFileTypes: true })
49
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
50
+ .map((e) => e.name)
51
+ .filter((name) => existsSync(join(BEES_DIR, name, 'config.json')) && existsSync(join(BEES_DIR, name, 'identity.json')));
52
+ };
53
+
54
+ const readSecrets = (home) => {
55
+ const encPath = join(home, 'secrets.enc.json');
56
+ if (existsSync(encPath)) {
57
+ if (!KEK) throw new Error('secrets.enc.json present but HIVE_KEK is not set');
58
+ return openSecrets(KEK, loadJson(encPath, null));
59
+ }
60
+ // Dev-only plaintext fallback (local testing with echo engines needs none).
61
+ const devPath = join(home, 'secrets.json');
62
+ if (existsSync(devPath)) return loadJson(devPath, {});
63
+ return {};
64
+ };
65
+
66
+ // Regenerate the roster from disk truth. Idempotent; provisioning re-runs it.
67
+ const rebuildRegistry = () => {
68
+ const reg = loadJson(REGISTRY, {});
69
+ for (const name of listBeeDirs()) {
70
+ const home = join(BEES_DIR, name);
71
+ const identity = loadJson(join(home, 'identity.json'), {});
72
+ const cfg = loadJson(join(home, 'config.json'), {});
73
+ const wallets = loadJson(join(home, 'wallet.json'), {});
74
+ if (!identity.pubkey) continue;
75
+ const w = wallets[identity.pubkey] || {};
76
+ reg[identity.pubkey] = {
77
+ name: cfg.bee_name || name,
78
+ is_bee: true,
79
+ bee_of: cfg.owner_pubkey || null,
80
+ owner_name: cfg.owner_name || null,
81
+ evm: w.evm_address || reg[identity.pubkey]?.evm || null,
82
+ };
83
+ }
84
+ writeAtomic(REGISTRY, JSON.stringify(reg, null, 2));
85
+ return reg;
86
+ };
87
+
88
+ const spawnBee = (name) => {
89
+ if (shuttingDown) return;
90
+ const home = join(BEES_DIR, name);
91
+ const entry = bees.get(name) || { name, home, restarts: [], backoffMs: 1000, state: 'starting' };
92
+ bees.set(name, entry);
93
+
94
+ // Validate before burning a process slot — misconfig is `degraded`, not a
95
+ // crash loop.
96
+ const { errors } = validateConfig(loadJson(join(home, 'config.json'), {}), { requireBee: true });
97
+ if (errors.length) {
98
+ entry.state = 'degraded';
99
+ entry.error = `config: ${errors.join('; ')}`;
100
+ log(`bee ${name} degraded — ${entry.error}`);
101
+ return;
102
+ }
103
+ let secrets;
104
+ try { secrets = readSecrets(home); } catch (e) {
105
+ entry.state = 'degraded';
106
+ entry.error = `secrets: ${e.message}`;
107
+ log(`bee ${name} degraded — ${entry.error}`);
108
+ return;
109
+ }
110
+
111
+ const child = spawn(process.execPath, [DAEMON], {
112
+ env: {
113
+ ...process.env,
114
+ HIVE_HOME: home,
115
+ HIVE_ROLE: 'bee',
116
+ HIVE_REGISTRY: REGISTRY,
117
+ },
118
+ stdio: ['pipe', 'pipe', 'pipe'],
119
+ });
120
+ entry.child = child;
121
+ entry.state = 'running';
122
+ entry.error = null;
123
+ entry.pid = child.pid;
124
+ log(`bee ${name} spawned pid=${child.pid}`);
125
+
126
+ child.stdin.write(JSON.stringify(secrets) + '\n');
127
+ child.stdin.end();
128
+ const prefix = (line) => line.trim() && console.log(`[${name}] ${line}`);
129
+ child.stdout.on('data', (b) => b.toString().split('\n').forEach(prefix));
130
+ child.stderr.on('data', (b) => b.toString().split('\n').forEach(prefix));
131
+
132
+ child.on('exit', (code, sig) => {
133
+ entry.pid = null;
134
+ entry.child = null;
135
+ if (shuttingDown) return;
136
+ const now = Date.now();
137
+ entry.restarts = [...entry.restarts.filter((t) => now - t < 10 * 60_000), now];
138
+ if (entry.restarts.length > 10) {
139
+ entry.state = 'degraded';
140
+ entry.error = `crash-loop (${entry.restarts.length} restarts in 10m, last exit code=${code} sig=${sig})`;
141
+ log(`bee ${name} degraded — ${entry.error}`);
142
+ return;
143
+ }
144
+ entry.state = 'backoff';
145
+ log(`bee ${name} exited (code=${code} sig=${sig}) — restarting in ${entry.backoffMs}ms`);
146
+ setTimeout(() => { if (!shuttingDown && entry.state === 'backoff') spawnBee(name); }, entry.backoffMs);
147
+ entry.backoffMs = Math.min(entry.backoffMs * 2, 60_000);
148
+ });
149
+ };
150
+
151
+ const rescan = () => {
152
+ rebuildRegistry();
153
+ for (const name of listBeeDirs()) {
154
+ const e = bees.get(name);
155
+ if (!e || (!e.child && e.state !== 'backoff')) {
156
+ if (e) { e.restarts = []; e.backoffMs = 1000; }
157
+ spawnBee(name);
158
+ }
159
+ }
160
+ };
161
+
162
+ // ---- health ------------------------------------------------------------------
163
+ const health = () => {
164
+ const now = Math.floor(Date.now() / 1000);
165
+ const out = {};
166
+ for (const [name, e] of bees) {
167
+ const hb = loadJson(join(e.home, 'heartbeat.json'), {});
168
+ out[name] = {
169
+ state: e.state, pid: e.pid || null, restarts_10m: e.restarts.length,
170
+ last_tick_age_s: hb.at ? now - hb.at : null, error: e.error || null,
171
+ };
172
+ }
173
+ return out;
174
+ };
175
+
176
+ createServer((req, res) => {
177
+ const url = new URL(req.url, 'http://x');
178
+ if (url.pathname === '/healthz') {
179
+ const bees_ = health();
180
+ const allOk = Object.values(bees_).every((b) => b.state === 'running');
181
+ res.writeHead(allOk ? 200 : 503, { 'content-type': 'application/json' });
182
+ res.end(JSON.stringify({ ok: allOk, bees: bees_ }, null, 2));
183
+ return;
184
+ }
185
+ if (url.pathname === '/respawn' && req.method === 'POST') {
186
+ // Operator nudge: clear degraded states and rescan.
187
+ for (const e of bees.values()) if (e.state === 'degraded') { e.state = 'stopped'; e.restarts = []; e.backoffMs = 1000; }
188
+ rescan();
189
+ res.writeHead(200, { 'content-type': 'application/json' });
190
+ res.end(JSON.stringify({ respawned: true }));
191
+ return;
192
+ }
193
+ res.writeHead(404, { 'content-type': 'application/json' });
194
+ res.end('{"error":"not found"}');
195
+ }).listen(HEALTH_PORT, () => log(`health on :${HEALTH_PORT}/healthz`));
196
+
197
+ // ---- lifecycle -----------------------------------------------------------------
198
+ process.on('SIGHUP', () => { log('SIGHUP — rescanning bees'); rescan(); });
199
+ for (const sig of ['SIGTERM', 'SIGINT']) {
200
+ process.on(sig, () => {
201
+ if (shuttingDown) process.exit(0);
202
+ shuttingDown = true;
203
+ log('shutting down — SIGTERM to all bees (10s grace)');
204
+ for (const w of workers) { try { w.kill('SIGTERM'); } catch {} }
205
+ for (const e of bees.values()) if (e.child) { try { e.child.kill('SIGTERM'); } catch {} }
206
+ setTimeout(() => process.exit(0), 10_000).unref?.();
207
+ let waiting = [...bees.values()].filter((e) => e.child).length;
208
+ if (!waiting) process.exit(0);
209
+ for (const e of bees.values()) e.child?.on('exit', () => { if (--waiting <= 0) process.exit(0); });
210
+ });
211
+ }
212
+
213
+ // ---- sibling workers (api + treasury) — spawned only when configured --------
214
+ const workers = [];
215
+ const spawnWorker = (name, script, requiredEnv) => {
216
+ if (requiredEnv.some((k) => !process.env[k])) { log(`${name} not started (missing ${requiredEnv.filter((k) => !process.env[k]).join(',')})`); return; }
217
+ const start = () => {
218
+ if (shuttingDown) return;
219
+ const child = spawn(process.execPath, [join(PACK_DIR, 'server', script)], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
220
+ workers.push(child);
221
+ const prefix = (line) => line.trim() && console.log(line.startsWith('[') ? line : `[${name}] ${line}`);
222
+ child.stdout.on('data', (b) => b.toString().split('\n').forEach(prefix));
223
+ child.stderr.on('data', (b) => b.toString().split('\n').forEach(prefix));
224
+ child.on('exit', (code) => {
225
+ if (shuttingDown) return;
226
+ log(`${name} exited (${code}) — restarting in 5s`);
227
+ setTimeout(start, 5000).unref?.();
228
+ });
229
+ };
230
+ start();
231
+ };
232
+
233
+ log(`bee-host starting: data=${DATA_DIR}, daemon=${DAEMON}`);
234
+ rescan();
235
+ setInterval(rescan, 60_000).unref?.(); // pick up newly provisioned bees
236
+ spawnWorker('api', 'api.mjs', ['HIVE_KEK', 'HIVE_STEWARD_KEY']);
237
+ spawnWorker('treasury', 'treasury.mjs', ['TREASURY_PRIVATE_KEY']);