nebula-treasury 0.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.
Files changed (53) hide show
  1. package/README.md +39 -0
  2. package/bin/nebula +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_unlock.ts +66 -0
  6. package/src/commands/chat-telegram.ts +398 -0
  7. package/src/commands/chat.tsx +1293 -0
  8. package/src/commands/drain.ts +90 -0
  9. package/src/commands/gateway-logs.ts +49 -0
  10. package/src/commands/gateway-run.ts +42 -0
  11. package/src/commands/gateway-start.ts +216 -0
  12. package/src/commands/gateway-status.ts +90 -0
  13. package/src/commands/gateway-stop.ts +133 -0
  14. package/src/commands/gateway.ts +101 -0
  15. package/src/commands/identity.ts +178 -0
  16. package/src/commands/init/cost.ts +40 -0
  17. package/src/commands/init/funding-gate.ts +64 -0
  18. package/src/commands/init/model-picker.ts +25 -0
  19. package/src/commands/init/operator-picker.ts +233 -0
  20. package/src/commands/init/telegram-step.ts +245 -0
  21. package/src/commands/init/wizard-state.ts +94 -0
  22. package/src/commands/init.ts +439 -0
  23. package/src/commands/logs.ts +37 -0
  24. package/src/commands/model.ts +48 -0
  25. package/src/commands/pairing-approve.ts +65 -0
  26. package/src/commands/pairing-clear.ts +39 -0
  27. package/src/commands/pairing-list.ts +55 -0
  28. package/src/commands/pairing-revoke.ts +49 -0
  29. package/src/commands/pairing.ts +81 -0
  30. package/src/commands/status.ts +44 -0
  31. package/src/commands/telegram-remove.ts +62 -0
  32. package/src/commands/telegram-setup.ts +64 -0
  33. package/src/commands/telegram-status.ts +87 -0
  34. package/src/commands/telegram.ts +44 -0
  35. package/src/config/load.ts +35 -0
  36. package/src/config/render.ts +99 -0
  37. package/src/index.ts +153 -0
  38. package/src/ui/app.tsx +673 -0
  39. package/src/ui/approval-summary.ts +32 -0
  40. package/src/ui/markdown-parse.ts +219 -0
  41. package/src/ui/markdown.tsx +37 -0
  42. package/src/ui/state.ts +181 -0
  43. package/src/util/bootstrap-mode.ts +25 -0
  44. package/src/util/bootstrap-progress-box.ts +378 -0
  45. package/src/util/cli-version.ts +28 -0
  46. package/src/util/format.ts +11 -0
  47. package/src/util/gateway-spawn.ts +125 -0
  48. package/src/util/gateway-version.ts +154 -0
  49. package/src/util/github-releases.ts +79 -0
  50. package/src/util/profile-key.ts +25 -0
  51. package/src/util/ref-resolver.ts +55 -0
  52. package/src/util/silence-console.ts +40 -0
  53. package/src/util/telegram-secrets.ts +218 -0
