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.
- package/README.md +5 -3
- package/bin/derive-evm-key.mjs +13 -0
- package/bin/hive +26 -6
- package/bin/hive-buzz.mjs +73 -0
- package/bin/hive-join.mjs +342 -91
- package/bin/hive-key.mjs +131 -0
- package/bin/hive-net.mjs +13 -0
- package/daemon/fanout.mjs +5 -2
- package/daemon/hived.mjs +21 -2
- package/docs/cli.md +3 -1
- package/package.json +3 -2
- package/server/api.mjs +41 -1
- package/server/join-page.mjs +7 -5
- package/server/provision.mjs +140 -4
- package/server/supervisor.mjs +27 -0
- package/shared/prompt.mjs +168 -0
package/bin/hive-join.mjs
CHANGED
|
@@ -1,34 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hive-join — full member onboarding
|
|
2
|
+
// hive-join — full member onboarding: one command → live bee → Buzz UI.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// npx joinhive join --invite <code> (primary path)
|
|
5
|
+
// hive join --invite <code> [--server https://api.<domain>]
|
|
5
6
|
//
|
|
6
7
|
// What happens where (the privacy contract):
|
|
7
8
|
// LAPTOP: identity keys, the shared wallet mnemonic (Apple Keychain), your
|
|
8
9
|
// LLM API key (login Keychain, for the sync watcher), and the
|
|
9
10
|
// profile distillation — RAW CHAT HISTORY NEVER LEAVES.
|
|
10
|
-
// UPLOADED: the distilled profile + a nacl.box-sealed
|
|
11
|
-
// llm_api_key}
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
// UPLOADED: the distilled profile (shown to you first) + a nacl.box-sealed
|
|
12
|
+
// {wallet_mnemonic, llm_api_key?} only the bee-host can open.
|
|
13
|
+
//
|
|
14
|
+
// Echo-first: you can join WITHOUT an LLM key — your bee is born in echo mode
|
|
15
|
+
// (alive, visible, silent) and `hive key set` switches its brain on later.
|
|
16
|
+
// Every step is idempotent: re-running the same command resumes.
|
|
17
|
+
//
|
|
18
|
+
// Flags: --name --provider --model-extract --model-compute --export <zip>
|
|
19
|
+
// --yes (skip confirmations) --no-watcher --redistill
|
|
20
|
+
// Headless: --provider … with HIVE_LLM_KEY set, --skip-key (echo-first,
|
|
21
|
+
// brainless until `hive key set`), or --provider echo (test seam: answers).
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, symlinkSync, rmSync, appendFileSync } from 'node:fs';
|
|
23
|
+
import { execFileSync, spawnSync, spawn } from 'node:child_process';
|
|
16
24
|
import { homedir, platform, userInfo } from 'node:os';
|
|
17
|
-
import { join, dirname } from 'node:path';
|
|
25
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
18
26
|
import { fileURLToPath } from 'node:url';
|
|
19
|
-
import { createInterface } from 'node:readline/promises';
|
|
20
27
|
import nacl from 'tweetnacl';
|
|
21
28
|
import { finalizeEvent } from 'nostr-tools/pure';
|
|
22
29
|
import { signedFetch } from '../shared/nip98.mjs';
|
|
23
30
|
import { computeAuthTag } from '../shared/nip-oa.mjs';
|
|
24
31
|
import { createEngine } from '../daemon/engines/index.mjs';
|
|
25
|
-
import { OPENAI_COMPAT_BASES, MODEL_TIERS } from '../shared/config-schema.mjs';
|
|
32
|
+
import { OPENAI_COMPAT_BASES, MODEL_TIERS, DEFAULTS } from '../shared/config-schema.mjs';
|
|
33
|
+
import { EV, tryJson } from '../shared/events.mjs';
|
|
34
|
+
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
35
|
+
import { select, ask, confirm, pause, spinner, checkpoints } from '../shared/prompt.mjs';
|
|
26
36
|
|
|
27
37
|
const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
28
38
|
const HIVE_HOME = process.env.HIVE_HOME || join(homedir(), '.hive');
|
|
29
39
|
const args = process.argv.slice(2);
|
|
30
40
|
const flag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
|
|
41
|
+
const has = (n) => args.includes(`--${n}`);
|
|
31
42
|
const say = (...a) => console.log('🐝', ...a);
|
|
43
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
32
44
|
const die = (msg) => { console.error('❌', msg); process.exit(1); };
|
|
33
45
|
const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
|
|
34
46
|
const node = (script, argv = [], opts = {}) => {
|
|
@@ -36,13 +48,71 @@ const node = (script, argv = [], opts = {}) => {
|
|
|
36
48
|
return { code: r.status, out: (r.stdout || '') + (r.status !== 0 ? (r.stderr || '') : '') };
|
|
37
49
|
};
|
|
38
50
|
|
|
51
|
+
// The flagship community. Invite links can point anywhere via --server.
|
|
52
|
+
const DEFAULT_SERVER = 'https://bee-host-production.up.railway.app';
|
|
39
53
|
const INVITE = flag('invite');
|
|
40
|
-
const SERVER = (flag('server') ||
|
|
41
|
-
if (!INVITE
|
|
54
|
+
const SERVER = (flag('server') || DEFAULT_SERVER).replace(/\/+$/, '');
|
|
55
|
+
if (!INVITE) die('usage: npx joinhive join --invite <code> [--server https://api.<domain>]');
|
|
42
56
|
|
|
43
|
-
|
|
57
|
+
// ---- npx self-install -------------------------------------------------------
|
|
58
|
+
// `npx joinhive …` runs from npm's _npx cache, which npm garbage-collects —
|
|
59
|
+
// anything durable pointed there (the launchd watcher plist, the PATH shim)
|
|
60
|
+
// dies silently later. So the first npx run copies the pack to ~/.hive-pack
|
|
61
|
+
// (the same canonical home the curl installer uses) and re-execs from there.
|
|
62
|
+
// If anything about the copy fails we keep going from the cache, minus the
|
|
63
|
+
// watcher.
|
|
64
|
+
let watcherUnsafe = false;
|
|
65
|
+
const vgte = (a, b) => { // "2.1.0" >= "2.0.1"
|
|
66
|
+
const pa = String(a).split('.').map(Number); const pb = String(b).split('.').map(Number);
|
|
67
|
+
for (let i = 0; i < 3; i++) { if ((pa[i] || 0) > (pb[i] || 0)) return true; if ((pa[i] || 0) < (pb[i] || 0)) return false; }
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
const installShim = (dest) => {
|
|
71
|
+
mkdirSync(join(homedir(), '.hive', 'bin'), { recursive: true });
|
|
72
|
+
const shim = join(homedir(), '.hive', 'bin', 'hive');
|
|
73
|
+
try { rmSync(shim, { force: true }); } catch {}
|
|
74
|
+
symlinkSync(join(dest, 'bin', 'hive'), shim);
|
|
75
|
+
for (const rc of [join(homedir(), '.zshrc'), join(homedir(), '.bashrc')]) {
|
|
76
|
+
try {
|
|
77
|
+
if (existsSync(rc) && !readFileSync(rc, 'utf8').includes('.hive/bin')) {
|
|
78
|
+
appendFileSync(rc, '\nexport PATH="$HOME/.hive/bin:$PATH"\n');
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const selfInstall = () => {
|
|
84
|
+
if (process.env.HIVE_NO_SELF_INSTALL) return;
|
|
85
|
+
const DEST = join(homedir(), '.hive-pack');
|
|
86
|
+
if (resolve(PACK_DIR) === resolve(DEST)) return;
|
|
87
|
+
const fromNpx = PACK_DIR.split(sep).includes('_npx') || process.env.npm_command === 'exec';
|
|
88
|
+
if (!fromNpx) return;
|
|
89
|
+
try {
|
|
90
|
+
const mine = loadJson(join(PACK_DIR, 'package.json'), {}).version || '0.0.0';
|
|
91
|
+
const theirs = loadJson(join(DEST, 'package.json'), {}).version;
|
|
92
|
+
if (!(theirs && vgte(theirs, mine))) {
|
|
93
|
+
say(`installing the hive pack to ~/.hive-pack (v${mine}) — npx cache dirs get garbage-collected …`);
|
|
94
|
+
cpSync(PACK_DIR, DEST, {
|
|
95
|
+
recursive: true, force: true,
|
|
96
|
+
filter: (src) => !/\/(node_modules|\.git)(\/|$)/.test(src) && !/\/onchain\/(lib|out|cache)(\/|$)/.test(src),
|
|
97
|
+
});
|
|
98
|
+
const inst = spawnSync('npm', ['install', '--omit=dev', '--silent', '--no-fund', '--no-audit'], { cwd: DEST, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
|
|
99
|
+
if (inst.status !== 0) throw new Error(`npm install: ${String(inst.stderr).slice(0, 160)}`);
|
|
100
|
+
}
|
|
101
|
+
installShim(DEST);
|
|
102
|
+
const r = spawnSync(process.execPath, [join(DEST, 'bin', 'hive-join.mjs'), ...args], {
|
|
103
|
+
stdio: 'inherit', env: { ...process.env, HIVE_NO_SELF_INSTALL: '1' },
|
|
104
|
+
});
|
|
105
|
+
process.exit(r.status ?? 0);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
say(`⚠️ self-install skipped (${String(e.message).slice(0, 120)}) — continuing from the npx cache without the auto-sync watcher`);
|
|
108
|
+
watcherUnsafe = true;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
selfInstall();
|
|
44
112
|
|
|
45
113
|
const main = async () => {
|
|
114
|
+
const joinStart = Math.floor(Date.now() / 1000);
|
|
115
|
+
|
|
46
116
|
// 0. Server hello: provisioning box key + the community's relay URL.
|
|
47
117
|
say(`checking in with ${SERVER} …`);
|
|
48
118
|
let hello;
|
|
@@ -51,13 +121,17 @@ const main = async () => {
|
|
|
51
121
|
if (!hello.box_pub || !hello.relay) die('server did not return a provisioning key');
|
|
52
122
|
const RELAY = hello.relay;
|
|
53
123
|
|
|
54
|
-
// 1. Identity (idempotent)
|
|
124
|
+
// 1. Identity (idempotent) — needed before the name check can tell
|
|
125
|
+
// "taken by someone else" from "mine, resuming".
|
|
55
126
|
const kg = node('bin/hive-keygen.mjs');
|
|
56
127
|
if (kg.code !== 0) die(`keygen failed: ${kg.out.slice(0, 200)}`);
|
|
57
128
|
const identity = loadJson(join(HIVE_HOME, 'identity.json'), null) || die('identity missing after keygen');
|
|
58
129
|
say(`your key: ${identity.pubkey.slice(0, 12)}…`);
|
|
59
130
|
|
|
60
|
-
// 2. Join the community relay (config + invite claim).
|
|
131
|
+
// 2. Join the community relay AS YOURSELF (config + invite claim). The
|
|
132
|
+
// server claims membership for the BEE key separately — without this
|
|
133
|
+
// claim the OWNER can't publish anything (kind-30177, channel joins,
|
|
134
|
+
// every future `hive ask`).
|
|
61
135
|
const cfgPath = join(HIVE_HOME, 'config.json');
|
|
62
136
|
const cfg = loadJson(cfgPath, {});
|
|
63
137
|
cfg.relay = RELAY;
|
|
@@ -68,113 +142,237 @@ const main = async () => {
|
|
|
68
142
|
if (claimJson.error === 'age_attestation_required') die('this community requires an age attestation — re-run with the CLI flag --age-confirmed via: hive claim-invite');
|
|
69
143
|
// Open relay (dev/local) has no membership gate: if reads work without a
|
|
70
144
|
// claim, continue. On a closed relay the probe 403s and this is fatal.
|
|
71
|
-
const
|
|
72
|
-
const probe = await probeFetch(identity.privkey, 'POST', `${RELAY.replace(/^ws/, 'http')}/query`, [{ kinds: [39000], limit: 1 }]);
|
|
145
|
+
const probe = await signedFetch(identity.privkey, 'POST', `${RELAY.replace(/^ws/, 'http')}/query`, [{ kinds: [39000], limit: 1 }]);
|
|
73
146
|
if (probe.status !== 200) die(`relay invite claim failed: ${claim.out.slice(0, 300)}`);
|
|
74
147
|
say('relay has no membership gate (open/dev) — continuing without a claim');
|
|
75
148
|
}
|
|
76
149
|
say(`joined relay ${RELAY}`);
|
|
77
150
|
|
|
78
|
-
// 3.
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (wallet.status === 'created') {
|
|
84
|
-
const exp = node('bin/hive-wallet.mjs', ['export']);
|
|
151
|
+
// 3. PROMPT 1 — the bee's name, availability-checked before any real work.
|
|
152
|
+
const defaultName = (flag('name') || cfg.owner_name || userInfo().username || 'member').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
|
|
153
|
+
let resuming = false;
|
|
154
|
+
const nameCheck = async (n) => {
|
|
155
|
+
if (!/^[a-z0-9][a-z0-9-]{1,23}$/.test(n)) return 'letters, digits, dashes — 2 to 24 chars';
|
|
85
156
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
const r = await fetch(`${SERVER}/api/bees/${n}/status`, { signal: AbortSignal.timeout(6000) });
|
|
158
|
+
if (r.status === 404) return true;
|
|
159
|
+
const st = await r.json().catch(() => ({}));
|
|
160
|
+
if (st.owner_pubkey && st.owner_pubkey !== identity.pubkey) return `"${n}.bee" already belongs to another member — pick a different name`;
|
|
161
|
+
resuming = !!st.owner_pubkey;
|
|
162
|
+
return true;
|
|
163
|
+
} catch { return true; } // network hiccup — provisioning still arbitrates (409)
|
|
164
|
+
};
|
|
165
|
+
const name = flag('name')
|
|
166
|
+
? await (async () => { const v = await nameCheck(defaultName); if (v !== true) die(v); return defaultName; })()
|
|
167
|
+
: await ask({ prompt: "your bee's name", def: defaultName, validate: nameCheck });
|
|
168
|
+
if (resuming) say(`${name}.bee is already yours — resuming that provision`);
|
|
92
169
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
170
|
+
// 3. PROMPT 2 — the bee's brain. "Skip for now" is first on purpose: the
|
|
171
|
+
// hardest ask of onboarding (go get an API key) comes AFTER the first
|
|
172
|
+
// win, not before it.
|
|
173
|
+
let provider = flag('provider') || (process.env.HIVE_LLM_KEY && ['anthropic', 'openai', 'openrouter', 'hermes'].includes(cfg.provider) ? cfg.provider : null);
|
|
174
|
+
let awaitingKey = false;
|
|
175
|
+
if (has('skip-key')) { provider = 'echo'; awaitingKey = true; } // headless echo-first
|
|
96
176
|
if (!provider) {
|
|
97
|
-
|
|
177
|
+
const pick = await select({
|
|
178
|
+
title: "your bee's brain",
|
|
179
|
+
options: [
|
|
180
|
+
{ label: 'Skip for now — decide later', hint: 'your bee joins in echo mode; `hive key set` adds a brain in 60s', value: 'skip' },
|
|
181
|
+
{ label: 'OpenRouter', hint: 'recommended: one key, every model — $5 lasts months (openrouter.ai/keys)', value: 'openrouter' },
|
|
182
|
+
{ label: 'Anthropic', hint: 'API key from console.anthropic.com (a Claude subscription is NOT an API key)', value: 'anthropic' },
|
|
183
|
+
{ label: 'OpenAI', hint: 'API key from platform.openai.com', value: 'openai' },
|
|
184
|
+
{ label: 'Nous Hermes', hint: 'API key from portal.nousresearch.com', value: 'hermes' },
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
if (pick === 'skip') { provider = 'echo'; awaitingKey = true; }
|
|
188
|
+
else provider = pick;
|
|
98
189
|
}
|
|
99
190
|
if (!['anthropic', 'openai', 'openrouter', 'hermes', 'echo'].includes(provider)) die(`unknown provider "${provider}"`);
|
|
191
|
+
|
|
100
192
|
let llmKey = process.env.HIVE_LLM_KEY || null;
|
|
101
193
|
let models = { extract: flag('model-extract'), compute: flag('model-compute') };
|
|
102
|
-
if (provider === 'echo') {
|
|
103
|
-
llmKey = 'echo'; // test seam: no key, no thinking, no spend — rehearsals/CI
|
|
104
|
-
} else {
|
|
194
|
+
if (provider === 'echo' && !awaitingKey) {
|
|
195
|
+
llmKey = 'echo'; // test seam (--provider echo): no key, no thinking, no spend — rehearsals/CI
|
|
196
|
+
} else if (provider !== 'echo') {
|
|
105
197
|
if (!llmKey && platform() === 'darwin') {
|
|
106
198
|
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 {}
|
|
107
199
|
}
|
|
108
|
-
if (!llmKey) llmKey = (await
|
|
109
|
-
if (!llmKey) die('an API key is required —
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
200
|
+
if (!llmKey) llmKey = (await ask({ prompt: `${provider} API key ${dim('(stored in your login Keychain, uploaded sealed for your bee)')}` })).trim();
|
|
201
|
+
if (!llmKey) die('an API key is required for that provider — or re-run and pick "Skip for now"');
|
|
202
|
+
if (provider === 'openrouter' || provider === 'hermes') {
|
|
203
|
+
// Sensible defaults instead of blank raw-model-ID prompts (Enter-Enter).
|
|
204
|
+
const defs = provider === 'openrouter'
|
|
205
|
+
? { extract: 'anthropic/claude-haiku-4.5', compute: 'anthropic/claude-sonnet-5' }
|
|
206
|
+
: { extract: 'Hermes-4-405B', compute: 'Hermes-4-405B' };
|
|
207
|
+
models.extract = models.extract || await ask({ prompt: `${provider} model for cheap extraction`, def: defs.extract });
|
|
208
|
+
models.compute = models.compute || await ask({ prompt: `${provider} model for strong compute`, def: defs.compute });
|
|
113
209
|
}
|
|
114
|
-
|
|
210
|
+
const spinKey = spinner('validating the key with a 1-token self-test …');
|
|
115
211
|
const tiers = MODEL_TIERS[provider] || {};
|
|
116
212
|
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 }, {});
|
|
117
213
|
const probe = await engine.selftest();
|
|
118
|
-
if (String(probe).startsWith('engine-error')) die(`key check failed: ${probe.slice(0, 200)}`);
|
|
119
|
-
|
|
214
|
+
if (String(probe).startsWith('engine-error')) { spinKey.stop(); die(`key check failed: ${probe.slice(0, 200)}`); }
|
|
215
|
+
spinKey.stop('🐝 key works ✓');
|
|
120
216
|
if (platform() === 'darwin') {
|
|
121
217
|
try { execFileSync('security', ['add-generic-password', '-U', '-a', provider, '-s', 'hive-llm-key', '-l', `Hive LLM key (${provider})`, '-w', llmKey], { stdio: 'ignore' }); } catch {}
|
|
122
218
|
}
|
|
123
219
|
}
|
|
124
220
|
|
|
125
|
-
//
|
|
221
|
+
// 4. PROMPT 3 — memory. Local chat history is auto-detected; a claude.ai
|
|
222
|
+
// export zip makes the profile much richer for people who mostly chat
|
|
223
|
+
// in the app rather than the terminal.
|
|
224
|
+
let exportZip = flag('export');
|
|
225
|
+
const profilePath = join(HIVE_HOME, 'data-store', 'profile.md');
|
|
226
|
+
const hasProfile = existsSync(profilePath) && readFileSync(profilePath, 'utf8').trim().length > 50 && !has('redistill');
|
|
227
|
+
if (!exportZip && !hasProfile && !has('yes')) {
|
|
228
|
+
exportZip = await ask({
|
|
229
|
+
prompt: `claude.ai export zip for richer memory ${dim('(claude.ai → Settings → Export data; Enter to skip)')}`,
|
|
230
|
+
def: '',
|
|
231
|
+
validate: (v) => !v || existsSync(v) || 'no file at that path (Enter to skip)',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 5. Shared wallet — mnemonic into the Apple Keychain, shown ONCE, and the
|
|
236
|
+
// flow HALTS until the human says they wrote it down (PROMPT 4).
|
|
237
|
+
const w = node('bin/hive-wallet.mjs');
|
|
238
|
+
if (w.code !== 0) die(`wallet failed: ${w.out.slice(0, 200)}`);
|
|
239
|
+
const wallet = JSON.parse(w.out.trim().split('\n').pop());
|
|
240
|
+
say(`shared wallet: ${wallet.evm_address}`);
|
|
241
|
+
if (wallet.status === 'created') {
|
|
242
|
+
const exp = node('bin/hive-wallet.mjs', ['export']);
|
|
243
|
+
try {
|
|
244
|
+
const mn = JSON.parse(exp.out).mnemonic;
|
|
245
|
+
say('———— RECOVERY PHRASE (shown once; backed up in your Apple Keychain) ————');
|
|
246
|
+
console.log(` ${mn}`);
|
|
247
|
+
say('————————————————————————————————————————————————————————————————————————');
|
|
248
|
+
if (!has('yes')) {
|
|
249
|
+
await pause({ prompt: ' write these 12 words down, then press Enter →' });
|
|
250
|
+
if (process.stdout.isTTY) process.stdout.write('\x1b[4A\x1b[0J');
|
|
251
|
+
say('recovery phrase acknowledged ✓ (it stays in your Keychain: hive wallet export)');
|
|
252
|
+
}
|
|
253
|
+
} catch {}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 6. Distill the private profile LOCALLY (the only data that gets uploaded).
|
|
126
257
|
// An existing profile is PRESERVED (migrations, re-joins) — pass
|
|
127
258
|
// --redistill to rebuild it from scratch.
|
|
128
259
|
mkdirSync(join(HIVE_HOME, 'data-store'), { recursive: true });
|
|
129
|
-
|
|
130
|
-
if (existsSync(profilePath) && readFileSync(profilePath, 'utf8').trim().length > 50 && !args.includes('--redistill')) {
|
|
260
|
+
if (hasProfile) {
|
|
131
261
|
say('using your existing profile (pass --redistill to rebuild it)');
|
|
132
262
|
} else {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
263
|
+
const spinD = spinner('distilling your profile from local chat history — raw chats never leave this machine …');
|
|
264
|
+
// Echo/keyless joins go straight to the heuristic ladder (no LLM call).
|
|
265
|
+
const distillArgs = ['--out', profilePath];
|
|
266
|
+
if (provider !== 'echo' && llmKey && llmKey !== 'echo') distillArgs.push('--provider', provider, '--key', llmKey);
|
|
136
267
|
if (models.extract) distillArgs.push('--model', models.extract);
|
|
137
|
-
|
|
268
|
+
if (exportZip) distillArgs.push('--export', exportZip);
|
|
269
|
+
const d = await new Promise((resolveP) => {
|
|
270
|
+
const p = spawn(process.execPath, [join(PACK_DIR, 'watcher', 'distill.mjs'), ...distillArgs], { env: { ...process.env, HIVE_HOME } });
|
|
271
|
+
let out = ''; let err = '';
|
|
272
|
+
p.stdout.on('data', (c) => { out += c; });
|
|
273
|
+
p.stderr.on('data', (c) => {
|
|
274
|
+
err += c;
|
|
275
|
+
const m = String(c).match(/corpus: (\d+) user turns from ~(\d+) sessions/);
|
|
276
|
+
if (m) spinD.text(`distilling locally: ${m[1]} of your chat turns from ~${m[2]} sessions …`);
|
|
277
|
+
});
|
|
278
|
+
p.on('close', (code) => resolveP({ code, out: out + (code !== 0 ? err : '') }));
|
|
279
|
+
p.on('error', (e) => resolveP({ code: 1, out: String(e.message) }));
|
|
280
|
+
});
|
|
281
|
+
spinD.stop();
|
|
138
282
|
if (d.code !== 0 || !existsSync(profilePath)) die(`distillation failed: ${d.out.slice(0, 300)}`);
|
|
139
283
|
}
|
|
140
284
|
const profile = readFileSync(profilePath, 'utf8');
|
|
141
|
-
say(`profile ready (${profile.length} chars) — review anytime: ~/.hive/data-store/profile.md`);
|
|
142
285
|
|
|
143
|
-
//
|
|
286
|
+
// 7. PROMPT 5 — the privacy promise, shown instead of claimed: this page is
|
|
287
|
+
// the ONLY personal data that leaves the laptop, and the member sees it
|
|
288
|
+
// before it goes.
|
|
289
|
+
const lines = profile.split('\n');
|
|
290
|
+
say(`profile ready (${profile.length} chars) — this one page is ALL that leaves your laptop:`);
|
|
291
|
+
console.log(dim(lines.slice(0, 15).map((l) => ` ${l}`).join('\n') + (lines.length > 15 ? `\n … (${lines.length - 15} more lines — full file: ~/.hive/data-store/profile.md)` : '')));
|
|
292
|
+
if (!has('yes')) {
|
|
293
|
+
const okUp = await confirm({ prompt: 'upload this profile to power your bee?', def: true });
|
|
294
|
+
if (!okUp) die('no problem — edit ~/.hive/data-store/profile.md, then re-run this exact command (it resumes where it stopped)');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 8. Seal secrets and provision — while the server walks its state machine
|
|
298
|
+
// we stream its per-step progress instead of going silent.
|
|
144
299
|
const mnemonic = (() => {
|
|
145
300
|
try { return JSON.parse(node('bin/hive-wallet.mjs', ['export']).out).mnemonic; } catch { return null; }
|
|
146
301
|
})();
|
|
147
302
|
if (!mnemonic) die('could not read the wallet mnemonic back from the Keychain');
|
|
148
303
|
const client = nacl.box.keyPair();
|
|
149
304
|
const nonce = nacl.randomBytes(24);
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
nonce, Buffer.from(hello.box_pub, 'base64'), client.secretKey,
|
|
153
|
-
);
|
|
305
|
+
const sealedPayload = { wallet_mnemonic: mnemonic, ...(awaitingKey ? {} : { llm_api_key: llmKey }) };
|
|
306
|
+
const box = nacl.box(Buffer.from(JSON.stringify(sealedPayload)), nonce, Buffer.from(hello.box_pub, 'base64'), client.secretKey);
|
|
154
307
|
say(`provisioning ${name}.bee on the community server …`);
|
|
155
308
|
const body = {
|
|
156
309
|
invite: INVITE, name, owner_pubkey: identity.pubkey, owner_name: name,
|
|
157
310
|
evm_address: wallet.evm_address, provider,
|
|
311
|
+
...(awaitingKey ? { awaiting_key: true } : {}),
|
|
158
312
|
...(OPENAI_COMPAT_BASES[provider] ? { base_url: OPENAI_COMPAT_BASES[provider] } : {}),
|
|
159
313
|
...(models.extract ? { model_extract: models.extract } : {}),
|
|
160
314
|
...(models.compute ? { model_compute: models.compute } : {}),
|
|
161
315
|
sealed: { nonce: Buffer.from(nonce).toString('base64'), box: Buffer.from(box).toString('base64'), client_pub: Buffer.from(client.publicKey).toString('base64') },
|
|
162
316
|
profile_md: profile,
|
|
163
317
|
};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
318
|
+
|
|
319
|
+
const STEP_LABELS = [
|
|
320
|
+
{ key: 'invite_checked', label: 'invite' },
|
|
321
|
+
{ key: 'bee_key', label: 'bee key' },
|
|
322
|
+
{ key: 'relay_membership', label: 'relay' },
|
|
323
|
+
{ key: 'secrets_stored', label: 'secrets' },
|
|
324
|
+
{ key: 'profile_written', label: 'profile' },
|
|
325
|
+
{ key: 'grants_queued', label: '500 JELLY' },
|
|
326
|
+
{ key: 'welcome_posted', label: 'welcome' },
|
|
327
|
+
{ key: 'done', label: 'daemon' },
|
|
328
|
+
];
|
|
329
|
+
const cp = checkpoints(STEP_LABELS);
|
|
330
|
+
let posted = null;
|
|
331
|
+
const postPromise = signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees`, body)
|
|
332
|
+
.then((r) => { posted = r; }).catch((e) => { posted = { status: 0, json: { error: String(e.message || e) } }; });
|
|
333
|
+
|
|
334
|
+
let st = null;
|
|
335
|
+
for (let i = 0; i < 200; i++) {
|
|
336
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
337
|
+
try {
|
|
338
|
+
const r = await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(5000) });
|
|
339
|
+
if (r.status === 200) {
|
|
340
|
+
st = await r.json();
|
|
341
|
+
for (const { key } of STEP_LABELS) {
|
|
342
|
+
if (key === 'done') continue;
|
|
343
|
+
if (st.steps?.includes(key)) cp.set(key, 'done');
|
|
344
|
+
}
|
|
345
|
+
if (st.steps?.includes('done')) cp.set('done', st.status === 'ready' ? 'done' : 'active');
|
|
346
|
+
if (st.status === 'ready') break;
|
|
347
|
+
}
|
|
348
|
+
} catch {}
|
|
349
|
+
if (posted && posted.status !== 200) break;
|
|
350
|
+
// Laptop-side work happens below while the daemon wakes; bail out of the
|
|
351
|
+
// streaming loop once the state machine is done and only the heartbeat
|
|
352
|
+
// is pending — the final wait continues after NIP-OA + config.
|
|
353
|
+
if (st?.steps?.includes('done')) break;
|
|
354
|
+
}
|
|
355
|
+
await postPromise;
|
|
356
|
+
if (posted && posted.status !== 200) {
|
|
357
|
+
cp.stop();
|
|
358
|
+
const err = JSON.stringify(posted.json).slice(0, 300);
|
|
359
|
+
if (awaitingKey && /llm_api_key/.test(err)) die('this community server predates echo-first joins — re-run and pick a brain, or ask the operator to update the bee-host');
|
|
360
|
+
die(`provisioning failed (http ${posted.status}): ${err}`);
|
|
361
|
+
}
|
|
362
|
+
cp.stop();
|
|
363
|
+
const beePubkey = posted?.json?.bee_pubkey || st?.bee_pubkey;
|
|
167
364
|
say(`${name}.bee exists: ${String(beePubkey).slice(0, 12)}…`);
|
|
168
365
|
|
|
169
|
-
//
|
|
366
|
+
// 8b. Buzz Agents directory (NIP-OA): the OWNER key signs both halves of
|
|
170
367
|
// the pairing right here — it never leaves this laptop.
|
|
171
368
|
// (a) kind-30177 managed-agent record (owner-authored, bee pubkey in
|
|
172
369
|
// the d tag), published straight to the relay: the relay requires
|
|
173
370
|
// event author == HTTP signer, so only this laptop can publish it.
|
|
174
371
|
// (b) the NIP-OA auth tag co-signature over the bee's pubkey, shipped
|
|
175
372
|
// to the server (it holds the bee key) so the bee's kind-0 profile
|
|
176
|
-
// carries it.
|
|
177
|
-
//
|
|
373
|
+
// carries it. Owner verification comes from this pair; the Agents
|
|
374
|
+
// TAB listing additionally needs the bot-role channel membership
|
|
375
|
+
// the server grants at provision (bot_role step).
|
|
178
376
|
// Best-effort: the bee works without it; a re-join retries both halves.
|
|
179
377
|
if (/^[0-9a-f]{64}$/.test(String(beePubkey))) {
|
|
180
378
|
try {
|
|
@@ -190,23 +388,34 @@ const main = async () => {
|
|
|
190
388
|
const authTag = computeAuthTag(identity.privkey, beePubkey, '');
|
|
191
389
|
const resp2 = await signedFetch(identity.privkey, 'POST', `${SERVER}/api/bees`, { ...body, nip_oa_auth: authTag });
|
|
192
390
|
if (resp2.status !== 200) throw new Error(`auth-tag delivery: http=${resp2.status} ${JSON.stringify(resp2.json).slice(0, 160)}`);
|
|
193
|
-
say(`
|
|
391
|
+
say(`Buzz Agents record published (owner-verified: ${name}.bee — visible in the app via hive buzz)`);
|
|
194
392
|
} catch (e) {
|
|
195
393
|
say(`⚠️ Buzz Agents registration incomplete (${String(e.message).slice(0, 160)}) — re-run this join to retry; the bee itself is unaffected`);
|
|
196
394
|
}
|
|
197
395
|
}
|
|
198
396
|
|
|
199
|
-
//
|
|
200
|
-
//
|
|
397
|
+
// 9. Remember the pairing locally (invite + server power `hive buzz` and
|
|
398
|
+
// `hive key set` later), join the hive channels as YOURSELF (the Buzz
|
|
399
|
+
// app only lists agents in channels you share with them), and publish
|
|
400
|
+
// your kind-0 so apps show your name, not a pubkey.
|
|
201
401
|
const cfg2 = loadJson(cfgPath, {});
|
|
202
|
-
Object.assign(cfg2, {
|
|
402
|
+
Object.assign(cfg2, {
|
|
403
|
+
owner_name: name, bee_name: `${name}.bee`, bee_pubkey: beePubkey,
|
|
404
|
+
server_url: SERVER, invite: INVITE,
|
|
405
|
+
sync: { enabled: true, provider },
|
|
406
|
+
});
|
|
203
407
|
writeFileSync(cfgPath, JSON.stringify(cfg2, null, 2) + '\n');
|
|
408
|
+
try {
|
|
409
|
+
const me = new RelayClient({ relayUrl: RELAY, privkey: identity.privkey, log: () => {} });
|
|
410
|
+
for (const chName of Object.values(DEFAULTS.channels)) await me.ensureChannel(chName);
|
|
411
|
+
} catch (e) { say(`⚠️ channel self-join incomplete (${String(e.message).slice(0, 100)}) — hive buzz will fix this`); }
|
|
204
412
|
const prof = node('bin/hive-net.mjs', ['set-name', name]);
|
|
205
413
|
if (prof.code === 0) say(`your profile name is set: ${name}`);
|
|
206
414
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
415
|
+
// 10. Auto-sync watcher (launchd, macOS). --no-watcher skips it — used for
|
|
416
|
+
// secondary endpoints on the same machine (one watcher per Mac). Never
|
|
417
|
+
// installed from an npx cache dir (npm may delete it under the plist).
|
|
418
|
+
if (platform() === 'darwin' && !has('no-watcher') && !watcherUnsafe) {
|
|
210
419
|
try {
|
|
211
420
|
const tmpl = readFileSync(join(PACK_DIR, 'watcher', 'global.nfh.hive.sync.plist.tmpl'), 'utf8')
|
|
212
421
|
.replaceAll('__NODE__', process.execPath).replaceAll('__PACK__', PACK_DIR).replaceAll('__HOME__', homedir());
|
|
@@ -219,25 +428,67 @@ const main = async () => {
|
|
|
219
428
|
} catch (e) { say(`watcher install skipped (${String(e.message).slice(0, 80)}) — run: hive sync now`); }
|
|
220
429
|
}
|
|
221
430
|
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
431
|
+
// 11. Wait for the bee's first heartbeat, then catch its welcome. The
|
|
432
|
+
// steward posted a welcome intent during provisioning; other bees'
|
|
433
|
+
// replies are the newcomer's first feed.
|
|
434
|
+
let ready = st?.status === 'ready';
|
|
435
|
+
if (!ready) {
|
|
436
|
+
const spinUp = spinner(`waiting for ${name}.bee to wake up (engine self-test + genesis grants) …`);
|
|
437
|
+
for (let i = 0; i < 30 && !ready; i++) {
|
|
438
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
439
|
+
try {
|
|
440
|
+
const r = await fetch(`${SERVER}/api/bees/${name}/status`, { signal: AbortSignal.timeout(5000) });
|
|
441
|
+
if (r.status === 200) { st = await r.json(); ready = st.status === 'ready'; }
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
spinUp.stop();
|
|
445
|
+
}
|
|
446
|
+
if (!ready) {
|
|
447
|
+
say('provisioned, but the bee has not heartbeated yet — check later: hive agent status');
|
|
448
|
+
return;
|
|
238
449
|
}
|
|
239
|
-
|
|
240
|
-
|
|
450
|
+
|
|
451
|
+
say('———— YOU ARE IN ————');
|
|
452
|
+
say(`bee: ${name}.bee (${String(beePubkey).slice(0, 12)}…)`);
|
|
453
|
+
say(`wallet: ${wallet.evm_address} (shared: you + your bee)`);
|
|
454
|
+
if (st?.grants?.jelly_tx) say(`grant: 500 JELLY — https://sepolia.etherscan.io/tx/${st.grants.jelly_tx}`);
|
|
455
|
+
if (awaitingKey) say(`brain: none yet — ${name}.bee is in echo mode (alive + visible, not thinking)`);
|
|
456
|
+
|
|
457
|
+
// Welcome tail: print the first replies addressed to the new member.
|
|
458
|
+
try {
|
|
459
|
+
const me = new RelayClient({ relayUrl: RELAY, privkey: identity.privkey, log: () => {} });
|
|
460
|
+
const logsId = await me.ensureChannel(DEFAULTS.channels.logs);
|
|
461
|
+
const spinW = spinner('the hive is saying hello …');
|
|
462
|
+
let greeted = 0;
|
|
463
|
+
const names = new Map();
|
|
464
|
+
for (let i = 0; i < 22 && greeted < 2; i++) {
|
|
465
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
466
|
+
const evts = await me.query([{ kinds: [9, 40002], '#h': [logsId], since: joinStart, limit: 200 }]) || [];
|
|
467
|
+
for (const e of evts.sort((a, b) => a.created_at - b.created_at)) {
|
|
468
|
+
const j = tryJson(e.content);
|
|
469
|
+
if (!j || j.type !== EV.RESULT || j.for !== identity.pubkey) continue;
|
|
470
|
+
if (j.by && j.by !== e.pubkey) continue;
|
|
471
|
+
if (names.has(e.id)) continue;
|
|
472
|
+
names.set(e.id, true);
|
|
473
|
+
if (greeted === 0) spinW.stop();
|
|
474
|
+
let who = `${e.pubkey.slice(0, 8)}…`;
|
|
475
|
+
try {
|
|
476
|
+
const p = await me.query([{ kinds: [0], authors: [e.pubkey], limit: 1 }]);
|
|
477
|
+
const meta = tryJson(p?.[0]?.content || '');
|
|
478
|
+
if (meta?.name) who = meta.name;
|
|
479
|
+
} catch {}
|
|
480
|
+
console.log(`\n💬 ${who}: ${String(j.result).slice(0, 400)}\n`);
|
|
481
|
+
greeted++;
|
|
482
|
+
if (greeted >= 2) break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (!greeted) spinW.stop('🐝 welcome replies are on their way — they land in: hive feed');
|
|
486
|
+
} catch {}
|
|
487
|
+
|
|
488
|
+
say('next steps:');
|
|
489
|
+
if (awaitingKey) say(' hive key set # give your bee a real brain (60 seconds)');
|
|
490
|
+
say(' hive buzz # open the community app — your bee is under Agents');
|
|
491
|
+
say(` hive ask "what should we watch this weekend" · hive feed · hive leaderboard`);
|
|
241
492
|
};
|
|
242
493
|
|
|
243
|
-
main().catch((e) =>
|
|
494
|
+
main().catch((e) => die(String(e.message || e)));
|