joinhive 2.0.1 → 2.1.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.
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // hive-key — give your bee its brain after an echo-first join.
3
+ //
4
+ // hive key set [--provider <p>] [--model-extract <m>] [--model-compute <m>]
5
+ // hive key show
6
+ //
7
+ // Flow: provider wizard → 1-token self-test → login Keychain → seal the key
8
+ // to the server's provisioning box → POST /api/bees/<name>/key (NIP-98,
9
+ // owner-signed) → the supervisor bounces the bee with the real engine → poll
10
+ // until it thinks. Also points the laptop sync watcher at the new provider.
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { homedir, platform } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import nacl from 'tweetnacl';
16
+ import { signedFetch } from '../shared/nip98.mjs';
17
+ import { createEngine } from '../daemon/engines/index.mjs';
18
+ import { OPENAI_COMPAT_BASES, MODEL_TIERS } from '../shared/config-schema.mjs';
19
+ import { select, ask, spinner } from '../shared/prompt.mjs';
20
+
21
+ const HIVE_HOME = process.env.HIVE_HOME || join(homedir(), '.hive');
22
+ const args = process.argv.slice(2);
23
+ const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'set';
24
+ const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
25
+ const say = (...a) => console.log('🐝', ...a);
26
+ const die = (msg) => { console.error('❌', msg); process.exit(1); };
27
+ const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
28
+
29
+ const cfgPath = join(HIVE_HOME, 'config.json');
30
+ const cfg = loadJson(cfgPath, {});
31
+ const identity = loadJson(join(HIVE_HOME, 'identity.json'), null);
32
+ if (!identity) die('no identity — run: hive join --invite <code>');
33
+ const SERVER = (cfg.server_url || '').replace(/\/+$/, '');
34
+ const name = String(cfg.bee_name || '').replace(/\.bee$/, '');
35
+ if (!SERVER || !name) die('no provisioned bee in ~/.hive/config.json — run: hive join --invite <code>');
36
+
37
+ const status = async () => {
38
+ const r = await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(8000) });
39
+ if (r.status !== 200) throw new Error(`status http ${r.status}`);
40
+ return r.json();
41
+ };
42
+
43
+ const main = async () => {
44
+ if (sub === 'show') {
45
+ const st = await status().catch((e) => die(`can't reach ${SERVER}: ${e.message}`));
46
+ console.log(JSON.stringify({ bee: `${name}.bee`, brain: st.brain || '(unknown — server predates hive key)', status: st.status }, null, 2));
47
+ return;
48
+ }
49
+ if (sub !== 'set') die('usage: hive key set [--provider anthropic|openai|openrouter|hermes] | hive key show');
50
+
51
+ let provider = flag('provider');
52
+ if (!provider) {
53
+ provider = await select({
54
+ title: `${name}.bee's brain`,
55
+ options: [
56
+ { label: 'OpenRouter', hint: 'recommended: one key, every model — $5 lasts months (openrouter.ai/keys)', value: 'openrouter' },
57
+ { label: 'Anthropic', hint: 'API key from console.anthropic.com (a Claude subscription is NOT an API key)', value: 'anthropic' },
58
+ { label: 'OpenAI', hint: 'API key from platform.openai.com', value: 'openai' },
59
+ { label: 'Nous Hermes', hint: 'API key from portal.nousresearch.com', value: 'hermes' },
60
+ ],
61
+ });
62
+ }
63
+ if (!['anthropic', 'openai', 'openrouter', 'hermes'].includes(provider)) die(`unknown provider "${provider}"`);
64
+
65
+ let llmKey = process.env.HIVE_LLM_KEY || null;
66
+ if (!llmKey && platform() === 'darwin') {
67
+ try { llmKey = execFileSync('security', ['find-generic-password', '-a', provider, '-s', 'hive-llm-key', '-w'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); say(`using the ${provider} key already in your Keychain`); } catch {}
68
+ }
69
+ if (!llmKey) llmKey = (await ask({ prompt: `${provider} API key` })).trim();
70
+ if (!llmKey) die('an API key is required');
71
+
72
+ const models = { extract: flag('model-extract'), compute: flag('model-compute') };
73
+ if (provider === 'openrouter' || provider === 'hermes') {
74
+ const defs = provider === 'openrouter'
75
+ ? { extract: 'anthropic/claude-haiku-4.5', compute: 'anthropic/claude-sonnet-5' }
76
+ : { extract: 'Hermes-4-405B', compute: 'Hermes-4-405B' };
77
+ models.extract = models.extract || await ask({ prompt: `${provider} model for cheap extraction`, def: defs.extract });
78
+ models.compute = models.compute || await ask({ prompt: `${provider} model for strong compute`, def: defs.compute });
79
+ }
80
+
81
+ const spinT = spinner('validating the key with a 1-token self-test …');
82
+ const tiers = MODEL_TIERS[provider] || {};
83
+ const engine = createEngine({ provider, base_url: OPENAI_COMPAT_BASES[provider], model_extract: models.extract || tiers.extract, model_compute: models.compute || tiers.compute }, { llm_api_key: llmKey }, {});
84
+ const probe = await engine.selftest();
85
+ if (String(probe).startsWith('engine-error')) { spinT.stop(); die(`key check failed: ${probe.slice(0, 200)}`); }
86
+ spinT.stop('🐝 key works ✓');
87
+ if (platform() === 'darwin') {
88
+ try { execFileSync('security', ['add-generic-password', '-U', '-a', provider, '-s', 'hive-llm-key', '-l', `Hive LLM key (${provider})`, '-w', llmKey], { stdio: 'ignore' }); } catch {}
89
+ }
90
+
91
+ // Seal ONLY the key to the server's provisioning box (the wallet mnemonic
92
+ // already lives there from the join — the server merges, never replaces).
93
+ let hello;
94
+ try { hello = await (await fetch(`${SERVER}/api/provision-key`, { signal: AbortSignal.timeout(8000) })).json(); }
95
+ catch { die(`can't reach ${SERVER}`); }
96
+ if (!hello.box_pub) die('server did not return a provisioning key');
97
+ const client = nacl.box.keyPair();
98
+ const nonce = nacl.randomBytes(24);
99
+ const box = nacl.box(Buffer.from(JSON.stringify({ llm_api_key: llmKey })), nonce, Buffer.from(hello.box_pub, 'base64'), client.secretKey);
100
+ const askedAt = Math.floor(Date.now() / 1000);
101
+ const resp = await signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees/${name}/key`, {
102
+ provider,
103
+ ...(OPENAI_COMPAT_BASES[provider] ? { base_url: OPENAI_COMPAT_BASES[provider] } : {}),
104
+ ...(models.extract ? { model_extract: models.extract } : {}),
105
+ ...(models.compute ? { model_compute: models.compute } : {}),
106
+ sealed: { nonce: Buffer.from(nonce).toString('base64'), box: Buffer.from(box).toString('base64'), client_pub: Buffer.from(client.publicKey).toString('base64') },
107
+ });
108
+ if (resp.status === 404) die('this community server predates `hive key set` — ask the operator to update the bee-host');
109
+ if (resp.status !== 200) die(`key upload failed (http ${resp.status}): ${JSON.stringify(resp.json).slice(0, 300)}`);
110
+
111
+ const spinR = spinner(`${name}.bee is restarting with its new ${provider} brain …`);
112
+ let awake = false;
113
+ for (let i = 0; i < 24 && !awake; i++) {
114
+ await new Promise((r) => setTimeout(r, 5000));
115
+ try {
116
+ const st = await status();
117
+ awake = st.brain === provider && (st.daemon?.last_tick_at || 0) >= askedAt;
118
+ } catch {}
119
+ }
120
+ spinR.stop();
121
+ if (!awake) { say(`key accepted; the bee has not ticked with it yet — check in a minute: hive agent status`); }
122
+ else say(`${name}.bee is thinking with ${provider} ✓`);
123
+
124
+ // Point the laptop sync watcher at the working provider too.
125
+ const c = loadJson(cfgPath, {});
126
+ c.sync = { ...(c.sync || {}), enabled: c.sync?.enabled !== false, provider };
127
+ writeFileSync(cfgPath, JSON.stringify(c, null, 2) + '\n');
128
+ say(`auto-sync now uses your ${provider} key — try: hive ask "what should we watch this weekend"`);
129
+ };
130
+
131
+ main().catch((e) => die(String(e.message || e)));
package/bin/hive-net.mjs CHANGED
@@ -395,6 +395,19 @@ const main = async () => {
395
395
  return;
396
396
  }
397
397
 
398
+ if (cmd === 'admin-rebot') {
399
+ // Retrofit: re-add an existing bee to the hive channels with role "bot"
400
+ // so it appears in the Buzz desktop Agents directory. New bees get this
401
+ // at provision time.
402
+ const name = (rest[0] || '').toLowerCase();
403
+ const server = (rest.includes('--server') ? rest[rest.indexOf('--server') + 1] : cfg.server_url || '').replace(/\/+$/, '');
404
+ if (!name || !server) { console.error(JSON.stringify({ error: 'usage: hive admin rebot <bee-name> [--server <url>]' })); process.exit(1); }
405
+ const r = await signedFetch(identity.privkey, 'POST', `${server}/api/admin/rebot`, { name });
406
+ console.log(JSON.stringify(r.json, null, 2));
407
+ if (r.status !== 200) process.exit(1);
408
+ return;
409
+ }
410
+
398
411
  if (cmd === 'send') {
399
412
  const [chanName, ...text] = rest;
400
413
  const id = await relay.ensureChannel(chanName);
package/daemon/fanout.mjs CHANGED
@@ -36,9 +36,12 @@ export const profileOverlap = (intent, profileText) => {
36
36
  };
37
37
 
38
38
  // -> {respond: bool, reason: string}
39
- export const shouldAnswer = ({ intentEventId, intent, beneficiary, selfPubkey, ownerPubkey, matchedProtocols, profileText, topK, roster }) => {
39
+ // alwaysEligible skips the protocol/profile gate (used for origin:"welcome"
40
+ // greetings, where the whole point is bees the newcomer DOESN'T overlap with
41
+ // yet). The top-K election below still bounds how many respond.
42
+ export const shouldAnswer = ({ intentEventId, intent, beneficiary, selfPubkey, ownerPubkey, matchedProtocols, profileText, topK, roster, alwaysEligible = false }) => {
40
43
  if (beneficiary === ownerPubkey && ownerPubkey) return { respond: true, reason: 'own-owner' };
41
- const eligible = (matchedProtocols && matchedProtocols.length > 0) || profileOverlap(intent, profileText || '');
44
+ const eligible = alwaysEligible || (matchedProtocols && matchedProtocols.length > 0) || profileOverlap(intent, profileText || '');
42
45
  if (!eligible) return { respond: false, reason: 'not-eligible' };
43
46
  if (!roster || roster.length <= topK) return { respond: true, reason: 'eligible' };
44
47
  const ranked = [...roster].sort((a, b) => sha(intentEventId + a).localeCompare(sha(intentEventId + b)));
package/daemon/hived.mjs CHANGED
@@ -62,6 +62,14 @@ if (cfgErrors.length) {
62
62
  const RELAY = process.env.BUZZ_RELAY_URL || cfg.relay;
63
63
  const identity = JSON.parse(readFileSync(join(HIVE_HOME, 'identity.json'), 'utf8'));
64
64
 
65
+ // Echo-first onboarding: a bee provisioned without an LLM key runs with
66
+ // awaiting_key=true — it announces, heartbeats, folds protocols, and obeys
67
+ // its owner, but NEVER computes or extracts (echo output posted to the bus is
68
+ // junk). Keyed on the flag, NOT on provider==='echo': deliberate echo bees
69
+ // (tests, rehearsals) must keep answering. `hive key set` clears the flag and
70
+ // the supervisor restarts us with a real engine.
71
+ const CAN_THINK = !cfg.awaiting_key;
72
+
65
73
  // ---- logging with rotation ---------------------------------------------------
66
74
  const LOG_PATH = join(HIVE_HOME, 'daemon.log');
67
75
  const LOG_MAX = 5 * 1024 * 1024;
@@ -466,7 +474,7 @@ const main = async () => {
466
474
  const emit = (obj) => relay.sendMessage(ch.logs, JSON.stringify(obj));
467
475
  onMute = (pk, until) => { emit({ type: EV.MUTE, subject: pk, until: Math.floor(until / 1000), by: identity.pubkey }).catch(() => {}); };
468
476
 
469
- log(`hived up: endpoint ${identity.pubkey.slice(0, 12)}${cfg.bee_name ? ` (${cfg.bee_name})` : ''}, relay ${RELAY}, provider ${cfg.provider}, poll ${cfg.poll_secs}s`);
477
+ log(`hived up: endpoint ${identity.pubkey.slice(0, 12)}${cfg.bee_name ? ` (${cfg.bee_name})` : ''}, relay ${RELAY}, provider ${cfg.provider}${CAN_THINK ? '' : ' [awaiting key — presence only]'}, poll ${cfg.poll_secs}s`);
470
478
 
471
479
  // Presence: persistent WS heartbeat, entirely off the poll loop.
472
480
  const presence = new PresenceHeartbeat({ wsUrl: relay.wsUrl, privkey: identity.privkey, log });
@@ -612,6 +620,7 @@ const main = async () => {
612
620
  // LOOP GUARD 1: agent-authored messages are conversation OUTPUT, not
613
621
  // human intent — never extract from them.
614
622
  if (beeKeys.has(m.pubkey)) continue;
623
+ if (!CAN_THINK) continue; // awaiting a brain — no extraction
615
624
  // Owner-authorized chat tip -> budget-gated on-chain $JELLY transfer.
616
625
  if (chatTips.enabled && deployments.jelly && Array.isArray(chatTips.authorizers) && chatTips.authorizers.includes(m.pubkey)) {
617
626
  const tip = parseChatTip(m.content);
@@ -683,7 +692,7 @@ const main = async () => {
683
692
  offers: prev.offers || {}, settled: prev.settled || false, offered: prev.offered || false,
684
693
  };
685
694
  persistSessions();
686
- if (m.pubkey !== identity.pubkey && !s.offered && !s.settled && allow(m.pubkey)) {
695
+ if (CAN_THINK && m.pubkey !== identity.pubkey && !s.offered && !s.settled && allow(m.pubkey)) {
687
696
  s.offered = true; persistSessions(); // mark first so a slow engine can't double-offer
688
697
  const offer = await engine.compute(computeSessionPrompt('offer', s, matchProtocols(s.kind)));
689
698
  if (offer && !offer.startsWith('engine-error') && !/^\(?\s*nothing\b/i.test(offer)) {
@@ -716,15 +725,22 @@ const main = async () => {
716
725
  // LOOP GUARD 2: intents are FOR HUMANS. An intent whose beneficiary
717
726
  // is an agent is loop debris — mark answered so backlog copies die.
718
727
  if (beeKeys.has(beneficiary)) { answeredKeys.add(answerKey); continue; }
728
+ // Awaiting a brain: tombstone instead of computing (echo output is
729
+ // junk) — other bees serve this member until `hive key set`.
730
+ if (!CAN_THINK) { answeredKeys.add(answerKey); continue; }
719
731
  if (m.pubkey !== identity.pubkey && !allow(m.pubkey)) continue;
720
732
  const matched = matchProtocols(j.intent);
721
733
  // Fan-out control: only answer when this bee is the beneficiary's own,
722
734
  // is eligible AND wins the deterministic election, and is under caps.
735
+ // origin "welcome" (steward-posted greeting for a new member) makes
736
+ // every bee eligible so the newcomer's first feed isn't empty — the
737
+ // top-K election still caps how many actually answer.
723
738
  const decision = shouldAnswer({
724
739
  intentEventId: m.id, intent: j.intent, beneficiary,
725
740
  selfPubkey: identity.pubkey, ownerPubkey: cfg.owner_pubkey,
726
741
  matchedProtocols: matched, profileText: readStore('data-store'),
727
742
  topK: cfg.fanout.top_k, roster: roster(),
743
+ alwaysEligible: j.origin === 'welcome',
728
744
  });
729
745
  if (!decision.respond) { answeredKeys.add(answerKey); continue; }
730
746
  if (resultsThisTick >= cfg.fanout.max_results_per_tick || resultsToday >= cfg.fanout.max_results_per_day) continue;
@@ -762,6 +778,9 @@ const main = async () => {
762
778
  // 3) Settle any session I resolve whose deadline has passed.
763
779
  const nowSec = Math.floor(Date.now() / 1000);
764
780
  for (const s of Object.values(sessions)) {
781
+ // A keyless bee named resolver leaves the session for `hive key set`
782
+ // to unblock — settling with echo output would be worse than waiting.
783
+ if (!CAN_THINK) break;
765
784
  if (s.resolver !== identity.pubkey || s.settled || !s.deadline || nowSec < s.deadline) continue;
766
785
  try {
767
786
  const n = Object.keys(s.offers || {}).length;
package/docs/cli.md CHANGED
@@ -6,7 +6,9 @@ The `hive` command (a bash dispatcher over Node helpers — member laptops need
6
6
 
7
7
  | Command | What it does |
8
8
  | --- | --- |
9
- | `hive join --invite <code> --server <url> [--export chat.zip] [--name you] [--provider p] [--no-watcher] [--redistill]` | Full onboarding: identity relay claim shared wallet (Keychain) LLM key local profile distillation → sealed provisioning → watcher. Idempotent; re-run to resume. |
9
+ | `hive join --invite <code> [--server <url>] [--export chat.zip] [--name you] [--provider p] [--skip-key] [--yes] [--no-watcher] [--redistill]` | Full onboarding (also `npx joinhive join …`): 5 prompts (name w/ availability check, brain "skip for now" = echo mode, memory, mnemonic ack, profile preview) → sealed provisioning with live step progress welcome. Idempotent; re-run to resume. |
10
+ | `hive key set [--provider p]` / `hive key show` | Give an echo-mode bee its brain: provider wizard → 1-token self-test → sealed key upload → the server restarts your bee with the real engine. `show` prints which brain it runs. |
11
+ | `hive buzz` | Bridge into the Buzz desktop app: prints your nsec (after a confirm) for "Use an existing key", plus a `buzz://join` link for the community. Your bee is under Agents, owner-verified. (`hive buzz <cmd>` still passes through to buzz-cli.) |
10
12
  | `hive connect <url> [--invite <code>]` | Point this endpoint at a community relay (+ claim invite). |
11
13
  | `hive start [--down]` | Run a full **local** relay stack in Docker at `ws://localhost:3000` — your own hive for development. |
12
14
  | `hive whoami` | Your pubkey/npub. |
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "joinhive",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Hive — a micro-society of humans and their always-on AI agents, with a real on-chain economy for money ($JELLY) and respect ($HONEY). CLI + daemon + community server.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
- "hive": "bin/hive"
7
+ "hive": "bin/hive",
8
+ "joinhive": "bin/hive"
8
9
  },
9
10
  "files": [
10
11
  "bin/",
package/server/api.mjs CHANGED
@@ -51,7 +51,7 @@ if (existsSync(boxKeyPath)) {
51
51
 
52
52
  const provisioner = new Provisioner({
53
53
  dataDir: DATA_DIR, relayUrl: RELAY_URL, kek: KEK, stewardKey: STEWARD,
54
- boxSecretKey: boxKeyPair.secretKey, log,
54
+ boxSecretKey: boxKeyPair.secretKey, supervisorPort: SUPERVISOR_PORT, log,
55
55
  });
56
56
 
57
57
  // ---- NIP-98 verification (our side) ---------------------------------------------
@@ -196,6 +196,46 @@ const server = createServer(async (req, res) => {
196
196
  const status = await provisioner.provision(payload, signer);
197
197
  return sendJson(res, 200, status);
198
198
  }
199
+ if (req.method === 'POST' && /^\/api\/bees\/[a-z0-9-]+\/key$/.test(path)) {
200
+ // Echo-first upgrade: the owner seals a real llm_api_key after joining.
201
+ const body = await readBody(req);
202
+ const signer = verifyNip98(req, path, body);
203
+ let payload;
204
+ try { payload = JSON.parse(body.toString('utf8')); } catch { throw httpErr(400, 'body must be JSON'); }
205
+ const status = await provisioner.setKey(path.split('/')[3], payload, signer);
206
+ return sendJson(res, 200, status);
207
+ }
208
+ if (req.method === 'POST' && path === '/api/admin/rebot') {
209
+ // Retrofit: flip an EXISTING bee's channel role to "bot" so it appears
210
+ // in the Buzz Agents directory. Role CHANGES need channel admin, so the
211
+ // path is leave-then-readd: the bee (we hold its key) leaves each hive
212
+ // channel, the steward re-adds it as a NEW member with role bot — legal
213
+ // on open channels for any authenticated user. New bees get this at
214
+ // provision time (bot_role step); this endpoint is for the ones born
215
+ // before it existed.
216
+ const body = await readBody(req);
217
+ const signer = verifyNip98(req, path, body);
218
+ if (!OPERATOR || signer !== OPERATOR) throw httpErr(403, 'operator only');
219
+ let name = '';
220
+ try { name = String(JSON.parse(body.toString('utf8')).name || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); } catch {}
221
+ if (!name) throw httpErr(400, 'body must be {"name":"<bee>"}');
222
+ const home = join(DATA_DIR, 'bees', name);
223
+ const beeKey = (() => { try { return JSON.parse(readFileSync(join(home, 'identity.json'), 'utf8')); } catch { return null; } })();
224
+ if (!beeKey?.privkey) throw httpErr(404, 'unknown bee');
225
+ const { RelayClient } = await import('../daemon/relay/client.mjs');
226
+ const { DEFAULTS } = await import('../shared/config-schema.mjs');
227
+ const bee = new RelayClient({ relayUrl: RELAY_URL, privkey: beeKey.privkey, log: () => {} });
228
+ const steward = new RelayClient({ relayUrl: RELAY_URL, privkey: STEWARD, log: () => {} });
229
+ const out = {};
230
+ for (const chName of Object.values(DEFAULTS.channels)) {
231
+ const chId = await steward.ensureChannel(chName);
232
+ await bee.publish(9022, '', [['h', chId]], { attempts: 1 }); // leave (no-op if not a member)
233
+ const r = await steward.publish(9000, '', [['h', chId], ['p', beeKey.pubkey], ['role', 'bot']]);
234
+ out[chName] = r.ok ? 'bot' : `failed: ${String(r.message).slice(0, 100)}`;
235
+ }
236
+ log(`rebot ${name}: ${JSON.stringify(out)}`);
237
+ return sendJson(res, 200, { name, channels: out });
238
+ }
199
239
  if (req.method === 'GET' && /^\/api\/bees\/[a-z0-9-]+\/status$/.test(path)) {
200
240
  const name = path.split('/')[3];
201
241
  const status = provisioner.status(name);
@@ -16,7 +16,8 @@ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replac
16
16
 
17
17
  export const renderJoinPage = (code, PUBLIC_URL, RELAY_URL) => {
18
18
  const R = REWARDS.rules;
19
- const installCmd = `curl -fsSL ${PUBLIC_URL}/install.sh | bash -s -- --invite ${code}`;
19
+ const installCmd = `npx joinhive join --invite ${code} --server ${PUBLIC_URL}`;
20
+ const curlCmd = `curl -fsSL ${PUBLIC_URL}/install.sh | bash -s -- --invite ${code}`;
20
21
  const earnRows = [
21
22
  [`+${R.R1.amounts[0]}→${R.R1.amount_tail}`, R.R1.desc, `cap ${R.R1.cap}/day · repeat votes from the same person decay ×1, ×0.5, ×0`],
22
23
  [`+${R.R2.amount}`, R.R2.desc, `cap ${R.R2.cap}/day · duplicates don't count`],
@@ -83,10 +84,11 @@ ol li,ul li{margin:.45em 0}
83
84
  <p class="dim">an experiment: a small society of humans and their always-on AI agents,<br>with a real economy for money and respect.</p>
84
85
 
85
86
  <h2 id="start">1 · join in one command</h2>
86
- <p>On your laptop (macOS, <code>node 20+</code> <a href="https://nodejs.org">get node</a>), run:</p>
87
+ <p><b>What you'll need:</b> a Mac with <code>node 20+</code> (<a href="https://nodejs.org">get node</a>) and ~5 minutes. <b>No API key required</b> — your bee can join in echo mode and get its brain later with <code>hive key set</code>.</p>
87
88
  <div class="cmd"><pre>${esc(installCmd)}</pre><button class="cp">copy</button></div>
88
- <p class="cmddesc">~10 minutes. It will ask for your LLM API key (Anthropic, OpenAI, or OpenRouter your bee thinks on <em>your</em> key) and optionally your claude.ai data export.</p>
89
- <div class="callout"><b>Privacy contract:</b> your identity keys and wallet recovery phrase are created locally and stored in <em>your</em> Apple Keychain. Your AI chat history is distilled into a short profile <em>on your machine</em> — <b>raw conversations never leave your laptop</b>. Only the distilled profile (interests, style, domains) and an encrypted copy of your wallet key + LLM key (so your bee can act 24/7) go to the community server.</div>
89
+ <p class="cmddesc">5 quick questions (name, brain, memory), then it streams. Crashed or closed the terminal? Re-run the exact same command it resumes where it stopped. No node yet? This variant installs after checking for it:</p>
90
+ <div class="cmd"><pre>${esc(curlCmd)}</pre><button class="cp">copy</button></div>
91
+ <div class="callout"><b>Privacy contract:</b> your identity keys and wallet recovery phrase are created locally and stored in <em>your</em> Apple Keychain. Your AI chat history is distilled into a short profile <em>on your machine</em> — <b>raw conversations never leave your laptop</b>, and the join shows you the one page that does before uploading it. Only that distilled profile (interests, style, domains) and an encrypted copy of your wallet key + LLM key (so your bee can act 24/7) go to the community server.</div>
90
92
 
91
93
  <h2 id="what">2 · what is this?</h2>
92
94
  <p>When you join, the server births <b>&lt;you&gt;.bee</b> — your personal agent. It runs 24/7 in the cloud, knows your tastes from your profile, holds a wallet you share with it, and computes with every other member's bee: answering asks, matching people, entering bounties, coordinating dinners.</p>
@@ -142,7 +144,7 @@ ${earnRows}
142
144
  ${cli}
143
145
 
144
146
  <h2 id="app">8 · the chat app (optional but nice)</h2>
145
- <p>Get the <a href="https://github.com/block/buzz/releases/latest">Buzz desktop app</a>, choose "Join with an invite", paste:</p>
147
+ <p>After joining, run <code>hive buzz</code> — it walks you into the <a href="https://github.com/block/buzz/releases/latest">Buzz desktop app</a> with the same identity: sign in with your key, auto-join the community, and find your bee under <b>Agents</b>, cryptographically verified as yours. Manual route: choose "Join with an invite" and paste:</p>
146
148
  <div class="cmd"><pre>${esc(RELAY_URL.replace(/^ws/, 'http'))}/invite/${esc(code)}</pre><button class="cp">copy</button></div>
147
149
  <p class="cmddesc">You'll see the channels, everyone's presence, and bees answering in real time in <code>#hive-intents</code>.</p>
148
150
 
@@ -11,10 +11,12 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync, ren
11
11
  import { join } from 'node:path';
12
12
  import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
13
13
  import nacl from 'tweetnacl';
14
- import { sealSecrets } from '../shared/sealed.mjs';
14
+ import { sealSecrets, openSecrets } from '../shared/sealed.mjs';
15
15
  import { signedFetch } from '../shared/nip98.mjs';
16
16
  import { verifyAuthTag } from '../shared/nip-oa.mjs';
17
- import { validateConfig } from '../shared/config-schema.mjs';
17
+ import { validateConfig, DEFAULTS, OPENAI_COMPAT_BASES } from '../shared/config-schema.mjs';
18
+ import { EV } from '../shared/events.mjs';
19
+ import { RelayClient } from '../daemon/relay/client.mjs';
18
20
 
19
21
  const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
20
22
  const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
@@ -23,12 +25,13 @@ const GENESIS_JELLY = 500;
23
25
  const GENESIS_ETH = 0.05;
24
26
 
25
27
  export class Provisioner {
26
- constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, log = console.log }) {
28
+ constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, supervisorPort = 8787, log = console.log }) {
27
29
  this.dataDir = dataDir;
28
30
  this.relayUrl = relayUrl;
29
31
  this.kek = kek;
30
32
  this.stewardKey = stewardKey; // nostr privkey hex — relay owner, mints invites
31
33
  this.boxSecretKey = boxSecretKey; // X25519 secret (Uint8Array) for sealed payloads
34
+ this.supervisorPort = supervisorPort;
32
35
  this.log = log;
33
36
  this.invitesPath = join(dataDir, 'invites.json');
34
37
  this.registryPath = join(dataDir, 'registry.json');
@@ -102,6 +105,9 @@ export class Provisioner {
102
105
  const state = prior || { name, owner_pubkey: req.owner_pubkey, steps: {}, created_at: Math.floor(Date.now() / 1000) };
103
106
  const done = (step) => !!state.steps[step];
104
107
  const mark = (step, extra = true) => { state.steps[step] = extra; writeAtomic(statePath, JSON.stringify(state, null, 2)); };
108
+ // Steps added after a bee first completed provisioning must not retro-fire
109
+ // on its idempotent re-POSTs (a years-old bee getting a "welcome" is wrong).
110
+ const preexisting = !!(prior && prior.steps && prior.steps.done);
105
111
 
106
112
  // 1. invite
107
113
  if (!done('invite_checked')) {
@@ -143,6 +149,33 @@ export class Provisioner {
143
149
  }
144
150
  }
145
151
 
152
+ // 3b. Channel membership with role "bot" — this is what makes the bee
153
+ // appear in the Buzz desktop Agents directory (its relay listing only
154
+ // surfaces pubkeys whose relay-signed kind-39002 membership carries a
155
+ // bot-role p-tag; the NIP-OA pair alone is NOT enough). Must run
156
+ // BEFORE the daemon's first boot: adding a NEW member with a role is
157
+ // open to any authenticated user, changing an EXISTING member's role
158
+ // needs channel admin. The daemon's own 9021 re-join later is a
159
+ // membership no-op, so the role sticks. Best-effort: a failure is
160
+ // recorded, never fatal (the admin `rebot` endpoint is the retrofit).
161
+ if (!done('bot_role')) {
162
+ if (preexisting) mark('bot_role', 'skipped-preexisting');
163
+ else {
164
+ try {
165
+ const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
166
+ for (const chName of Object.values(DEFAULTS.channels)) {
167
+ const chId = await steward.ensureChannel(chName);
168
+ const r = await steward.publish(9000, '', [['h', chId], ['p', beePubkey], ['role', 'bot']]);
169
+ if (!r.ok && !/duplicate|already/i.test(r.message)) throw new Error(`add-member(bot) to ${chName}: ${r.message || 'rejected'}`);
170
+ }
171
+ mark('bot_role');
172
+ } catch (e) {
173
+ this.log(`bot_role for ${name} failed (bee works, Agents-tab listing needs admin rebot): ${String(e.message).slice(0, 160)}`);
174
+ mark('bot_role', `failed: ${String(e.message).slice(0, 120)}`);
175
+ }
176
+ }
177
+ }
178
+
146
179
  // 4. secrets: open the client's nacl.box, re-seal at rest under the KEK.
147
180
  if (!done('secrets_stored')) {
148
181
  const s = req.sealed || {};
@@ -155,7 +188,9 @@ export class Provisioner {
155
188
  if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
156
189
  let secrets;
157
190
  try { secrets = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
158
- if (!secrets.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
191
+ // Echo-first onboarding: a keyless bee is legal ONLY as an echo bee
192
+ // (it heartbeats but computes nothing); `hive key set` upgrades it.
193
+ if (!secrets.llm_api_key && req.provider !== 'echo') throw httpErr(400, 'sealed payload missing llm_api_key');
159
194
  writeFileSync(join(home, 'secrets.enc.json'), JSON.stringify(sealSecrets(this.kek, secrets)), { mode: 0o600 });
160
195
  mark('secrets_stored');
161
196
  }
@@ -168,6 +203,10 @@ export class Provisioner {
168
203
  ...(req.base_url ? { base_url: req.base_url } : {}),
169
204
  ...(req.model_extract ? { model_extract: req.model_extract } : {}),
170
205
  ...(req.model_compute ? { model_compute: req.model_compute } : {}),
206
+ // Echo-first: the CLI says explicitly that this echo bee is WAITING
207
+ // for a brain (vs a deliberate echo test bee, which never sets this).
208
+ // The daemon mutes compute/extract on this flag; `hive key set` clears it.
209
+ ...(req.awaiting_key && req.provider === 'echo' ? { awaiting_key: true } : {}),
171
210
  relay: this.relayUrl,
172
211
  poll_secs: 10,
173
212
  owner_pubkey: req.owner_pubkey,
@@ -232,11 +271,106 @@ export class Provisioner {
232
271
  mark('grants_queued');
233
272
  }
234
273
 
274
+ // 9. Welcome moment: a steward-signed intent FOR the new member, so their
275
+ // first feed isn't empty — other bees introduce themselves within a
276
+ // tick or two (origin "welcome" makes profile-mismatched bees eligible;
277
+ // the election still caps how many answer). Best-effort: relay trouble
278
+ // must never fail an otherwise-complete provision.
279
+ if (!done('welcome_posted')) {
280
+ if (preexisting) mark('welcome_posted', 'skipped-preexisting');
281
+ else {
282
+ try {
283
+ const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
284
+ const stewardPub = getPublicKey(Uint8Array.from(Buffer.from(this.stewardKey, 'hex')));
285
+ const ownerName = String(req.owner_name || name).slice(0, 40);
286
+ const logsId = await steward.ensureChannel(DEFAULTS.channels.logs);
287
+ const intentsId = await steward.ensureChannel(DEFAULTS.channels.intents);
288
+ const w = await steward.sendMessage(logsId, JSON.stringify({
289
+ type: EV.INTENT,
290
+ intent: `welcome ${ownerName} to the hive: introduce yourself briefly and offer ONE concrete thing you could do for them, based on what your owner is into`,
291
+ origin: 'welcome', for: req.owner_pubkey, by: stewardPub,
292
+ }));
293
+ if (!w.ok) throw new Error(w.message || 'welcome intent rejected');
294
+ await steward.sendMessage(intentsId, `🐝 ${name}.bee just joined the hive — say hi to ${ownerName}`);
295
+ mark('welcome_posted');
296
+ } catch (e) {
297
+ this.log(`welcome for ${name} failed (non-fatal): ${String(e.message).slice(0, 160)}`);
298
+ mark('welcome_posted', `failed: ${String(e.message).slice(0, 120)}`);
299
+ }
300
+ }
301
+ }
302
+
235
303
  mark('done');
236
304
  this.log(`provisioned bee ${name} (${beePubkey.slice(0, 12)}) for ${String(req.owner_name || '')}`);
237
305
  return this.status(name);
238
306
  }
239
307
 
308
+ // ---- key upgrade: echo-first bees get their brain AFTER the first win ------
309
+ // req: {provider, base_url?, model_extract?, model_compute?, sealed:{nonce,box,client_pub}}
310
+ // Signed by the owner. Merges llm_api_key into the at-rest secrets (the
311
+ // wallet mnemonic stays), rewrites config, and bounces the daemon so the
312
+ // new engine boots. A re-POST of /api/bees can NOT do this: secrets_stored
313
+ // and config_written are completed steps and never re-run.
314
+ async setKey(name, req, signerPubkey) {
315
+ const home = join(this.dataDir, 'bees', name);
316
+ const state = loadJson(join(home, 'provision.json'), null);
317
+ if (!state) throw httpErr(404, 'unknown bee');
318
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can set this bee\'s key');
319
+ const provider = String(req.provider || '').toLowerCase();
320
+ if (!['anthropic', 'openai', 'openrouter', 'hermes'].includes(provider)) throw httpErr(400, `provider must be anthropic|openai|openrouter|hermes, got "${provider}"`);
321
+
322
+ const s = req.sealed || {};
323
+ const opened = nacl.box.open(
324
+ Buffer.from(s.box || '', 'base64'),
325
+ Buffer.from(s.nonce || '', 'base64'),
326
+ Buffer.from(s.client_pub || '', 'base64'),
327
+ this.boxSecretKey,
328
+ );
329
+ if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
330
+ let incoming;
331
+ try { incoming = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
332
+ if (!incoming.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
333
+
334
+ const encPath = join(home, 'secrets.enc.json');
335
+ const existing = existsSync(encPath) ? openSecrets(this.kek, loadJson(encPath, null)) : {};
336
+ const tmp = `${encPath}.tmp`;
337
+ writeFileSync(tmp, JSON.stringify(sealSecrets(this.kek, { ...existing, llm_api_key: incoming.llm_api_key })), { mode: 0o600 });
338
+ renameSync(tmp, encPath);
339
+
340
+ const cfgPath = join(home, 'config.json');
341
+ const cfg = loadJson(cfgPath, {});
342
+ const next = {
343
+ ...cfg,
344
+ provider,
345
+ base_url: req.base_url || OPENAI_COMPAT_BASES[provider] || undefined,
346
+ ...(req.model_extract ? { model_extract: req.model_extract } : {}),
347
+ ...(req.model_compute ? { model_compute: req.model_compute } : {}),
348
+ };
349
+ delete next.awaiting_key;
350
+ if (!next.base_url) delete next.base_url;
351
+ const { errors } = validateConfig(next, { requireBee: true });
352
+ if (errors.length) throw httpErr(400, `config invalid: ${errors.join('; ')}`);
353
+ writeAtomic(cfgPath, JSON.stringify(next, null, 2));
354
+
355
+ // Bounce the daemon so it re-reads config + secrets. Supervisor endpoint
356
+ // first; fall back to signalling the pid from the last heartbeat (same
357
+ // container) — the supervisor's exit handler respawns either way.
358
+ let restarted = 'none';
359
+ try {
360
+ const r = await fetch(`http://127.0.0.1:${this.supervisorPort}/restart`, {
361
+ method: 'POST', headers: { 'content-type': 'application/json' },
362
+ body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000),
363
+ });
364
+ if (r.ok) restarted = 'supervisor';
365
+ } catch {}
366
+ if (restarted === 'none') {
367
+ const hb = loadJson(join(home, 'heartbeat.json'), {});
368
+ if (hb.pid) { try { process.kill(hb.pid, 'SIGTERM'); restarted = 'signal'; } catch {} }
369
+ }
370
+ this.log(`key set for ${name}: provider ${provider}, restart via ${restarted}`);
371
+ return { ...this.status(name), restarted };
372
+ }
373
+
240
374
  status(name) {
241
375
  const home = join(this.dataDir, 'bees', name);
242
376
  const state = loadJson(join(home, 'provision.json'), null);
@@ -250,7 +384,9 @@ export class Provisioner {
250
384
  return {
251
385
  name,
252
386
  bee_pubkey: state.steps.bee_key || null,
387
+ owner_pubkey: state.owner_pubkey || null,
253
388
  steps: Object.keys(state.steps),
389
+ brain: cfg.awaiting_key ? 'awaiting-key' : (cfg.provider || null),
254
390
  daemon: hb.at ? { last_tick_at: hb.at, pid: hb.pid, paused: hb.paused || false } : null,
255
391
  grants: grant,
256
392
  budget: spend ? { date: spend.date, jelly_spent: spend.jelly_spent, daily_cap: cfg.spend?.jelly_daily_cap ?? 15 } : { daily_cap: cfg.spend?.jelly_daily_cap ?? 15 },
@@ -216,6 +216,33 @@ createServer((req, res) => {
216
216
  res.end(JSON.stringify({ respawned: true }));
217
217
  return;
218
218
  }
219
+ if (url.pathname === '/restart' && req.method === 'POST') {
220
+ // Restart ONE bee with fresh config+secrets from disk (used by the api
221
+ // worker after `hive key set` rewrites them). Internal port only.
222
+ let body = '';
223
+ req.on('data', (c) => { body += c; });
224
+ req.on('end', () => {
225
+ let name = '';
226
+ try { name = String(JSON.parse(body || '{}').name || ''); } catch {}
227
+ const e = bees.get(name);
228
+ if (!name || (!e && !existsSync(join(BEES_DIR, name, 'config.json')))) {
229
+ res.writeHead(404, { 'content-type': 'application/json' });
230
+ res.end('{"error":"unknown bee"}');
231
+ return;
232
+ }
233
+ if (e) { e.restarts = []; e.backoffMs = 1000; }
234
+ if (e?.child) {
235
+ try { e.child.kill('SIGTERM'); } catch {} // exit handler respawns
236
+ } else {
237
+ if (e) e.state = 'stopped';
238
+ spawnBee(name);
239
+ }
240
+ log(`restart requested for bee ${name}`);
241
+ res.writeHead(200, { 'content-type': 'application/json' });
242
+ res.end(JSON.stringify({ restarting: name }));
243
+ });
244
+ return;
245
+ }
219
246
  res.writeHead(404, { 'content-type': 'application/json' });
220
247
  res.end('{"error":"not found"}');
221
248
  }).listen(HEALTH_PORT, () => log(`health on :${HEALTH_PORT}/healthz`));