@@ -0,0 +1,439 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { cancel, confirm, intro, isCancel, note, outro, select, spinner } from '@clack/prompts'
5
+ import {
6
+ NETWORK_CHAIN_ID,
7
+ NETWORK_RPC,
8
+ type NebulaNetwork,
9
+ OPERATOR_BLOB_SCOPES,
10
+ type OperatorSessionKeys,
11
+ agentPaths,
12
+ buildOperatorSession,
13
+ defineConfig,
14
+ generateAgentWallet,
15
+ getGasPriceWithFloor,
16
+ placeholderAgentId,
17
+ precomputeAllScopes,
18
+ saveKeystoreLocally,
19
+ waitForReceiptResilient,
20
+ writeOperatorSession,
21
+ } from 'nebula-ai-core'
22
+ import { type Address, type Hex, formatEther, hexToBytes, parseEther } from 'viem'
23
+ import { writeConfigTs } from '../config/render'
24
+ import { withSilencedConsole } from '../util/silence-console'
25
+ import { estimateCosts, renderCostSummary } from './init/cost'
26
+ import { fundingGate } from './init/funding-gate'
27
+ import { pickBrainModel } from './init/model-picker'
28
+ import { pickOperatorSigner } from './init/operator-picker'
29
+ import { initialWizardState, updateWizardState, writeWizardState } from './init/wizard-state'
30
+
31
+ export async function runInit(opts?: { cwd?: string; resume?: boolean }): Promise<void> {
32
+ const configPath = agentPaths.config
33
+
34
+ intro('nebula init')
35
+
36
+ if (existsSync(configPath) && !opts?.resume) {
37
+ const choice = (await select({
38
+ message: `${configPath} exists`,
39
+ options: [
40
+ { value: 'overwrite', label: 'Start fresh (overwrite)' },
41
+ { value: 'cancel', label: 'Cancel' },
42
+ ],
43
+ initialValue: 'cancel',
44
+ })) as 'overwrite' | 'cancel' | symbol
45
+ if (isCancel(choice) || choice === 'cancel') {
46
+ cancel('Aborted.')
47
+ return
48
+ }
49
+ }
50
+
51
+ // ─── Phase A: local prompts (no chain, no wallet) ───────────────────────
52
+
53
+ const network = (await select({
54
+ message: 'Which Mantle network?',
55
+ options: [
56
+ { value: 'mantle-mainnet' as NebulaNetwork, label: 'Mantle mainnet (5000)' },
57
+ { value: 'mantle-testnet' as NebulaNetwork, label: 'Mantle Sepolia testnet (5003)' },
58
+ ],
59
+ initialValue: 'mantle-mainnet' as NebulaNetwork,
60
+ })) as NebulaNetwork
61
+ if (isCancel(network)) {
62
+ cancel('Aborted.')
63
+ return
64
+ }
65
+
66
+ // The agent always runs locally: a harness on this machine, always-on while
67
+ // the CLI (or the local gateway daemon) is open. The remote compute-
68
+ // marketplace deploy target was removed.
69
+ const deployTarget = 'local' as const
70
+
71
+ // SANN `.nebula.0g` name service was removed (0G-only); the agent is now
72
+ // local-identity. No subname prompt or on-chain registration.
73
+ const requestedSubname: string | null = null
74
+
75
+ const modelPick = await pickBrainModel({ network })
76
+ if (!modelPick) {
77
+ const keepGoing = await confirm({
78
+ message: 'Model catalog unavailable; continue and pick later?',
79
+ initialValue: true,
80
+ })
81
+ if (isCancel(keepGoing) || !keepGoing) {
82
+ cancel('Aborted.')
83
+ return
84
+ }
85
+ }
86
+
87
+ // Compute-ledger deposit prompt removed with the decentralized-compute
88
+ // backend. The agent's LLM is an API-key model (OPENAI_API_KEY / NEBULA_LLM_*),
89
+ // so there is no on-chain compute ledger to fund at init time.
90
+ const ledgerSize = 0
91
+
92
+ // ─── Phase B: wallet gate ────────────────────────────────────────────────
93
+
94
+ const picked = await pickOperatorSigner({ network })
95
+ if (!picked) return
96
+ const { signer: operator, hint: operatorHint } = picked
97
+
98
+ const sConnect = spinner()
99
+ sConnect.start(`Connecting via ${operator.source}`)
100
+ let operatorAddress: Address
101
+ try {
102
+ operatorAddress = await operator.address()
103
+ sConnect.stop(`operator: ${operatorAddress}`)
104
+ } catch (e) {
105
+ sConnect.stop(`connection failed: ${(e as Error).message.slice(0, 140)}`)
106
+ await operator.close?.()
107
+ return
108
+ }
109
+
110
+ const costs = estimateCosts({
111
+ ledgerSizeOg: ledgerSize,
112
+ withSubname: !!requestedSubname,
113
+ deployTarget,
114
+ })
115
+ note(renderCostSummary(costs), 'cost summary (Mantle ~$0.50)')
116
+
117
+ const publicClient = await operator.publicClient(network)
118
+ const operatorBalance = await publicClient.getBalance({ address: operatorAddress })
119
+
120
+ let skipLedger = false
121
+ if (operatorBalance < costs.totalOperator) {
122
+ const need = costs.totalOperator - operatorBalance
123
+ note(
124
+ `Operator balance ${formatEther(operatorBalance)} Mantle, need ${formatEther(need)} Mantle more.`,
125
+ 'insufficient funds',
126
+ )
127
+ const gate = await fundingGate({
128
+ publicClient,
129
+ operatorAddress,
130
+ requiredOg: costs.totalOperator,
131
+ })
132
+ if (gate.kind === 'cancel') {
133
+ await operator.close?.()
134
+ return
135
+ }
136
+ if (gate.kind === 'skip-ledger') skipLedger = true
137
+ }
138
+
139
+ const proceed = await confirm({ message: 'Proceed?', initialValue: true })
140
+ if (isCancel(proceed) || !proceed) {
141
+ cancel('Aborted.')
142
+ await operator.close?.()
143
+ return
144
+ }
145
+
146
+ // ─── Phase C: execute with Pattern B state tracking ─────────────────────
147
+
148
+ const agent = generateAgentWallet()
149
+ const provisionalAgentId = placeholderAgentId(agent.address)
150
+ const provisional = agentPaths.agent(provisionalAgentId)
151
+ await mkdir(provisional.dir, { recursive: true })
152
+
153
+ await writeWizardState(provisional.dir, {
154
+ ...initialWizardState(agent.address, network),
155
+ })
156
+
157
+ // Plain-EOA identity: the agent EOA is the identity. No iNFT mint, no
158
+ // on-chain anchoring — just a local encrypted keystore.
159
+ const finalAgentId = provisionalAgentId
160
+ const paths = provisional
161
+
162
+ // v0.23.1: derive BOTH operator-scope keys (keystore + profile) in parallel
163
+ // up front, then reuse them everywhere. This is the single "two signatures
164
+ // back to back" moment in the wizard: keystore scope (for the encrypted
165
+ // privkey blob) + profile scope (for the operator-private user-partition
166
+ // memory slot). Folding profile derivation into init removes the v0.23.0
167
+ // need for `nebula profile init` as a follow-up command.
168
+ const sKeys = spinner()
169
+ sKeys.start('Deriving operator scope keys (may prompt twice: keystore + profile)')
170
+ let operatorKeys: OperatorSessionKeys
171
+ let keystoreKeyBuf: Buffer
172
+ try {
173
+ operatorKeys = await precomputeAllScopes(operator, agent.address as Address, [
174
+ OPERATOR_BLOB_SCOPES.PROFILE,
175
+ ])
176
+ keystoreKeyBuf = Buffer.from(hexToBytes(operatorKeys.keystore))
177
+ sKeys.stop('scope keys derived')
178
+ } catch (e) {
179
+ sKeys.stop(`scope key derive failed: ${(e as Error).message.slice(0, 160)}`)
180
+ cancel('Aborted (operator signature required for keystore + profile scopes).')
181
+ await operator.close?.()
182
+ return
183
+ }
184
+
185
+ // Pass the already-derived keystoreKey so saveKeystoreLocally skips
186
+ // signing again. Save BEFORE funding the agent EOA per
187
+ // `feedback-init-must-save-keystore-before-funding.md`.
188
+ const sLocal = spinner()
189
+ sLocal.start('Encrypting agent keystore to operator wallet (local insurance)')
190
+ let encryptedBytes: Uint8Array
191
+ try {
192
+ const saved = await saveKeystoreLocally({
193
+ agentAddress: agent.address as Address,
194
+ agentPrivkey: agent.privkeyHex as Hex,
195
+ cachePath: paths.keystore,
196
+ precomputedKey: keystoreKeyBuf,
197
+ })
198
+ encryptedBytes = saved.bytes
199
+ await updateWizardState(paths.dir, draft => {
200
+ draft.steps.keystoreSaved = true
201
+ })
202
+ sLocal.stop(`keystore saved locally at ${paths.keystore}`)
203
+ } catch (e) {
204
+ sLocal.stop(`local keystore save failed: ${(e as Error).message.slice(0, 120)}`)
205
+ cancel('Aborted before funding (keystore encryption failed).')
206
+ await operator.close?.()
207
+ return
208
+ }
209
+
210
+ const sFund = spinner()
211
+ const fundingAmount = parseEther('0.1') + parseEther(String(ledgerSize))
212
+ sFund.start(`Funding agent ${agent.address} with ${formatEther(fundingAmount)} Mantle`)
213
+ try {
214
+ const opWc = await operator.walletClient(network)
215
+ const opAccount = opWc.account
216
+ if (!opAccount) throw new Error('walletClient is missing default account')
217
+ const fundGasPrice = await getGasPriceWithFloor(publicClient)
218
+ const fundTx = await withSilencedConsole(() =>
219
+ opWc.sendTransaction({
220
+ to: agent.address as Address,
221
+ value: fundingAmount,
222
+ chain: operator.chain(network),
223
+ account: opAccount,
224
+ maxFeePerGas: fundGasPrice,
225
+ maxPriorityFeePerGas: fundGasPrice,
226
+ }),
227
+ )
228
+ await waitForReceiptResilient(publicClient, fundTx)
229
+ await updateWizardState(paths.dir, draft => {
230
+ draft.steps.agentFundedTx = fundTx
231
+ })
232
+ sFund.stop(`funded (tx ${fundTx})`)
233
+ } catch (e) {
234
+ sFund.stop(`fund failed: ${(e as Error).message}`)
235
+ await operator.close?.()
236
+ return
237
+ }
238
+
239
+ // The encrypted keystore is on disk (saved before funding). The operator
240
+ // wallet can always decrypt + recover the agent — no on-chain anchor needed.
241
+ void encryptedBytes
242
+
243
+ // v0.23.1: cache the operator scope keys to `.operator-session` so:
244
+ // - First `nebula` chat does NOT re-prompt Touch ID (`gateway-start` will
245
+ // find both keystore + profile scopes already cached and skip
246
+ // re-derivation).
247
+ // - First sync after init can encrypt + anchor the PROFILE slot
248
+ // transparently — operator never needs to run `nebula profile init`.
249
+ // requiredScopesForAgent now returns ['keystore', 'nebula-profile-v1']
250
+ // because seedStarterMemoryFiles just wrote user/profile.md.
251
+ try {
252
+ const sess = buildOperatorSession({ agent: agent.address as Address, keys: operatorKeys })
253
+ writeOperatorSession(finalAgentId, sess)
254
+ } catch (e) {
255
+ console.warn(`operator-session write skipped: ${(e as Error).message.slice(0, 160)}`)
256
+ }
257
+
258
+ // Compute-ledger prepay step removed with the decentralized-compute backend
259
+ // (Nebula uses an API-key LLM; no per-provider on-chain ledger to fund).
260
+
261
+ // SANN `.nebula.0g` registration was removed (0G-only); the agent is
262
+ // local-identity, so there is no on-chain subname to claim.
263
+ const registeredSubname: string | null = null
264
+
265
+ // Seed canonical memory starter files. With no SANN subname the seed uses
266
+ // the generic "I am nebula" template.
267
+ await seedStarterMemoryFiles({
268
+ paths,
269
+ network,
270
+ contractAddress: '0x0000000000000000000000000000000000000000' as Address,
271
+ tokenId: 0n,
272
+ agentAddress: agent.address as Address,
273
+ operatorAddress,
274
+ brainProvider: modelPick?.provider ?? null,
275
+ brainModel: modelPick?.model ?? null,
276
+ subname: registeredSubname,
277
+ })
278
+
279
+ // v0.24.4: Phase E (Telegram bot setup) MUST run before Phase 11 (sandbox
280
+ // provision) so the sandbox handoff envelope can ship `telegram-secrets`
281
+ // and the listener boots active. Previously Phase E ran AFTER provision and
282
+ // the sandbox booted with `listeners.telegram: disabled`, forcing the
283
+ // operator to `nebula upgrade --in-place` post-init to re-ship secrets.
284
+ let telegramConfigured: { botUsername: string; mode: string } | null = null
285
+ {
286
+ const tgChoice = await confirm({
287
+ message: 'Configure a Telegram bot for this agent now? (recommended)',
288
+ initialValue: true,
289
+ })
290
+ if (!isCancel(tgChoice) && tgChoice === true) {
291
+ try {
292
+ const { runTelegramStep } = await import('./init/telegram-step')
293
+ const tgResult = await runTelegramStep({
294
+ signer: operator,
295
+ agentId: finalAgentId,
296
+ agentAddress: agent.address as Address,
297
+ configPath,
298
+ // Synthetic partial cfg — caller writes the final cfg below. Pass
299
+ // skipConfigWrite=true so telegram-step doesn't touch disk.
300
+ config: { plugins: [], subname: registeredSubname } as never,
301
+ network,
302
+ skipConfigWrite: true,
303
+ })
304
+ if (tgResult.configured && tgResult.botUsername && tgResult.modeUsed) {
305
+ telegramConfigured = {
306
+ botUsername: tgResult.botUsername,
307
+ mode: tgResult.modeUsed,
308
+ }
309
+ // v0.24.3: append TELEGRAM key to `.operator-session` so the gateway
310
+ // daemon auto-spawns on first chat without re-prompting Touch ID.
311
+ if (tgResult.telegramScopeKeyHex) {
312
+ try {
313
+ const sess = buildOperatorSession({
314
+ agent: agent.address as Address,
315
+ keys: {
316
+ ...operatorKeys,
317
+ [OPERATOR_BLOB_SCOPES.TELEGRAM]: tgResult.telegramScopeKeyHex,
318
+ },
319
+ })
320
+ writeOperatorSession(finalAgentId, sess)
321
+ } catch (e) {
322
+ note(
323
+ `operator-session rewrite skipped: ${(e as Error).message.slice(0, 160)}\nRun \`nebula telegram setup\` later to re-derive the TG scope key.`,
324
+ 'telegram (non-fatal)',
325
+ )
326
+ }
327
+ }
328
+ }
329
+ } catch (e) {
330
+ note(
331
+ `Telegram step failed: ${(e as Error).message.slice(0, 200)}\nIdentity is safe. Re-run \`nebula telegram setup\` later.`,
332
+ 'non-fatal',
333
+ )
334
+ }
335
+ }
336
+ }
337
+
338
+ // ─── Write final config ─────────────────────────────────────────────────
339
+
340
+ const cfg = defineConfig({
341
+ identity: {
342
+ operator: operatorAddress,
343
+ agent: agent.address,
344
+ },
345
+ network,
346
+ storage: { network },
347
+ brain: {
348
+ provider: modelPick?.provider ?? null,
349
+ model: modelPick?.model ?? null,
350
+ },
351
+ plugins: telegramConfigured ? ['onchain', 'system', 'telegram'] : ['onchain', 'system'],
352
+ tools: {},
353
+ imports: { claudeCode: true },
354
+ operator: operatorHint,
355
+ })
356
+ await writeConfigTs(configPath, cfg, {
357
+ header: '// Regenerated by `nebula init`. Edit freely; type-safe.',
358
+ })
359
+
360
+ await operator.close?.()
361
+
362
+ // ─── Phase D: summary ───────────────────────────────────────────────────
363
+
364
+ const lines = [
365
+ '',
366
+ ` agent id ${finalAgentId}`,
367
+ ` agent EOA ${agent.address}`,
368
+ ` operator ${operatorAddress} (source: ${operatorHint.source})`,
369
+ ` network ${network} (${NETWORK_RPC[network]})`,
370
+ ` chain id ${NETWORK_CHAIN_ID[network]}`,
371
+ ` config ${configPath}`,
372
+ ` keystore ${paths.keystore} (encrypted to operator wallet)`,
373
+ ]
374
+ if (modelPick) lines.push(` brain ${modelPick.model ?? '?'} (${modelPick.provider})`)
375
+ if (!skipLedger) lines.push(` ledger ${ledgerSize} Mantle`)
376
+ if (telegramConfigured) {
377
+ lines.push(` bot @${telegramConfigured.botUsername} (mode: ${telegramConfigured.mode})`)
378
+ }
379
+ const nextSteps = telegramConfigured
380
+ ? 'Next: `nebula` to chat · DM the bot on Telegram · `nebula status` for health'
381
+ : 'Next: `nebula` to chat · `nebula telegram setup` for the bot · `nebula topup` to add funds'
382
+ lines.push('', nextSteps)
383
+ outro(lines.join('\n'))
384
+ }
385
+
386
+ interface SeedStarterOpts {
387
+ paths: ReturnType<typeof agentPaths.agent>
388
+ network: NebulaNetwork
389
+ contractAddress: Address
390
+ tokenId: bigint
391
+ agentAddress: Address
392
+ operatorAddress: Address
393
+ brainProvider: string | null
394
+ brainModel: string | null
395
+ /**
396
+ * Operator-chosen SANN label (e.g. "chou" for `chou.nebula.0g`). Threaded
397
+ * into identity + persona so the agent introduces itself by name on the
398
+ * very first turn instead of the generic "I am Nebula" template.
399
+ */
400
+ subname: string | null
401
+ }
402
+
403
+ /**
404
+ * Seed `MEMORY.md`, `/agent/identity.md`, `/agent/persona.md`, and
405
+ * `/user/profile.md` immediately after mint so the per-turn sync manager
406
+ * has real content for the identity / persona / memory-index slots on the
407
+ * first chat turn. Without this, those slots stay bootstrap-placeholder
408
+ * forever (gap discovered during the Phase 6.7 stress test).
409
+ */
410
+ async function seedStarterMemoryFiles(opts: SeedStarterOpts): Promise<void> {
411
+ const memDir = opts.paths.memoryDir
412
+ const agentMem = `${memDir}/agent`
413
+ const userMem = `${memDir}/user`
414
+ await mkdir(agentMem, { recursive: true })
415
+ await mkdir(userMem, { recursive: true })
416
+
417
+ const now = new Date().toISOString().slice(0, 10)
418
+ const displayName = opts.subname ?? 'nebula'
419
+ const fullName = opts.subname ?? null
420
+ const identityTitle = opts.subname
421
+ ? `# ${opts.subname} identity (nebula harness)`
422
+ : '# Nebula identity'
423
+ const subnameLine = fullName ? `- Name: ${fullName}\n` : ''
424
+ const personaIntro = fullName
425
+ ? `I am ${displayName} (${fullName}), a sovereign agent running on the nebula harness on Mantle.`
426
+ : 'I am nebula, a sovereign agent harness on Mantle.'
427
+ const identity = `---\nname: identity\ndescription: Auto-written agent identity facts.\ntype: agent-identity\n---\n${identityTitle}\n\n- Name: ${displayName}\n${subnameLine}- iNFT: #${opts.tokenId.toString()} at ${opts.contractAddress} (${opts.network})\n- Agent EOA: ${opts.agentAddress}\n- Operator: ${opts.operatorAddress}\n- Minted: ${now}\n${opts.brainProvider ? `- Brain provider: ${opts.brainProvider}\n` : ''}${opts.brainModel ? `- Brain model: ${opts.brainModel}\n` : ''}`
428
+ const persona = `---\nname: persona\ndescription: Voice + behavior style.\ntype: agent-persona\n---\n# Persona\n\n${personaIntro} I anchor my state on chain every turn, decrypt my keystore via my operator wallet at session start, and use Mantle Compute (TEE-attested) for reasoning. I am direct, concise, and factual. When asked who I am, I introduce myself as ${displayName}.\n`
429
+ const profile =
430
+ '---\nname: profile\ndescription: User profile (operator-scoped, never anchored on chain).\ntype: user\n---\n# User profile\n\n(empty, fills as we chat)\n'
431
+
432
+ await writeFile(join(agentMem, 'identity.md'), identity, 'utf8')
433
+ await writeFile(join(agentMem, 'persona.md'), persona, 'utf8')
434
+ await writeFile(join(userMem, 'profile.md'), profile, 'utf8')
435
+
436
+ // Seed an empty MEMORY.md so per-turn sync has something to anchor and the
437
+ // brain's first turn sees a parseable index.
438
+ await writeFile(opts.paths.memoryIndex, '# Nebula Memory Index\n\n', 'utf8')
439
+ }
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { agentPaths } from 'nebula-ai-core'
3
+ import { pickDefaultAgent } from './_agents'
4
+
5
+ export async function runLogs(opts: { agent?: string; tail?: number } = {}): Promise<void> {
6
+ // Local mode: read from agentPaths
7
+ const id = opts.agent ?? (await pickDefaultAgent())
8
+ if (!id) {
9
+ console.log('No agents found in ~/.nebula/agents. Run `nebula init` first.')
10
+ process.exit(1)
11
+ }
12
+ const path = agentPaths.agent(id).activityLog
13
+
14
+ let raw: string
15
+ try {
16
+ raw = await readFile(path, 'utf8')
17
+ } catch (e) {
18
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
19
+ console.log(`No activity log at ${path}`)
20
+ return
21
+ }
22
+ throw e
23
+ }
24
+
25
+ const lines = raw.trimEnd().split('\n').filter(Boolean)
26
+ const slice = opts.tail ? lines.slice(-opts.tail) : lines
27
+ for (const line of slice) {
28
+ try {
29
+ const entry = JSON.parse(line) as { ts: number; kind: string; data: unknown }
30
+ const d = new Date(entry.ts).toISOString()
31
+ const body = JSON.stringify(entry.data)
32
+ console.log(`${d} ${entry.kind.padEnd(16)} ${body.slice(0, 200)}`)
33
+ } catch {
34
+ console.log(line)
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ import { cancel, intro, outro } from '@clack/prompts'
2
+ import { defineConfig } from 'nebula-ai-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+ import { writeConfigTs } from '../config/render'
5
+ import { pickBrainModel } from './init/model-picker'
6
+
7
+ /**
8
+ * `nebula model` — re-pick the brain provider/model. Updates the persisted
9
+ * config so subsequent `nebula` (chat) sessions use the new choice.
10
+ *
11
+ * The TUI also exposes `/model` as a slash command for in-session switching;
12
+ * see `chat.tsx`.
13
+ */
14
+ export async function runModel(): Promise<void> {
15
+ intro('nebula model')
16
+
17
+ const loaded = await findAndLoadConfig()
18
+ if (!loaded) {
19
+ cancel('No nebula.config.ts found. Run `nebula init` first.')
20
+ return
21
+ }
22
+ const { config } = loaded
23
+
24
+ const pick = await pickBrainModel({ network: config.network })
25
+ if (!pick) {
26
+ cancel('No model picked.')
27
+ return
28
+ }
29
+
30
+ const updated = defineConfig({
31
+ ...config,
32
+ brain: { provider: pick.provider, model: pick.model },
33
+ })
34
+ await writeConfigTs(loaded.path, updated, {
35
+ header: '// Updated by `nebula model`. Edit freely; type-safe.',
36
+ })
37
+
38
+ outro(
39
+ [
40
+ '',
41
+ ` brain ${pick.model ?? '?'}`,
42
+ ` provider ${pick.provider}`,
43
+ ` config ${loaded.path}`,
44
+ '',
45
+ 'Next chat session will use the new brain.',
46
+ ].join('\n'),
47
+ )
48
+ }
@@ -0,0 +1,65 @@
1
+ import {
2
+ PAIRING_ALPHABET,
3
+ PAIRING_CODE_LENGTH,
4
+ PairingStore,
5
+ agentPaths,
6
+ placeholderAgentId,
7
+ } from 'nebula-ai-core'
8
+ import { findAndLoadConfig } from '../config/load'
9
+
10
+ export interface RunPairingApproveOpts {
11
+ platform: string
12
+ code: string
13
+ }
14
+
15
+ export async function runPairingApprove(opts: RunPairingApproveOpts): Promise<void> {
16
+ const normalized = opts.code.toUpperCase().trim()
17
+ if (normalized.length !== PAIRING_CODE_LENGTH) {
18
+ console.error(
19
+ `Invalid pairing code: expected ${PAIRING_CODE_LENGTH} characters, got ${normalized.length}`,
20
+ )
21
+ process.exit(1)
22
+ }
23
+ for (const ch of normalized) {
24
+ if (!PAIRING_ALPHABET.includes(ch)) {
25
+ console.error(`Invalid pairing code: contains '${ch}' which is not in the pairing alphabet`)
26
+ process.exit(1)
27
+ }
28
+ }
29
+
30
+ const loaded = await findAndLoadConfig()
31
+ if (!loaded) {
32
+ console.error('No nebula.config.ts found. Run `nebula init` first.')
33
+ process.exit(1)
34
+ }
35
+ const { config } = loaded
36
+ if (!config.identity.agent) {
37
+ console.error('Config has no agent. Run `nebula init` first.')
38
+ process.exit(1)
39
+ }
40
+
41
+ // Operate directly on the host's PairingStore (same path as the daemon
42
+ // process when NEBULA_FORCE_EMBEDDED or local-mode chat.tsx).
43
+ const agentId = placeholderAgentId(config.identity.agent)
44
+ const dir = agentPaths.agent(agentId).pairingDir
45
+ const store = new PairingStore({ dir })
46
+
47
+ const result = store.approveCode(opts.platform, normalized)
48
+ if (!result) {
49
+ if (store.isLockedOut(opts.platform)) {
50
+ console.error(
51
+ `Platform '${opts.platform}' is locked out due to repeated bad codes. Wait 1 hour and try again.`,
52
+ )
53
+ process.exit(1)
54
+ }
55
+ console.error(`Code ${normalized} not found in pending list. Maybe it expired (1h TTL).`)
56
+ process.exit(1)
57
+ }
58
+
59
+ console.log(
60
+ `✓ Approved on ${opts.platform}: id=${result.userId}${
61
+ result.userName ? ` (@${result.userName})` : ''
62
+ }`,
63
+ )
64
+ console.log('The user can now DM the bot. Their next message will be processed.')
65
+ }
@@ -0,0 +1,39 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
2
+ import { PairingStore, agentPaths, placeholderAgentId } from 'nebula-ai-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+
5
+ export interface RunPairingClearOpts {
6
+ platform?: string
7
+ yes?: boolean
8
+ }
9
+
10
+ export async function runPairingClear(opts: RunPairingClearOpts): Promise<void> {
11
+ const loaded = await findAndLoadConfig()
12
+ if (!loaded) {
13
+ console.error('No nebula.config.ts found. Run `nebula init` first.')
14
+ process.exit(1)
15
+ }
16
+ const { config } = loaded
17
+ if (!config.identity.agent) {
18
+ console.error('Config has no agent. Run `nebula init` first.')
19
+ process.exit(1)
20
+ }
21
+ const agentId = placeholderAgentId(config.identity.agent)
22
+ const dir = agentPaths.agent(agentId).pairingDir
23
+ const store = new PairingStore({ dir })
24
+
25
+ if (!opts.yes) {
26
+ const target = opts.platform ? `${opts.platform} pending` : 'ALL pending pairing codes'
27
+ const ok = await confirm({
28
+ message: `Clear ${target}?`,
29
+ initialValue: false,
30
+ })
31
+ if (isCancel(ok) || !ok) {
32
+ console.log('Aborted.')
33
+ return
34
+ }
35
+ }
36
+
37
+ const count = store.clearPending(opts.platform)
38
+ console.log(`✓ Cleared ${count} pending pairing code${count === 1 ? '' : 's'}`)
39
+ }
@@ -0,0 +1,55 @@
1
+ import { PairingStore, agentPaths, placeholderAgentId } from 'nebula-ai-core'
2
+ import { findAndLoadConfig } from '../config/load'
3
+
4
+ export interface RunPairingListOpts {
5
+ platform?: string
6
+ }
7
+
8
+ export async function runPairingList(opts: RunPairingListOpts): Promise<void> {
9
+ const store = await openPairingStore()
10
+ if (!store) return
11
+
12
+ const pending = store.listPending(opts.platform)
13
+ const approved = store.listApproved(opts.platform)
14
+
15
+ const pendingTitle = opts.platform ? `Pending (${opts.platform})` : 'Pending'
16
+ console.log(`\n${pendingTitle} (1h TTL):`)
17
+ if (pending.length === 0) {
18
+ console.log(' (none)')
19
+ } else {
20
+ for (const p of pending) {
21
+ const userLabel = p.userName ? `@${p.userName}` : '(unknown)'
22
+ const idLabel = `id=${p.userId}`
23
+ console.log(` [${p.platform}] ${p.code} ${userLabel} ${idLabel} age=${p.ageMinutes}m`)
24
+ }
25
+ }
26
+
27
+ const approvedTitle = opts.platform ? `Approved (${opts.platform})` : 'Approved'
28
+ console.log(`\n${approvedTitle}:`)
29
+ if (approved.length === 0) {
30
+ console.log(' (none)')
31
+ } else {
32
+ for (const a of approved) {
33
+ const userLabel = a.userName ? `@${a.userName}` : '(unknown)'
34
+ const idLabel = `id=${a.userId}`
35
+ console.log(` [${a.platform}] ${userLabel} ${idLabel}`)
36
+ }
37
+ }
38
+ console.log()
39
+ }
40
+
41
+ async function openPairingStore(): Promise<PairingStore | null> {
42
+ const loaded = await findAndLoadConfig()
43
+ if (!loaded) {
44
+ console.error('No nebula.config.ts found. Run `nebula init` first.')
45
+ return null
46
+ }
47
+ const { config } = loaded
48
+ if (!config.identity.agent) {
49
+ console.error('Config has no agent. Run `nebula init` first.')
50
+ return null
51
+ }
52
+ const agentId = placeholderAgentId(config.identity.agent)
53
+ const dir = agentPaths.agent(agentId).pairingDir
54
+ return new PairingStore({ dir })
55
+ }