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.
- package/README.md +39 -0
- package/bin/nebula +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_unlock.ts +66 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1293 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +216 -0
- package/src/commands/gateway-status.ts +90 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/identity.ts +178 -0
- package/src/commands/init/cost.ts +40 -0
- package/src/commands/init/funding-gate.ts +64 -0
- package/src/commands/init/model-picker.ts +25 -0
- package/src/commands/init/operator-picker.ts +233 -0
- package/src/commands/init/telegram-step.ts +245 -0
- package/src/commands/init/wizard-state.ts +94 -0
- package/src/commands/init.ts +439 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +44 -0
- package/src/commands/telegram-remove.ts +62 -0
- package/src/commands/telegram-setup.ts +64 -0
- package/src/commands/telegram-status.ts +87 -0
- package/src/commands/telegram.ts +44 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +153 -0
- package/src/ui/app.tsx +673 -0
- package/src/ui/approval-summary.ts +32 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.ts +181 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.ts +218 -0
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { spinner } from '@clack/prompts'
|
|
4
|
+
import {
|
|
5
|
+
ActivityLog,
|
|
6
|
+
type BrainMessage,
|
|
7
|
+
type ClaudeAgent,
|
|
8
|
+
type ClaudeCommand,
|
|
9
|
+
HookBus,
|
|
10
|
+
type Listener,
|
|
11
|
+
LocalBackend,
|
|
12
|
+
McpManager,
|
|
13
|
+
type NebulaConfig,
|
|
14
|
+
OpenAIBrain,
|
|
15
|
+
type PermissionDecision,
|
|
16
|
+
type PermissionMode,
|
|
17
|
+
type PermissionRequest,
|
|
18
|
+
PermissionService,
|
|
19
|
+
type PostToolCallContext,
|
|
20
|
+
type PreToolCallContext,
|
|
21
|
+
type PreToolCallResult,
|
|
22
|
+
type SandboxBackend,
|
|
23
|
+
type SkillRef,
|
|
24
|
+
ToolRegistry,
|
|
25
|
+
type VisionInferFn,
|
|
26
|
+
agentPaths,
|
|
27
|
+
applyPerms,
|
|
28
|
+
applyYolo,
|
|
29
|
+
buildFrozenPrefix,
|
|
30
|
+
createFsHistoryPersist,
|
|
31
|
+
decodeKeystoreBytes,
|
|
32
|
+
decryptAgentKey,
|
|
33
|
+
detectFetchEscalation,
|
|
34
|
+
discoverClaudeExtras,
|
|
35
|
+
discoverMcpServers,
|
|
36
|
+
explorerTxUrl,
|
|
37
|
+
loadPlugins,
|
|
38
|
+
makeMemoryListTool,
|
|
39
|
+
makeMemoryReadTool,
|
|
40
|
+
makeMemorySaveTool,
|
|
41
|
+
makeSandboxBackend,
|
|
42
|
+
makeToolSearchTool,
|
|
43
|
+
makeViemClients,
|
|
44
|
+
matchSkillTriggers,
|
|
45
|
+
newEventId,
|
|
46
|
+
placeholderAgentId,
|
|
47
|
+
readIndexFile,
|
|
48
|
+
runEscalation,
|
|
49
|
+
scanSkills,
|
|
50
|
+
} from 'nebula-ai-core'
|
|
51
|
+
import {
|
|
52
|
+
ONCHAIN_GUIDANCE,
|
|
53
|
+
type OnchainRuntimeContext,
|
|
54
|
+
policyFromEnv,
|
|
55
|
+
policyRequiresApprovalForCall,
|
|
56
|
+
} from 'nebula-ai-plugin-onchain'
|
|
57
|
+
import {
|
|
58
|
+
TELEGRAM_GUIDANCE,
|
|
59
|
+
type TelegramApprovalBridge,
|
|
60
|
+
type TelegramRuntimeContext,
|
|
61
|
+
formatInboundPreview as formatTelegramInboundPreview,
|
|
62
|
+
} from 'nebula-ai-plugin-telegram'
|
|
63
|
+
import { type Address, type Hex, formatEther } from 'viem'
|
|
64
|
+
import { findAndLoadConfig } from '../config/load'
|
|
65
|
+
import { writeConfigTs } from '../config/render'
|
|
66
|
+
import { shortAddr } from '../util/format'
|
|
67
|
+
import { loadTelegramSecrets, telegramSecretsExist } from '../util/telegram-secrets'
|
|
68
|
+
import {
|
|
69
|
+
type TelegramDispatchSlot,
|
|
70
|
+
buildTelegramDispatch,
|
|
71
|
+
buildTelegramRuntimeContext,
|
|
72
|
+
} from './chat-telegram'
|
|
73
|
+
import { loadOrPickOperatorSigner } from './init/operator-picker'
|
|
74
|
+
|
|
75
|
+
export async function runChat(opts?: { cwd?: string; yolo?: boolean }): Promise<void> {
|
|
76
|
+
const found = await findAndLoadConfig(opts?.cwd)
|
|
77
|
+
if (!found) {
|
|
78
|
+
console.log('No nebula.config.ts found. Run `nebula init` first.')
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
let { config } = found
|
|
82
|
+
const configPath = found.path
|
|
83
|
+
|
|
84
|
+
if (!config.identity.agent) {
|
|
85
|
+
console.log('Config has no agent yet. Re-run `nebula init`.')
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
// Phase 14: if a local gateway daemon is running for this agent (socket
|
|
89
|
+
// present at ~/.nebula/agents/<id>/gateway.sock), route to the same thin
|
|
90
|
+
// client over a unix socket. The TUI no longer holds the runtime — the
|
|
91
|
+
// gateway daemon does. Closing the TUI doesn't stop the listeners.
|
|
92
|
+
//
|
|
93
|
+
// v0.21.5: when no daemon is running but an operator session is fresh,
|
|
94
|
+
// AUTO-SPAWN the daemon as a child process and attach as thin-client.
|
|
95
|
+
// Without this, embedded TUI fallthrough silently disables (a) Telegram
|
|
96
|
+
// pairing-store wiring (no inbound delivery) and (b) AutoTopupManager
|
|
97
|
+
// polling. NEBULA_FORCE_EMBEDDED=1 escape hatch keeps the legacy path
|
|
98
|
+
// available for tests / debugging.
|
|
99
|
+
// Identity is the agent EOA; chat always runs embedded. To bring telegram +
|
|
100
|
+
// pairing online as an always-on daemon, run `nebula gateway start`.
|
|
101
|
+
const agentAddress = config.identity.agent as Address
|
|
102
|
+
const agentId = placeholderAgentId(agentAddress)
|
|
103
|
+
const paths = agentPaths.agent(agentId)
|
|
104
|
+
|
|
105
|
+
const operator = await loadOrPickOperatorSigner({
|
|
106
|
+
network: config.network,
|
|
107
|
+
hint: config.operator,
|
|
108
|
+
})
|
|
109
|
+
if (!operator) {
|
|
110
|
+
console.log('No operator wallet available; cannot decrypt keystore.')
|
|
111
|
+
process.exit(1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sUnlock = spinner()
|
|
115
|
+
sUnlock.start('Decrypting agent keystore via operator wallet')
|
|
116
|
+
let agentPrivkey: Hex
|
|
117
|
+
try {
|
|
118
|
+
// The encrypted agent keystore lives only on disk, decryptable by the
|
|
119
|
+
// operator wallet signature.
|
|
120
|
+
const raw = await readFile(paths.keystore, 'utf8')
|
|
121
|
+
const keystore = decodeKeystoreBytes(new TextEncoder().encode(raw))
|
|
122
|
+
agentPrivkey = (await decryptAgentKey({ signer: operator, agentAddress, keystore })) as Hex
|
|
123
|
+
sUnlock.stop('unlocked (keystore source: local)')
|
|
124
|
+
} catch (e) {
|
|
125
|
+
sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
|
|
126
|
+
await operator.close?.()
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Phase 12: decrypt telegram-secrets blob (if any) using the SAME operator
|
|
131
|
+
// signer we already have unlocked. Avoids a second keychain prompt later.
|
|
132
|
+
// We only attempt this if the operator opted in via `nebula telegram setup`
|
|
133
|
+
// (presence of the encrypted blob); the plugin opt-in is independent and
|
|
134
|
+
// checked again below at plugin filter time.
|
|
135
|
+
let telegramSecrets: Awaited<ReturnType<typeof loadTelegramSecrets>> = null
|
|
136
|
+
const envTgToken = process.env.TELEGRAM_BOT_TOKEN
|
|
137
|
+
if (envTgToken) {
|
|
138
|
+
// Env-configured bot: works without `nebula telegram setup`. TELEGRAM_CHAT_ID
|
|
139
|
+
// (optional) is the sole allowed DM user; blank = open access.
|
|
140
|
+
const envChatId = process.env.TELEGRAM_CHAT_ID
|
|
141
|
+
telegramSecrets = {
|
|
142
|
+
botToken: envTgToken,
|
|
143
|
+
botUsername: process.env.TELEGRAM_USERNAME,
|
|
144
|
+
allowedUserIds: envChatId ? [Number(envChatId)] : [],
|
|
145
|
+
}
|
|
146
|
+
if (!(config.plugins ?? []).includes('telegram')) {
|
|
147
|
+
config = { ...config, plugins: [...(config.plugins ?? []), 'telegram'] }
|
|
148
|
+
}
|
|
149
|
+
} else if (telegramSecretsExist(agentId) && (config.plugins ?? []).includes('telegram')) {
|
|
150
|
+
const sTg = spinner()
|
|
151
|
+
sTg.start('Decrypting telegram secrets')
|
|
152
|
+
try {
|
|
153
|
+
telegramSecrets = await loadTelegramSecrets({ signer: operator, agentAddress, agentId })
|
|
154
|
+
sTg.stop(`telegram unlocked (bot @${telegramSecrets?.botUsername ?? '?'})`)
|
|
155
|
+
} catch (e) {
|
|
156
|
+
sTg.stop(`telegram decrypt failed: ${(e as Error).message.slice(0, 160)}`)
|
|
157
|
+
// Soft-fail: telegram is opt-in. Boot continues without it.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await operator.close?.()
|
|
162
|
+
|
|
163
|
+
if (!config.brain.provider) {
|
|
164
|
+
const updated = await runModelPicker(config, configPath)
|
|
165
|
+
if (!updated) process.exit(1)
|
|
166
|
+
config = updated
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const tools = new ToolRegistry(config.tools)
|
|
170
|
+
tools.register(makeMemorySaveTool({ agentId }) as Parameters<typeof tools.register>[0])
|
|
171
|
+
tools.register(makeMemoryReadTool({ agentId }) as Parameters<typeof tools.register>[0])
|
|
172
|
+
tools.register(makeMemoryListTool({ agentId }) as Parameters<typeof tools.register>[0])
|
|
173
|
+
tools.register(makeToolSearchTool(tools) as Parameters<typeof tools.register>[0])
|
|
174
|
+
|
|
175
|
+
const initialMode: PermissionMode = opts?.yolo ? 'off' : (config.approvals?.mode ?? 'prompt')
|
|
176
|
+
const permission = new PermissionService({ mode: initialMode })
|
|
177
|
+
const hooks = new HookBus()
|
|
178
|
+
|
|
179
|
+
// Plugin failures are reported but do not abort startup; the brain still has
|
|
180
|
+
// memory tools.
|
|
181
|
+
//
|
|
182
|
+
// The dynamic `import()` MUST happen from the CLI package context: that's
|
|
183
|
+
// where the workspace deps `nebula-ai-plugin-*` live. Passing this
|
|
184
|
+
// resolver pins the import site to chat.tsx so bun's resolver finds them.
|
|
185
|
+
// Claude Code extras (commands + agents) discovery happens BEFORE plugin
|
|
186
|
+
// load so delegate.task can surface agents.
|
|
187
|
+
let claudeCommands: ClaudeCommand[] = []
|
|
188
|
+
let claudeAgents: ClaudeAgent[] = []
|
|
189
|
+
try {
|
|
190
|
+
const extras = await discoverClaudeExtras({
|
|
191
|
+
importsClaudeCode: config.imports?.claudeCode ?? true,
|
|
192
|
+
})
|
|
193
|
+
claudeCommands = extras.commands
|
|
194
|
+
claudeAgents = extras.agents
|
|
195
|
+
} catch {
|
|
196
|
+
// Discovery failed; continue without commands/agents.
|
|
197
|
+
}
|
|
198
|
+
const commandIndex = new Map<string, ClaudeCommand>()
|
|
199
|
+
for (const cmd of claudeCommands) {
|
|
200
|
+
if (!commandIndex.has(cmd.name)) commandIndex.set(cmd.name, cmd)
|
|
201
|
+
if (!commandIndex.has(cmd.id)) commandIndex.set(cmd.id, cmd)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// OpenAI-compatible LLM config (env-driven; default gpt-4o-mini, swappable to Z.AI/Tencent).
|
|
205
|
+
const llmApiKey = process.env.OPENAI_API_KEY ?? process.env.NEBULA_LLM_API_KEY ?? ''
|
|
206
|
+
const llmBaseUrl = process.env.NEBULA_LLM_BASE_URL
|
|
207
|
+
const llmModel = process.env.NEBULA_LLM_MODEL ?? config.brain?.model ?? 'gpt-4o-mini'
|
|
208
|
+
|
|
209
|
+
// Sub-brain factory for delegate.task (Phase 9.3). The factory creates a
|
|
210
|
+
// fresh OpenAIBrain with a custom system prompt. Tools default to none for
|
|
211
|
+
// delegated work; the parent calls delegate.task only when isolation matters.
|
|
212
|
+
const delegateFactory: import('nebula-ai-core').DelegateBrainFactory = async ({
|
|
213
|
+
systemPrompt,
|
|
214
|
+
tools: subTools,
|
|
215
|
+
}) => {
|
|
216
|
+
const subBrain = new OpenAIBrain({
|
|
217
|
+
apiKey: llmApiKey,
|
|
218
|
+
baseUrl: llmBaseUrl,
|
|
219
|
+
model: llmModel,
|
|
220
|
+
tools: subTools,
|
|
221
|
+
prefix: buildFrozenPrefix({
|
|
222
|
+
systemPrompt,
|
|
223
|
+
memoryIndex: null,
|
|
224
|
+
identity: null,
|
|
225
|
+
persona: null,
|
|
226
|
+
loadedToolNames: [],
|
|
227
|
+
skills: [],
|
|
228
|
+
timestamp: null,
|
|
229
|
+
}),
|
|
230
|
+
})
|
|
231
|
+
await subBrain.init()
|
|
232
|
+
return subBrain as unknown as import('nebula-ai-core').DelegateBrainHandle
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Phase 9.5: build sandbox backend BEFORE plugins load. Tools that spawn
|
|
236
|
+
// subprocesses (shell.run, code.execute, shell.process_start) wrap their
|
|
237
|
+
// spawn argv through this backend. NEBULA_SANDBOX_MODE env var wins over
|
|
238
|
+
// config (matches hermes' TERMINAL_ENV pattern — per-launch override
|
|
239
|
+
// without editing config).
|
|
240
|
+
const envOverride = process.env.NEBULA_SANDBOX_MODE
|
|
241
|
+
const sandboxMode: 'none' | 'os' | 'docker' =
|
|
242
|
+
envOverride === 'none' || envOverride === 'os' || envOverride === 'docker'
|
|
243
|
+
? envOverride
|
|
244
|
+
: (config.sandbox?.mode ?? 'none')
|
|
245
|
+
let sandbox: SandboxBackend
|
|
246
|
+
try {
|
|
247
|
+
sandbox = makeSandboxBackend({
|
|
248
|
+
mode: sandboxMode,
|
|
249
|
+
agentDir: paths.dir,
|
|
250
|
+
workspaceRoot: process.cwd(),
|
|
251
|
+
homedir: homedir(),
|
|
252
|
+
dockerImage: config.sandbox?.dockerImage,
|
|
253
|
+
dockerMountWorkspace: config.sandbox?.dockerMountWorkspace,
|
|
254
|
+
dockerRuntimePath: config.sandbox?.dockerRuntimePath,
|
|
255
|
+
dockerCpu: config.sandbox?.dockerCpu,
|
|
256
|
+
dockerMemoryMb: config.sandbox?.dockerMemoryMb,
|
|
257
|
+
dockerDiskMb: config.sandbox?.dockerDiskMb,
|
|
258
|
+
dockerNoNetwork: config.sandbox?.dockerNoNetwork,
|
|
259
|
+
})
|
|
260
|
+
} catch (err) {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
`nebula: sandbox init failed (${(err as Error).message}), continuing without sandbox\n`,
|
|
263
|
+
)
|
|
264
|
+
sandbox = new LocalBackend()
|
|
265
|
+
}
|
|
266
|
+
if (sandbox.mode === 'os') {
|
|
267
|
+
process.stderr.write(
|
|
268
|
+
`nebula: sandbox active [${sandbox.label}] — limb spawns gated to agentDir + cwd + /tmp/nebula-* + /var/folders; reads of ~/.ssh ~/.aws ~/Library/Keychains ~/.config/gcloud denied\n`,
|
|
269
|
+
)
|
|
270
|
+
} else if (sandbox.mode === 'docker') {
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`nebula: container sandbox active [${sandbox.label}] — every shell-class spawn runs inside the container; host fs invisible to those tools${config.sandbox?.dockerMountWorkspace ? ' except mounted /workspace' : ''}\n`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
// Register dispose hook so docker containers don't leak when nebula exits.
|
|
276
|
+
// Signal handlers MUST await dispose before exiting; sync `process.exit(0)`
|
|
277
|
+
// would discard the dispose promise and leave the container orphaned.
|
|
278
|
+
if (sandbox.dispose) {
|
|
279
|
+
const disposeOnce = (() => {
|
|
280
|
+
let done = false
|
|
281
|
+
return async () => {
|
|
282
|
+
if (done) return
|
|
283
|
+
done = true
|
|
284
|
+
await sandbox.dispose?.().catch(() => {})
|
|
285
|
+
}
|
|
286
|
+
})()
|
|
287
|
+
process.once('SIGINT', () => {
|
|
288
|
+
void disposeOnce().then(() => process.exit(0))
|
|
289
|
+
})
|
|
290
|
+
process.once('SIGTERM', () => {
|
|
291
|
+
void disposeOnce().then(() => process.exit(0))
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Vision routing via the OpenAI-compatible brain is a follow-up; disabled for now.
|
|
296
|
+
const visionInfer: VisionInferFn | null = null
|
|
297
|
+
|
|
298
|
+
// Plugin filter: system + onchain ship; telegram is opt-in via
|
|
299
|
+
// `nebula telegram setup` which writes ~/.nebula/agents/<id>/telegram-secrets.encrypted
|
|
300
|
+
// and adds 'telegram' to config.plugins.
|
|
301
|
+
const pluginNames = (config.plugins ?? []).filter(
|
|
302
|
+
p => p === 'system' || p === 'onchain' || p === 'telegram',
|
|
303
|
+
)
|
|
304
|
+
// viem clients are built up front so the agent-EOA balance refresher works
|
|
305
|
+
// regardless of which plugins are loaded.
|
|
306
|
+
const viemClients = makeViemClients({ network: config.network, privkeyHex: agentPrivkey })
|
|
307
|
+
// Onchain side-band ctx: viem clients (already built above) + agent EOA.
|
|
308
|
+
// `mintBlock` is the Transfer-event scan floor for token discovery; with a
|
|
309
|
+
// plain-EOA identity there is no mint, so it starts at genesis (0n).
|
|
310
|
+
let onchain: OnchainRuntimeContext | undefined
|
|
311
|
+
if (pluginNames.includes('onchain')) {
|
|
312
|
+
onchain = {
|
|
313
|
+
agentEoa: agentAddress,
|
|
314
|
+
network: config.network,
|
|
315
|
+
policy: policyFromEnv(),
|
|
316
|
+
publicClient: viemClients.publicClient,
|
|
317
|
+
walletClient: viemClients.walletClient,
|
|
318
|
+
agentDir: paths.dir,
|
|
319
|
+
mintBlock: 0n,
|
|
320
|
+
brainProvider: config.brain.provider,
|
|
321
|
+
brainModel: config.brain.model,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Phase 12: telegram side-band ctx. We build the runtime context now (before
|
|
325
|
+
// brain.init) so the plugin can register its listener via ctx.registerListener,
|
|
326
|
+
// but the dispatch callback is deferred — the slot's `.current` is null until
|
|
327
|
+
// brain.init resolves and we wire it below. Same for the system-row sink:
|
|
328
|
+
// populated once state exists.
|
|
329
|
+
const telegramSlot: TelegramDispatchSlot = { current: null }
|
|
330
|
+
const telegramSystemRowSink: { current: ((text: string) => void) | null } = { current: null }
|
|
331
|
+
const telegramInboundRowSink: { current: ((text: string) => void) | null } = { current: null }
|
|
332
|
+
const telegramAssistantRowSink: { current: ((text: string) => void) | null } = { current: null }
|
|
333
|
+
// Bridge for inline-keyboard approval. Listener fills the inner refs on
|
|
334
|
+
// start; chat-telegram's runOne reads them at turn time.
|
|
335
|
+
const telegramApprovalBridge: TelegramApprovalBridge = {
|
|
336
|
+
sendApproval: { current: null },
|
|
337
|
+
installCallbackHandler: { current: null },
|
|
338
|
+
}
|
|
339
|
+
let telegram: TelegramRuntimeContext | undefined
|
|
340
|
+
if (telegramSecrets && pluginNames.includes('telegram')) {
|
|
341
|
+
telegram = buildTelegramRuntimeContext({
|
|
342
|
+
botToken: telegramSecrets.botToken,
|
|
343
|
+
allowedUserIds: telegramSecrets.allowedUserIds,
|
|
344
|
+
agentName: `agent-${agentId.slice(0, 8)}`,
|
|
345
|
+
slot: telegramSlot,
|
|
346
|
+
systemRowSink: telegramSystemRowSink,
|
|
347
|
+
})
|
|
348
|
+
telegram.approvalBridge = telegramApprovalBridge
|
|
349
|
+
}
|
|
350
|
+
// Local listener registry: plugins register listeners via ctx.registerListener
|
|
351
|
+
// (e.g. telegram's inbound poller); we collect them here so chat can start them
|
|
352
|
+
// once brain init is done.
|
|
353
|
+
const collectedListeners: Listener[] = []
|
|
354
|
+
const skillsDisabled = { current: [...(config.skills?.disabled ?? [])] }
|
|
355
|
+
const loadResult = await loadPlugins(pluginNames, {
|
|
356
|
+
tools,
|
|
357
|
+
hooks,
|
|
358
|
+
listeners: {
|
|
359
|
+
register: l => {
|
|
360
|
+
collectedListeners.push(l)
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
agentDir: paths.dir,
|
|
364
|
+
agentId,
|
|
365
|
+
network: config.network,
|
|
366
|
+
configPath,
|
|
367
|
+
imports: { claudeCode: config.imports?.claudeCode ?? true },
|
|
368
|
+
skillsDisabled,
|
|
369
|
+
activityLogPath: paths.activityLog,
|
|
370
|
+
workspaceRoot: process.cwd(),
|
|
371
|
+
delegateFactory,
|
|
372
|
+
claudeAgents,
|
|
373
|
+
brainSupportsVision: false,
|
|
374
|
+
brainModelLabel: config.brain.model ?? config.brain.provider,
|
|
375
|
+
visionInfer,
|
|
376
|
+
sandbox,
|
|
377
|
+
onchain,
|
|
378
|
+
telegram,
|
|
379
|
+
resolve: async name => {
|
|
380
|
+
switch (name) {
|
|
381
|
+
case 'system':
|
|
382
|
+
return await import('nebula-ai-plugin-system')
|
|
383
|
+
case 'onchain':
|
|
384
|
+
return await import('nebula-ai-plugin-onchain')
|
|
385
|
+
case 'telegram':
|
|
386
|
+
return await import('nebula-ai-plugin-telegram')
|
|
387
|
+
default:
|
|
388
|
+
throw new Error(`unknown first-party plugin: ${name}`)
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
})
|
|
392
|
+
if (loadResult.errors.length > 0 || process.env.NEBULA_DEBUG_PLUGINS) {
|
|
393
|
+
const { writeFile } = await import('node:fs/promises')
|
|
394
|
+
const { join } = await import('node:path')
|
|
395
|
+
await writeFile(
|
|
396
|
+
join(paths.dir, 'plugin-debug.log'),
|
|
397
|
+
JSON.stringify(
|
|
398
|
+
{
|
|
399
|
+
ts: Date.now(),
|
|
400
|
+
pluginNames,
|
|
401
|
+
loadResult,
|
|
402
|
+
registeredTools: tools.list().map(t => t.name),
|
|
403
|
+
},
|
|
404
|
+
null,
|
|
405
|
+
2,
|
|
406
|
+
),
|
|
407
|
+
).catch(() => {})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// MCP discovery: scan ~/.nebula/.mcp.json + ~/.claude/.mcp.json + plugin
|
|
411
|
+
// cache, spawn each stdio server, register tools as deferred. Failures are
|
|
412
|
+
// logged but never block startup.
|
|
413
|
+
let mcpManager: McpManager | null = null
|
|
414
|
+
try {
|
|
415
|
+
const { servers } = await discoverMcpServers({
|
|
416
|
+
importsClaudeCode: config.imports?.claudeCode ?? true,
|
|
417
|
+
})
|
|
418
|
+
if (servers.length > 0) {
|
|
419
|
+
mcpManager = new McpManager(servers)
|
|
420
|
+
const mcpResult = await mcpManager.registerAll(def =>
|
|
421
|
+
tools.register(def as Parameters<typeof tools.register>[0]),
|
|
422
|
+
)
|
|
423
|
+
if (mcpResult.failed.length > 0 || process.env.NEBULA_DEBUG_PLUGINS) {
|
|
424
|
+
const { writeFile } = await import('node:fs/promises')
|
|
425
|
+
const { join } = await import('node:path')
|
|
426
|
+
await writeFile(
|
|
427
|
+
join(paths.dir, 'mcp-debug.log'),
|
|
428
|
+
JSON.stringify(
|
|
429
|
+
{ ts: Date.now(), servers: servers.map(s => s.name), result: mcpResult },
|
|
430
|
+
null,
|
|
431
|
+
2,
|
|
432
|
+
),
|
|
433
|
+
).catch(() => {})
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
// Discovery itself failed (probably I/O); proceed without MCP.
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Memory is local-only; the on-chain (iNFT slot) sync was removed. This
|
|
441
|
+
// no-op preserves the per-turn flush call sites; memory persists as files
|
|
442
|
+
// via the memory.* tools.
|
|
443
|
+
const sync = {
|
|
444
|
+
flushTurn: async (): Promise<{ txHash: Hex | null; changedSlots: string[] }> => ({
|
|
445
|
+
txHash: null,
|
|
446
|
+
changedSlots: [],
|
|
447
|
+
}),
|
|
448
|
+
flushAll: async (): Promise<{ txHash: Hex | null; changedSlots: string[] }> => ({
|
|
449
|
+
txHash: null,
|
|
450
|
+
changedSlots: [],
|
|
451
|
+
}),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await mkdir(paths.memoryDir, { recursive: true })
|
|
455
|
+
const [memoryIndex, identityText, personaText, scannedSkills] = await Promise.all([
|
|
456
|
+
readIndexFile(paths.memoryIndex).catch(() => null),
|
|
457
|
+
readMemoryFileOrNull(`${paths.memoryDir}/agent/identity.md`),
|
|
458
|
+
readMemoryFileOrNull(`${paths.memoryDir}/agent/persona.md`),
|
|
459
|
+
scanSkills({ importsClaudeCode: config.imports?.claudeCode ?? true }).catch(
|
|
460
|
+
() => [] as SkillRef[],
|
|
461
|
+
),
|
|
462
|
+
])
|
|
463
|
+
// Use tools.list() (includes deferred) for guidance lookup — guidance
|
|
464
|
+
// fires per-tool-namespace, not per-prompt-schema. tools.schemas() is the
|
|
465
|
+
// separate set the brain SEES in its prompt; deferred tools stay hidden
|
|
466
|
+
// there until tool.search loads them. But the brain still needs to know
|
|
467
|
+
// they EXIST via guidance, otherwise it never thinks to search.
|
|
468
|
+
const loadedToolNames = tools.list().map(t => t.name)
|
|
469
|
+
const disabledSkillSet = new Set(skillsDisabled.current)
|
|
470
|
+
const skillsRef: { current: SkillRef[] } = {
|
|
471
|
+
current: scannedSkills.filter(s => !disabledSkillSet.has(s.id)),
|
|
472
|
+
}
|
|
473
|
+
const promptAppend = config.prompt?.append ?? null
|
|
474
|
+
// Surface sandbox awareness so the brain doesn't have to empirically discover
|
|
475
|
+
// its container/profile via pwd + ls + uname round-trips. Without it,
|
|
476
|
+
// qwen3.6-plus would hit fs.read('/workspace/X') → ENOENT (fs.* runs on host),
|
|
477
|
+
// sed -i '' (BSD) → fails on Linux GNU sed, and answer "where am I?" only
|
|
478
|
+
// after probing. Each wasted call costs latency + tokens.
|
|
479
|
+
const envInfo = {
|
|
480
|
+
cwd: process.cwd(),
|
|
481
|
+
platform: process.platform,
|
|
482
|
+
sandbox: sandbox.envHint?.() ?? null,
|
|
483
|
+
}
|
|
484
|
+
// Plugin-contributed prompt sections.
|
|
485
|
+
const extraGuidance: string[] = []
|
|
486
|
+
if (onchain) extraGuidance.push(ONCHAIN_GUIDANCE)
|
|
487
|
+
if (telegram) extraGuidance.push(TELEGRAM_GUIDANCE)
|
|
488
|
+
|
|
489
|
+
const buildPrefix = async () => {
|
|
490
|
+
const idx = await readIndexFile(paths.memoryIndex).catch(() => null)
|
|
491
|
+
return buildFrozenPrefix({
|
|
492
|
+
memoryIndex: idx,
|
|
493
|
+
identity: identityText,
|
|
494
|
+
persona: personaText,
|
|
495
|
+
loadedToolNames,
|
|
496
|
+
skills: skillsRef.current,
|
|
497
|
+
promptAppend,
|
|
498
|
+
envInfo,
|
|
499
|
+
extraGuidance,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
const prefix = buildFrozenPrefix({
|
|
503
|
+
memoryIndex,
|
|
504
|
+
identity: identityText,
|
|
505
|
+
persona: personaText,
|
|
506
|
+
loadedToolNames,
|
|
507
|
+
skills: skillsRef.current,
|
|
508
|
+
promptAppend,
|
|
509
|
+
envInfo,
|
|
510
|
+
extraGuidance,
|
|
511
|
+
})
|
|
512
|
+
const activity = new ActivityLog(paths.activityLog)
|
|
513
|
+
|
|
514
|
+
// Brain init must happen BEFORE createCliRenderer. clack/prompts spinner
|
|
515
|
+
// calls setRawMode(false) + stdin.pause() on stop, which undoes the
|
|
516
|
+
// stdin.resume() that opentui's setupTerminal sets up. If brain init
|
|
517
|
+
// (and its spinner) ran AFTER createCliRenderer, the stop would flip
|
|
518
|
+
// stdin back into a state where opentui can't read keypresses, AND the
|
|
519
|
+
// event loop would empty (no stdin keepalive) so the process exits.
|
|
520
|
+
// The fix: every clack interaction finishes before opentui takes the wheel.
|
|
521
|
+
const { render } = await import('@opentui/solid')
|
|
522
|
+
const { createCliRenderer } = await import('@opentui/core')
|
|
523
|
+
const { createChatState } = await import('../ui/state')
|
|
524
|
+
const { ChatApp } = await import('../ui/app')
|
|
525
|
+
|
|
526
|
+
const state = createChatState({
|
|
527
|
+
initialSystem: opts?.yolo
|
|
528
|
+
? 'connected. YOLO mode: approval prompts disabled.'
|
|
529
|
+
: 'connected. type messages and press enter.',
|
|
530
|
+
// Show the configured agent name when set, else the 16-char agent ID hash.
|
|
531
|
+
// Use the FULL agent EOA (no shortAddr) so operators see the complete
|
|
532
|
+
// address — useful for chain explorers.
|
|
533
|
+
identityLabel: `agent ${agentId} ${agentAddress}`,
|
|
534
|
+
approvalsMode: initialMode,
|
|
535
|
+
// v0.24.4: embedded chat runs in-process on the operator's machine — by
|
|
536
|
+
// definition local. Tag it so the statusbar hides the sandbox-billing
|
|
537
|
+
// segment, matching the standalone-local-gateway path.
|
|
538
|
+
isLocalGateway: true,
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// Phase 12: now that state exists, point the telegram row sinks at it. The
|
|
542
|
+
// dispatch slot stays null until brain.init resolves below.
|
|
543
|
+
if (telegram) {
|
|
544
|
+
telegramSystemRowSink.current = (text: string) => state.pushRow({ role: 'system', text })
|
|
545
|
+
telegramInboundRowSink.current = (text: string) => state.pushRow({ role: 'inbox-tg', text })
|
|
546
|
+
telegramAssistantRowSink.current = (text: string) =>
|
|
547
|
+
state.pushRow({ role: 'telegram-assistant', text })
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Statusline balance refreshers; fired at boot, post-turn, and post-/sync.
|
|
551
|
+
const refreshEoaBalance = () => {
|
|
552
|
+
viemClients.publicClient
|
|
553
|
+
.getBalance({ address: agentAddress })
|
|
554
|
+
.then(wei => state.setEoaBalance(Number(formatEther(wei))))
|
|
555
|
+
.catch(() => {})
|
|
556
|
+
}
|
|
557
|
+
const refreshBalances = () => {
|
|
558
|
+
refreshEoaBalance()
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
permission.setPrompter(req => {
|
|
562
|
+
return new Promise<PermissionDecision>(resolve => {
|
|
563
|
+
// Value-moving onchain ops carry amount/recipient/token so we render a
|
|
564
|
+
// friendlier "send 0.05 Mantle to 0xC635...87Ec" instead of a raw command.
|
|
565
|
+
const detail =
|
|
566
|
+
req.amount !== undefined
|
|
567
|
+
? `${req.amount}${req.token ? ` ${req.token}` : ''}${req.recipient ? ` to ${req.recipient}` : ''}`
|
|
568
|
+
: (req.command ?? req.path ?? '(?)')
|
|
569
|
+
state.pushRow({
|
|
570
|
+
role: 'system',
|
|
571
|
+
text: `[approval requested] ${req.reason}: ${detail}`,
|
|
572
|
+
})
|
|
573
|
+
state.setPendingApproval({ request: req, resolve })
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
hooks.add<PreToolCallContext, PreToolCallResult>('pre_tool_call', async ({ call }) => {
|
|
578
|
+
const checks = describePermissionCheck(call)
|
|
579
|
+
if (!checks) return undefined
|
|
580
|
+
// Deterministic policy floor: escalate to approval beneath the session mode
|
|
581
|
+
// (even YOLO) when the on-chain policy flags this call as material-risk.
|
|
582
|
+
if (
|
|
583
|
+
!checks.force &&
|
|
584
|
+
policyRequiresApprovalForCall(
|
|
585
|
+
call.name,
|
|
586
|
+
(call.args ?? {}) as Record<string, unknown>,
|
|
587
|
+
policyFromEnv(),
|
|
588
|
+
)
|
|
589
|
+
) {
|
|
590
|
+
checks.force = true
|
|
591
|
+
}
|
|
592
|
+
const result = await permission.resolve(checks)
|
|
593
|
+
if (result.allowed) return undefined
|
|
594
|
+
return {
|
|
595
|
+
short: {
|
|
596
|
+
ok: false,
|
|
597
|
+
error: `Denied: ${result.reason ?? 'permission check failed'} (mode=${permission.getMode()}). Operator rejected this call. Do NOT retry, instruct another tool, or claim the transaction is queued. Surface the rejection to the operator and ask whether to proceed differently.`,
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// Skills auto-trigger: when a tool call matches a skill's filePattern or
|
|
603
|
+
// bashPattern, surface a system row so the operator sees the auto-load AND
|
|
604
|
+
// queue the SKILL.md body for next-turn injection via brain.injectContext().
|
|
605
|
+
const pendingSkillInjections = new Set<string>()
|
|
606
|
+
hooks.add<PostToolCallContext, void>('post_tool_call', async ({ call, result }) => {
|
|
607
|
+
if (result.ok === false) return
|
|
608
|
+
const matches = matchSkillTriggers({ name: call.name, args: call.args }, skillsRef.current)
|
|
609
|
+
for (const match of matches) {
|
|
610
|
+
if (pendingSkillInjections.has(match.skill.id)) continue
|
|
611
|
+
pendingSkillInjections.add(match.skill.id)
|
|
612
|
+
state.pushRow({
|
|
613
|
+
role: 'system',
|
|
614
|
+
text: `↳ skill auto-loaded: ${match.skill.id} (matched ${match.reason}). use skills.view to read body.`,
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
const bootSpinner = spinner()
|
|
620
|
+
bootSpinner.start(`Connecting to model ${llmModel}`)
|
|
621
|
+
const persistConversations = config.brain?.persistConversations !== false
|
|
622
|
+
const brain = new OpenAIBrain({
|
|
623
|
+
apiKey: llmApiKey,
|
|
624
|
+
baseUrl: llmBaseUrl,
|
|
625
|
+
model: llmModel,
|
|
626
|
+
tools: tools.schemas(),
|
|
627
|
+
prefix,
|
|
628
|
+
maxOutputTokens: config.brain?.maxOutputTokens,
|
|
629
|
+
compaction:
|
|
630
|
+
config.brain?.compaction === null
|
|
631
|
+
? null
|
|
632
|
+
: {
|
|
633
|
+
threshold: config.brain?.compaction?.threshold ?? 0.5,
|
|
634
|
+
contextWindow: config.brain?.contextWindow ?? 1_000_000,
|
|
635
|
+
keepRecent: config.brain?.compaction?.keepRecent ?? 8,
|
|
636
|
+
},
|
|
637
|
+
persist: persistConversations
|
|
638
|
+
? createFsHistoryPersist({ dir: `${paths.dir}/conversations` })
|
|
639
|
+
: undefined,
|
|
640
|
+
onToolCall: async call => {
|
|
641
|
+
state.pushRow({
|
|
642
|
+
role: 'tool-call',
|
|
643
|
+
text: '',
|
|
644
|
+
toolName: call.name,
|
|
645
|
+
args: summarizeArgs(call.args),
|
|
646
|
+
})
|
|
647
|
+
const pre = await hooks.runPreToolCall({ call })
|
|
648
|
+
if (pre.short) {
|
|
649
|
+
await activity.append({
|
|
650
|
+
ts: Date.now(),
|
|
651
|
+
kind: 'tool-call',
|
|
652
|
+
data: { call, result: pre.short, blocked: true },
|
|
653
|
+
})
|
|
654
|
+
state.pushRow({
|
|
655
|
+
role: 'tool-result',
|
|
656
|
+
text: summarizeToolResult(pre.short),
|
|
657
|
+
failed: pre.short.ok === false,
|
|
658
|
+
})
|
|
659
|
+
return { role: 'tool', content: JSON.stringify(pre.short) } as BrainMessage
|
|
660
|
+
}
|
|
661
|
+
const effectiveCall = pre.call ?? call
|
|
662
|
+
const result = await tools.dispatch(effectiveCall)
|
|
663
|
+
await hooks.runPostToolCall({ call: effectiveCall, result })
|
|
664
|
+
await activity.append({
|
|
665
|
+
ts: Date.now(),
|
|
666
|
+
kind: 'tool-call',
|
|
667
|
+
data: { call: effectiveCall, result },
|
|
668
|
+
})
|
|
669
|
+
state.pushRow({
|
|
670
|
+
role: 'tool-result',
|
|
671
|
+
text: summarizeToolResult(result),
|
|
672
|
+
failed: result.ok === false,
|
|
673
|
+
})
|
|
674
|
+
// v0.21.2 R1: deterministic browser.navigate retry when web.fetch hits
|
|
675
|
+
// a bot-block. Mirror block in build-runtime.ts; both share orchestration
|
|
676
|
+
// via runEscalation so any future change lands in one place. Sinks differ:
|
|
677
|
+
// TUI pushes rows here, gateway publishes SSE events.
|
|
678
|
+
const escalation = detectFetchEscalation(effectiveCall, result)
|
|
679
|
+
if (escalation.needed) {
|
|
680
|
+
const merged = await runEscalation(escalation, result, {
|
|
681
|
+
runPreCall: c => hooks.runPreToolCall({ call: c }),
|
|
682
|
+
runPostCall: (c, r) => hooks.runPostToolCall({ call: c, result: r }),
|
|
683
|
+
dispatch: c => tools.dispatch(c),
|
|
684
|
+
appendActivity: (c, r) =>
|
|
685
|
+
activity.append({
|
|
686
|
+
ts: Date.now(),
|
|
687
|
+
kind: 'tool-call',
|
|
688
|
+
data: { call: c, result: r, autoEscalated: true },
|
|
689
|
+
}),
|
|
690
|
+
onStart: c =>
|
|
691
|
+
state.pushRow({
|
|
692
|
+
role: 'tool-call',
|
|
693
|
+
text: '',
|
|
694
|
+
toolName: c.name,
|
|
695
|
+
args: summarizeArgs(c.args),
|
|
696
|
+
autoEscalated: true,
|
|
697
|
+
}),
|
|
698
|
+
onEnd: (_c, r) =>
|
|
699
|
+
state.pushRow({
|
|
700
|
+
role: 'tool-result',
|
|
701
|
+
text: summarizeToolResult(r),
|
|
702
|
+
failed: r.ok === false,
|
|
703
|
+
autoEscalated: true,
|
|
704
|
+
}),
|
|
705
|
+
})
|
|
706
|
+
return { role: 'tool', content: JSON.stringify(merged) } as BrainMessage
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
role: 'tool',
|
|
710
|
+
content: JSON.stringify(result),
|
|
711
|
+
} as BrainMessage
|
|
712
|
+
},
|
|
713
|
+
})
|
|
714
|
+
try {
|
|
715
|
+
await brain.init()
|
|
716
|
+
bootSpinner.stop('Connected')
|
|
717
|
+
} catch (e) {
|
|
718
|
+
bootSpinner.stop(`Connection failed: ${(e as Error).message.slice(0, 120)}`)
|
|
719
|
+
process.exit(1)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Phase 12: brain is up. Wire the deferred TG dispatch slot so any inbound
|
|
723
|
+
// TG message that lands once collectedListeners[i].start() fires below
|
|
724
|
+
// routes through brain.infer with source=telegram.
|
|
725
|
+
if (telegram) {
|
|
726
|
+
const handle = buildTelegramDispatch({
|
|
727
|
+
activity,
|
|
728
|
+
sync,
|
|
729
|
+
permission,
|
|
730
|
+
pushAssistantRow: text => telegramAssistantRowSink.current?.(text),
|
|
731
|
+
pushInboundRow: text => telegramInboundRowSink.current?.(text),
|
|
732
|
+
isBusy: () => state.status() === 'thinking',
|
|
733
|
+
buildPrefix,
|
|
734
|
+
brain,
|
|
735
|
+
setThinking: on => state.setStatus(on ? 'thinking' : 'idle'),
|
|
736
|
+
setActiveAbort: ctrl => state.setActiveAbort(ctrl),
|
|
737
|
+
refreshBalances,
|
|
738
|
+
formatInboundPreview: input =>
|
|
739
|
+
formatTelegramInboundPreview({
|
|
740
|
+
chatId: input.chatId,
|
|
741
|
+
username: input.username,
|
|
742
|
+
displayName: input.displayName,
|
|
743
|
+
text: input.text.replace(/^<channel[^>]*>([\s\S]*)<\/channel>$/, '$1'),
|
|
744
|
+
}),
|
|
745
|
+
approvalBridge: telegramApprovalBridge,
|
|
746
|
+
})
|
|
747
|
+
telegramSlot.current = handle.dispatch
|
|
748
|
+
// Drain queued TG messages whenever the brain returns to idle (closes G4
|
|
749
|
+
// starvation: a stdin turn ending while a TG message was queued used to
|
|
750
|
+
// leave it stuck until the next inbound).
|
|
751
|
+
state.onStatusChange(next => {
|
|
752
|
+
if (next === 'idle' && handle.getQueueSize() > 0) handle.drainQueue()
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Initial balances for the status bar (best-effort, never blocks boot).
|
|
757
|
+
refreshBalances()
|
|
758
|
+
|
|
759
|
+
// Redirect noisy SDK chatter (Mantle storage progress, ethers RPC errors) to a
|
|
760
|
+
// log file so it doesn't fall through opentui's alt-screen and pollute the
|
|
761
|
+
// chat UI. Keep process.stdout intact - opentui itself needs to write there.
|
|
762
|
+
const { createWriteStream } = await import('node:fs')
|
|
763
|
+
const chatLog = createWriteStream(`${paths.dir}/chat.log`, { flags: 'a' })
|
|
764
|
+
const stringifyArg = (a: unknown): string => {
|
|
765
|
+
if (typeof a === 'string') return a
|
|
766
|
+
if (a instanceof Error) return a.stack ?? a.message
|
|
767
|
+
try {
|
|
768
|
+
return JSON.stringify(a, (_k, v) => (typeof v === 'bigint' ? `${v}n` : v))
|
|
769
|
+
} catch {
|
|
770
|
+
return String(a)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const logTo =
|
|
774
|
+
(level: string) =>
|
|
775
|
+
(...args: unknown[]) => {
|
|
776
|
+
const line = args.map(stringifyArg).join(' ')
|
|
777
|
+
chatLog.write(`[${new Date().toISOString()}] [${level}] ${line}\n`)
|
|
778
|
+
}
|
|
779
|
+
console.log = logTo('log') as typeof console.log
|
|
780
|
+
console.warn = logTo('warn') as typeof console.warn
|
|
781
|
+
console.error = logTo('error') as typeof console.error
|
|
782
|
+
console.info = logTo('info') as typeof console.info
|
|
783
|
+
console.debug = logTo('debug') as typeof console.debug
|
|
784
|
+
process.on('unhandledRejection', err => {
|
|
785
|
+
chatLog.write(`[unhandled] ${(err as Error)?.stack ?? String(err)}\n`)
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
const renderer = await createCliRenderer({
|
|
789
|
+
exitOnCtrlC: false,
|
|
790
|
+
consoleMode: 'disabled',
|
|
791
|
+
openConsoleOnError: false,
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
// Listener catch-up + WS subscribe runs in the background. `start` only
|
|
795
|
+
// resolves after catch-up finishes, which can be slow on long-restored
|
|
796
|
+
// agents; awaiting it would block the chat from accepting input.
|
|
797
|
+
for (const l of collectedListeners) {
|
|
798
|
+
l.start(undefined as never).catch(e => {
|
|
799
|
+
state.pushRow({
|
|
800
|
+
role: 'system',
|
|
801
|
+
text: `listener ${l.name} failed to start: ${(e as Error).message.slice(0, 160)}`,
|
|
802
|
+
})
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const handleSubmit = async (text: string): Promise<void> => {
|
|
807
|
+
const trimmed = text.trim()
|
|
808
|
+
if (trimmed.startsWith('/')) {
|
|
809
|
+
const handled = await handleSlash(trimmed)
|
|
810
|
+
if (handled) {
|
|
811
|
+
// Slash commands skip brain.infer; reset thinking → idle so the
|
|
812
|
+
// spinner row stops. (The keyboard handler in app.tsx flips
|
|
813
|
+
// status='thinking' on every Enter, regardless of payload.)
|
|
814
|
+
state.setStatus('idle')
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Per-turn AbortController. Esc in the TUI calls .abort() on this.
|
|
819
|
+
// Stored on state so the keyboard handler can reach it from app.tsx.
|
|
820
|
+
const abortCtrl = new AbortController()
|
|
821
|
+
state.setActiveAbort(abortCtrl)
|
|
822
|
+
try {
|
|
823
|
+
// Refresh per-turn user-context (MEMORY.md may have grown last turn).
|
|
824
|
+
// The system prefix stays cached; only the user-msg context updates.
|
|
825
|
+
const refreshed = await buildPrefix()
|
|
826
|
+
brain.refreshUserContext(refreshed)
|
|
827
|
+
await activity.append({
|
|
828
|
+
ts: Date.now(),
|
|
829
|
+
kind: 'wake',
|
|
830
|
+
data: { source: 'stdin', text },
|
|
831
|
+
})
|
|
832
|
+
const turn = await brain.infer({
|
|
833
|
+
event: {
|
|
834
|
+
id: newEventId(),
|
|
835
|
+
source: 'stdin',
|
|
836
|
+
payload: { label: 'user-message', data: text },
|
|
837
|
+
ts: Date.now(),
|
|
838
|
+
},
|
|
839
|
+
channelKey: 'tui:stdin',
|
|
840
|
+
signal: abortCtrl.signal,
|
|
841
|
+
onCompactionEvent: ev => {
|
|
842
|
+
state.pushRow({
|
|
843
|
+
role: 'system',
|
|
844
|
+
text: `✂︎ context compacted (${ev.from} → ${ev.to} messages, ~${Math.round(ev.promptTokens / 1000)}K tokens)`,
|
|
845
|
+
})
|
|
846
|
+
},
|
|
847
|
+
})
|
|
848
|
+
await activity.append({
|
|
849
|
+
ts: Date.now(),
|
|
850
|
+
kind: 'brain-response',
|
|
851
|
+
data: {
|
|
852
|
+
content: turn.content,
|
|
853
|
+
toolCalls: turn.toolCalls.length,
|
|
854
|
+
finishReason: turn.finishReason,
|
|
855
|
+
usage: turn.usage,
|
|
856
|
+
},
|
|
857
|
+
})
|
|
858
|
+
state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
|
|
859
|
+
state.setStatus('idle')
|
|
860
|
+
// Compute ledger drains via inference; agent EOA via tool chain writes.
|
|
861
|
+
refreshBalances()
|
|
862
|
+
if (turn.usage) {
|
|
863
|
+
state.setUsage({
|
|
864
|
+
total: turn.usage.totalTokens,
|
|
865
|
+
cached: turn.usage.cachedTokens,
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
// Per-turn auto-sync: upload changed memory + activity-log to Mantle Storage,
|
|
869
|
+
// anchor in iNFT. Fire-and-forget; chat doesn't wait. Errors surface
|
|
870
|
+
// as a system row every turn — repetition is the signal that a real
|
|
871
|
+
// upstream issue persists, not noise to suppress.
|
|
872
|
+
sync
|
|
873
|
+
.flushTurn()
|
|
874
|
+
.then(res => {
|
|
875
|
+
if (res.txHash && res.changedSlots.length > 0) {
|
|
876
|
+
state.pushRow({
|
|
877
|
+
role: 'system',
|
|
878
|
+
text: `synced ${res.changedSlots.join(', ')} → ${explorerTxUrl(config.network, res.txHash)}`,
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
})
|
|
882
|
+
.catch(e => {
|
|
883
|
+
state.pushRow({
|
|
884
|
+
role: 'system',
|
|
885
|
+
text: `sync error: ${summarizeError(e)}`,
|
|
886
|
+
})
|
|
887
|
+
})
|
|
888
|
+
} catch (e) {
|
|
889
|
+
// AbortError = operator pressed Esc; render as a clean sys row, NOT an
|
|
890
|
+
// error. The activity log gets a paired entry so the post-mortem reflects
|
|
891
|
+
// operator intent, not a real fault.
|
|
892
|
+
if ((e instanceof Error && e.name === 'AbortError') || abortCtrl.signal.aborted) {
|
|
893
|
+
state.pushRow({
|
|
894
|
+
role: 'system',
|
|
895
|
+
text: 'turn interrupted (esc). brain stopped at the last completed step.',
|
|
896
|
+
})
|
|
897
|
+
await activity.append({
|
|
898
|
+
ts: Date.now(),
|
|
899
|
+
kind: 'brain-response',
|
|
900
|
+
data: { content: '(aborted by operator)', toolCalls: 0, finishReason: 'aborted' },
|
|
901
|
+
})
|
|
902
|
+
state.setStatus('idle')
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
// Mirror real errors to chat.log too — render-layer bugs can swallow the
|
|
906
|
+
// sys row before it hits the screen, and chat.log is the only artifact
|
|
907
|
+
// the operator can read post-mortem.
|
|
908
|
+
const errMsg = e instanceof Error ? e.message : String(e ?? 'unknown error')
|
|
909
|
+
const dumped = e instanceof Error ? (e.stack ?? e.message) : errMsg
|
|
910
|
+
console.error('[handleSubmit] error:', dumped)
|
|
911
|
+
state.pushRow({ role: 'system', text: `error: ${errMsg.slice(0, 300)}` })
|
|
912
|
+
state.setStatus('error')
|
|
913
|
+
} finally {
|
|
914
|
+
state.setActiveAbort(null)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const handleSlash = async (cmd: string): Promise<boolean> => {
|
|
919
|
+
if (cmd === '/exit' || cmd === '/quit') {
|
|
920
|
+
state.pushRow({ role: 'system', text: 'goodbye.' })
|
|
921
|
+
handleExit()
|
|
922
|
+
return true
|
|
923
|
+
}
|
|
924
|
+
if (cmd === '/model') {
|
|
925
|
+
state.pushRow({
|
|
926
|
+
role: 'system',
|
|
927
|
+
text: 'Switching brain. (Quit chat first; run `nebula model` to pick a new brain, then re-launch `nebula`.)',
|
|
928
|
+
})
|
|
929
|
+
return true
|
|
930
|
+
}
|
|
931
|
+
if (cmd === '/sync') {
|
|
932
|
+
state.pushRow({ role: 'system', text: 'force-syncing memory + activity to Mantle…' })
|
|
933
|
+
try {
|
|
934
|
+
const res = await sync.flushAll()
|
|
935
|
+
if (res.txHash) {
|
|
936
|
+
state.pushRow({
|
|
937
|
+
role: 'system',
|
|
938
|
+
text: `synced ${res.changedSlots.join(', ')} → ${explorerTxUrl(config.network, res.txHash)}`,
|
|
939
|
+
})
|
|
940
|
+
refreshEoaBalance()
|
|
941
|
+
} else {
|
|
942
|
+
state.pushRow({ role: 'system', text: 'nothing to sync (everything up to date)' })
|
|
943
|
+
}
|
|
944
|
+
} catch (e) {
|
|
945
|
+
state.pushRow({ role: 'system', text: `sync error: ${summarizeError(e)}` })
|
|
946
|
+
}
|
|
947
|
+
return true
|
|
948
|
+
}
|
|
949
|
+
if (cmd === '/yolo') {
|
|
950
|
+
const result = applyYolo(permission)
|
|
951
|
+
state.setApprovalsMode(result.mode)
|
|
952
|
+
state.pushRow({ role: 'system', text: result.message })
|
|
953
|
+
return true
|
|
954
|
+
}
|
|
955
|
+
if (cmd === '/perms' || cmd.startsWith('/perms ')) {
|
|
956
|
+
const arg = cmd.split(/\s+/)[1]
|
|
957
|
+
const result = applyPerms(permission, arg)
|
|
958
|
+
state.setApprovalsMode(result.mode)
|
|
959
|
+
state.pushRow({ role: 'system', text: result.message })
|
|
960
|
+
return true
|
|
961
|
+
}
|
|
962
|
+
if (cmd === '/reset') {
|
|
963
|
+
try {
|
|
964
|
+
await brain.clearChannel('tui:stdin')
|
|
965
|
+
state.pushRow({ role: 'system', text: 'conversation reset (TUI channel cleared)' })
|
|
966
|
+
} catch (e) {
|
|
967
|
+
state.pushRow({ role: 'system', text: `reset error: ${summarizeError(e)}` })
|
|
968
|
+
}
|
|
969
|
+
return true
|
|
970
|
+
}
|
|
971
|
+
if (cmd === '/jobs') {
|
|
972
|
+
const tool = tools.find('market.listMyJobs')
|
|
973
|
+
if (!tool) {
|
|
974
|
+
state.pushRow({
|
|
975
|
+
role: 'system',
|
|
976
|
+
text: 'market plugin not loaded; cannot list jobs.',
|
|
977
|
+
})
|
|
978
|
+
return true
|
|
979
|
+
}
|
|
980
|
+
state.pushRow({ role: 'system', text: 'fetching active jobs…' })
|
|
981
|
+
try {
|
|
982
|
+
const res = await tool.handler({ status: 'active', limit: 20 } as never)
|
|
983
|
+
const data = (res as { ok: boolean; data?: { jobs: unknown[] } }).data
|
|
984
|
+
const jobs = (data?.jobs ?? []) as Array<{
|
|
985
|
+
jobId: string
|
|
986
|
+
role: string
|
|
987
|
+
counterparty: string | null
|
|
988
|
+
amount0g: string
|
|
989
|
+
status: string
|
|
990
|
+
}>
|
|
991
|
+
if (jobs.length === 0) {
|
|
992
|
+
state.pushRow({ role: 'system', text: 'no active escrow jobs.' })
|
|
993
|
+
} else {
|
|
994
|
+
const lines = jobs.map(
|
|
995
|
+
j =>
|
|
996
|
+
` job#${j.jobId} · ${j.role}${j.counterparty ? ` w/ ${shortAddr(j.counterparty)}` : ''} · ${j.amount0g} Mantle · ${j.status}`,
|
|
997
|
+
)
|
|
998
|
+
state.pushRow({
|
|
999
|
+
role: 'system',
|
|
1000
|
+
text: `active jobs (${jobs.length}):\n${lines.join('\n')}`,
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
state.pushRow({ role: 'system', text: `jobs error: ${summarizeError(e)}` })
|
|
1005
|
+
}
|
|
1006
|
+
return true
|
|
1007
|
+
}
|
|
1008
|
+
if (cmd === '/help') {
|
|
1009
|
+
const builtins =
|
|
1010
|
+
" /sync force memory + activity flush to Mantle\n /jobs list active escrow jobs\n /model switch brain (run nebula model after exiting)\n /yolo toggle approval prompts off/on for this session\n /perms <mode> set permission mode (off|prompt|strict); no arg shows current\n /reset clear this channel's conversation history\n /exit quit nebula (drains Mantle storage flush, releases process)\n /help this message"
|
|
1011
|
+
const claudeBlock =
|
|
1012
|
+
commandIndex.size === 0
|
|
1013
|
+
? ''
|
|
1014
|
+
: `\n\nClaude Code commands (auto-loaded):\n${[
|
|
1015
|
+
...new Set([...commandIndex.values()].map(c => c.name)),
|
|
1016
|
+
]
|
|
1017
|
+
.sort()
|
|
1018
|
+
.map(name => {
|
|
1019
|
+
const c = commandIndex.get(name)!
|
|
1020
|
+
return ` /${c.name} ${c.description.slice(0, 80)}`
|
|
1021
|
+
})
|
|
1022
|
+
.join('\n')}`
|
|
1023
|
+
state.pushRow({
|
|
1024
|
+
role: 'system',
|
|
1025
|
+
text: `slash commands:\n${builtins}${claudeBlock}`,
|
|
1026
|
+
})
|
|
1027
|
+
return true
|
|
1028
|
+
}
|
|
1029
|
+
// Claude Code command match. Strip leading `/`, take first whitespace
|
|
1030
|
+
// segment as the command name, treat the rest as the user-supplied args.
|
|
1031
|
+
if (cmd.startsWith('/')) {
|
|
1032
|
+
const rest = cmd.slice(1).trim()
|
|
1033
|
+
if (!rest) return false
|
|
1034
|
+
const space = rest.indexOf(' ')
|
|
1035
|
+
const name = space === -1 ? rest : rest.slice(0, space)
|
|
1036
|
+
const args = space === -1 ? '' : rest.slice(space + 1).trim()
|
|
1037
|
+
const command = commandIndex.get(name)
|
|
1038
|
+
if (!command) return false
|
|
1039
|
+
const trimmedBody = command.body.trim()
|
|
1040
|
+
const inlined = args
|
|
1041
|
+
? `# Command: /${command.name}${command.argumentHint ? ` (${command.argumentHint})` : ''}\n# User args: ${args}\n\n${trimmedBody}`
|
|
1042
|
+
: `# Command: /${command.name}\n\n${trimmedBody}`
|
|
1043
|
+
state.pushRow({
|
|
1044
|
+
role: 'system',
|
|
1045
|
+
text: `↳ command: /${command.name} (${command.id}, ${command.body.length} bytes inlined as user message)`,
|
|
1046
|
+
})
|
|
1047
|
+
// Send the command body as a user message so the brain executes it.
|
|
1048
|
+
try {
|
|
1049
|
+
const refreshed = await buildPrefix()
|
|
1050
|
+
brain.refreshUserContext(refreshed)
|
|
1051
|
+
const turn = await brain.infer({
|
|
1052
|
+
event: {
|
|
1053
|
+
id: newEventId(),
|
|
1054
|
+
source: 'stdin',
|
|
1055
|
+
payload: { label: 'user-message', data: inlined },
|
|
1056
|
+
ts: Date.now(),
|
|
1057
|
+
},
|
|
1058
|
+
channelKey: 'tui:stdin',
|
|
1059
|
+
})
|
|
1060
|
+
state.pushRow({ role: 'assistant', text: turn.content ?? '(no content)' })
|
|
1061
|
+
state.setStatus('idle')
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
state.pushRow({
|
|
1064
|
+
role: 'system',
|
|
1065
|
+
text: `command error: ${(e as Error).message.slice(0, 200)}`,
|
|
1066
|
+
})
|
|
1067
|
+
}
|
|
1068
|
+
return true
|
|
1069
|
+
}
|
|
1070
|
+
return false
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// @opentui/solid's render() resolves once the component mounts; it does not
|
|
1074
|
+
// block. On macOS the renderer's animation loop runs in a worker thread, so
|
|
1075
|
+
// the main thread has no JS task keeping the event loop alive after render
|
|
1076
|
+
// returns. Anchor: a never-resolving promise after render(); handleExit is
|
|
1077
|
+
// the only escape via process.exit.
|
|
1078
|
+
const handleExit = (): void => {
|
|
1079
|
+
try {
|
|
1080
|
+
renderer.destroy()
|
|
1081
|
+
} catch {}
|
|
1082
|
+
try {
|
|
1083
|
+
mcpManager?.closeAll()
|
|
1084
|
+
} catch {}
|
|
1085
|
+
// Best-effort: kill any background processes registered via shell.process.
|
|
1086
|
+
try {
|
|
1087
|
+
const { killAllProcesses } = require('nebula-ai-plugin-system') as {
|
|
1088
|
+
killAllProcesses: () => void
|
|
1089
|
+
}
|
|
1090
|
+
killAllProcesses()
|
|
1091
|
+
} catch {}
|
|
1092
|
+
// Best-effort drain: if a flush is mid-flight, await it. Caps at 30s so
|
|
1093
|
+
// we never hang the CLI on a wedged RPC.
|
|
1094
|
+
Promise.race([sync.flushTurn(), new Promise(r => setTimeout(r, 30_000))]).finally(() =>
|
|
1095
|
+
process.exit(0),
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Map Claude Code commands into SlashCommand shape so the slash
|
|
1100
|
+
// autocomplete popup lists them alongside the bundled registry.
|
|
1101
|
+
const extraSlashCommands = [...new Set([...commandIndex.values()].map(c => c.name))].map(name => {
|
|
1102
|
+
const c = commandIndex.get(name)!
|
|
1103
|
+
return {
|
|
1104
|
+
name: c.name.toLowerCase(),
|
|
1105
|
+
description: c.description ?? `Claude Code command (${c.id})`,
|
|
1106
|
+
surfaces: ['tui'] as ('tui' | 'tg')[],
|
|
1107
|
+
scope: 'local' as const,
|
|
1108
|
+
bypassesBrain: false,
|
|
1109
|
+
argHint: c.argumentHint,
|
|
1110
|
+
}
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
await render(
|
|
1114
|
+
() => (
|
|
1115
|
+
<ChatApp
|
|
1116
|
+
state={state}
|
|
1117
|
+
onSubmit={handleSubmit}
|
|
1118
|
+
onExit={handleExit}
|
|
1119
|
+
extraSlashCommands={extraSlashCommands}
|
|
1120
|
+
/>
|
|
1121
|
+
),
|
|
1122
|
+
renderer,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
await new Promise<void>(() => {
|
|
1126
|
+
// Block forever; only handleExit (via process.exit) escapes this.
|
|
1127
|
+
})
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function runModelPicker(
|
|
1131
|
+
config: NebulaConfig,
|
|
1132
|
+
configPath: string,
|
|
1133
|
+
): Promise<NebulaConfig | null> {
|
|
1134
|
+
// Nebula uses a fixed OpenAI-compatible model (env-configured); no live catalog.
|
|
1135
|
+
const model = process.env.NEBULA_LLM_MODEL ?? config.brain?.model ?? 'gpt-4o-mini'
|
|
1136
|
+
const updated: NebulaConfig = {
|
|
1137
|
+
...config,
|
|
1138
|
+
brain: { provider: 'openai-compatible', model },
|
|
1139
|
+
}
|
|
1140
|
+
await writeConfigTs(configPath, updated)
|
|
1141
|
+
return updated
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Squash a ToolResult down to a single-line summary for the chat row. The TUI
|
|
1146
|
+
* adds the `⎿` indent + color from the role, so this returns just the content:
|
|
1147
|
+
* - failed → the error message (truncated)
|
|
1148
|
+
* - ok+path → the file path the tool acted on
|
|
1149
|
+
* - ok+data → "ok"
|
|
1150
|
+
* - done → "done" (legacy: pre-ok results)
|
|
1151
|
+
*/
|
|
1152
|
+
function summarizeToolResult(result: unknown): string {
|
|
1153
|
+
const r = result as { ok?: boolean; error?: string; data?: { path?: string } } | null | undefined
|
|
1154
|
+
if (!r || r.ok === undefined) return 'done'
|
|
1155
|
+
if (r.ok === false) return (r.error ?? 'failed').slice(0, 200)
|
|
1156
|
+
const path = typeof r.data?.path === 'string' ? r.data.path : null
|
|
1157
|
+
return path ? path : 'ok'
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Squash an Error into a single-line, length-capped string for the TUI.
|
|
1162
|
+
* ethers / viem multi-line stack traces blow up the chat UX otherwise.
|
|
1163
|
+
* Strategy: collapse whitespace, drop everything after the first ` (action=`
|
|
1164
|
+
* marker (where ethers appends transaction blobs), cap at 90 chars so the
|
|
1165
|
+
* row stays on one terminal line in any reasonably-sized pane.
|
|
1166
|
+
*/
|
|
1167
|
+
function summarizeError(e: unknown): string {
|
|
1168
|
+
const raw = e instanceof Error ? e.message : String(e)
|
|
1169
|
+
let s = raw.replace(/\s+/g, ' ').trim()
|
|
1170
|
+
const annotIdx = s.indexOf(' (action=')
|
|
1171
|
+
if (annotIdx >= 0) s = s.slice(0, annotIdx)
|
|
1172
|
+
return s.length > 90 ? `${s.slice(0, 87)}...` : s
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
type PermArgs = Record<string, unknown>
|
|
1176
|
+
const _str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
|
1177
|
+
const _strOpt = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
|
1178
|
+
|
|
1179
|
+
const PERMISSION_DESCRIBERS: Record<string, (a: PermArgs) => PermissionRequest | null> = {
|
|
1180
|
+
'shell.run': a => ({
|
|
1181
|
+
kind: 'shell.run',
|
|
1182
|
+
command: _str(a.command),
|
|
1183
|
+
reason: 'shell command execution',
|
|
1184
|
+
}),
|
|
1185
|
+
'code.execute': a => ({
|
|
1186
|
+
kind: 'code.execute',
|
|
1187
|
+
command: `[${_str(a.language) || '?'}] ${_str(a.code)}`,
|
|
1188
|
+
reason: 'arbitrary code execution',
|
|
1189
|
+
}),
|
|
1190
|
+
'shell.process_start': a => ({
|
|
1191
|
+
kind: 'shell.process',
|
|
1192
|
+
command: _str(a.command),
|
|
1193
|
+
reason: 'background process start',
|
|
1194
|
+
}),
|
|
1195
|
+
'shell.process_output': () => null,
|
|
1196
|
+
'shell.process_list': () => null,
|
|
1197
|
+
'shell.process_kill': () => null,
|
|
1198
|
+
'fs.write': a => ({ kind: 'fs.write', path: _str(a.path), reason: 'fs.write request' }),
|
|
1199
|
+
'fs.patch': a => ({ kind: 'fs.patch', path: _str(a.path), reason: 'fs.patch request' }),
|
|
1200
|
+
// Phase 10: value-moving on-chain tools. Pre-fill amount/recipient/token
|
|
1201
|
+
// so the modal renders "send 0.05 Mantle to 0xC635..." not a raw command.
|
|
1202
|
+
'chain.send': a => ({
|
|
1203
|
+
kind: 'chain.send',
|
|
1204
|
+
amount: _strOpt(a.amount) ?? '?',
|
|
1205
|
+
recipient: _strOpt(a.to) ?? '?',
|
|
1206
|
+
token: _strOpt(a.token) ?? 'MNT',
|
|
1207
|
+
reason: 'native/ERC-20 transfer',
|
|
1208
|
+
}),
|
|
1209
|
+
'swap.execute': a => ({
|
|
1210
|
+
kind: 'chain.swap',
|
|
1211
|
+
amount: _strOpt(a.amountIn) ?? '?',
|
|
1212
|
+
token: `${_strOpt(a.tokenIn) ?? '?'}→${_strOpt(a.tokenOut) ?? '?'}`,
|
|
1213
|
+
reason: 'Agni swap execution',
|
|
1214
|
+
}),
|
|
1215
|
+
'moe.swap': a => ({
|
|
1216
|
+
kind: 'chain.swap',
|
|
1217
|
+
amount: _strOpt(a.amountIn) ?? '?',
|
|
1218
|
+
token: `${_strOpt(a.tokenIn) ?? '?'}→${_strOpt(a.tokenOut) ?? '?'}`,
|
|
1219
|
+
reason: 'Merchant Moe swap execution',
|
|
1220
|
+
}),
|
|
1221
|
+
'swap.best': a => ({
|
|
1222
|
+
kind: 'chain.swap',
|
|
1223
|
+
amount: _strOpt(a.amountIn) ?? '?',
|
|
1224
|
+
token: `${_strOpt(a.tokenIn) ?? '?'}→${_strOpt(a.tokenOut) ?? '?'}`,
|
|
1225
|
+
reason: 'best-execution swap',
|
|
1226
|
+
}),
|
|
1227
|
+
'aave.supply': a => ({
|
|
1228
|
+
kind: 'chain.write',
|
|
1229
|
+
command: `aave.supply ${_strOpt(a.amount) ?? '?'} ${_strOpt(a.token) ?? '?'}`,
|
|
1230
|
+
reason: 'supply collateral to Aave V3',
|
|
1231
|
+
}),
|
|
1232
|
+
'aave.withdraw': a => ({
|
|
1233
|
+
kind: 'chain.write',
|
|
1234
|
+
command: `aave.withdraw ${_strOpt(a.amount) ?? '?'} ${_strOpt(a.token) ?? '?'}`,
|
|
1235
|
+
reason: 'withdraw collateral from Aave V3',
|
|
1236
|
+
}),
|
|
1237
|
+
'aave.borrow': a => ({
|
|
1238
|
+
kind: 'chain.write',
|
|
1239
|
+
command: `aave.borrow ${_strOpt(a.amount) ?? '?'} ${_strOpt(a.token) ?? '?'}`,
|
|
1240
|
+
reason: 'borrow from Aave V3 (leverage)',
|
|
1241
|
+
}),
|
|
1242
|
+
'aave.repay': a => ({
|
|
1243
|
+
kind: 'chain.write',
|
|
1244
|
+
command: `aave.repay ${_strOpt(a.amount) ?? '?'} ${_strOpt(a.token) ?? '?'}`,
|
|
1245
|
+
reason: 'repay Aave V3 debt',
|
|
1246
|
+
}),
|
|
1247
|
+
'chain.wrap': a => ({
|
|
1248
|
+
kind: 'chain.send',
|
|
1249
|
+
amount: _strOpt(a.amount) ?? '?',
|
|
1250
|
+
token: 'MNT→WMNT',
|
|
1251
|
+
reason: 'wrap native to WMNT',
|
|
1252
|
+
}),
|
|
1253
|
+
'chain.unwrap': a => ({
|
|
1254
|
+
kind: 'chain.send',
|
|
1255
|
+
amount: _strOpt(a.amount) ?? '?',
|
|
1256
|
+
token: 'WMNT→MNT',
|
|
1257
|
+
reason: 'unwrap WMNT to native',
|
|
1258
|
+
}),
|
|
1259
|
+
'chain.write': a => ({
|
|
1260
|
+
kind: 'chain.write',
|
|
1261
|
+
recipient: _strOpt(a.to) ?? '?',
|
|
1262
|
+
command: _strOpt(a.signature) ?? '?',
|
|
1263
|
+
amount: _strOpt(a.value) ? `${_strOpt(a.value)} wei` : undefined,
|
|
1264
|
+
reason: 'arbitrary state-changing call',
|
|
1265
|
+
}),
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function describePermissionCheck(call: { name: string; args: unknown }): PermissionRequest | null {
|
|
1269
|
+
const fn = PERMISSION_DESCRIBERS[call.name]
|
|
1270
|
+
return fn ? fn((call.args ?? {}) as PermArgs) : null
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function summarizeArgs(args: unknown): string {
|
|
1274
|
+
if (typeof args !== 'object' || args === null) return String(args ?? '').slice(0, 60)
|
|
1275
|
+
const entries = Object.entries(args as Record<string, unknown>)
|
|
1276
|
+
return entries
|
|
1277
|
+
.map(([k, v]) => {
|
|
1278
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
1279
|
+
return `${k}=${s.length > 40 ? `${s.slice(0, 40)}…` : s}`
|
|
1280
|
+
})
|
|
1281
|
+
.slice(0, 3)
|
|
1282
|
+
.join(', ')
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function readMemoryFileOrNull(path: string): Promise<string | null> {
|
|
1286
|
+
try {
|
|
1287
|
+
const { readFile } = await import('node:fs/promises')
|
|
1288
|
+
return await readFile(path, 'utf8')
|
|
1289
|
+
} catch (e) {
|
|
1290
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
1291
|
+
throw e
|
|
1292
|
+
}
|
|
1293
|
+
}
|