nebula-ai-core 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 +24 -0
- package/package.json +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +75 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +213 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- package/src/wallet/operator-session.ts +344 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { detectDangerousCommand } from './dangerous'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Permission system core. Three resolution modes:
|
|
5
|
+
* - 'strict': dangerous patterns always denied, no prompt
|
|
6
|
+
* - 'prompt': dangerous patterns prompt the user once / for the session / deny
|
|
7
|
+
* - 'off' (YOLO): dangerous patterns allowed silently
|
|
8
|
+
*
|
|
9
|
+
* Approvals are scoped per-session via a Set of allowed pattern keys. The
|
|
10
|
+
* service is UI-agnostic; chat.tsx wires `setPrompter()` to its own modal.
|
|
11
|
+
* In headless contexts (tests, scripted CLI) the default prompter denies.
|
|
12
|
+
*/
|
|
13
|
+
export type PermissionMode = 'strict' | 'prompt' | 'off'
|
|
14
|
+
export type PermissionDecision = 'allow-once' | 'allow-session' | 'deny'
|
|
15
|
+
|
|
16
|
+
export interface PermissionRequest {
|
|
17
|
+
kind:
|
|
18
|
+
| 'shell.run'
|
|
19
|
+
| 'shell.process'
|
|
20
|
+
| 'code.execute'
|
|
21
|
+
| 'fs.write'
|
|
22
|
+
| 'fs.patch'
|
|
23
|
+
| 'chain.send'
|
|
24
|
+
| 'chain.swap'
|
|
25
|
+
| 'chain.write'
|
|
26
|
+
command?: string
|
|
27
|
+
path?: string
|
|
28
|
+
/** For value-moving tx tools: human-readable amount (e.g. "0.05 Mantle"). */
|
|
29
|
+
amount?: string
|
|
30
|
+
/** For value-moving tx tools: 0x recipient or contract address. */
|
|
31
|
+
recipient?: string
|
|
32
|
+
/** For value-moving tx tools: token symbol. */
|
|
33
|
+
token?: string
|
|
34
|
+
/** Description of why approval is needed (e.g. "delete in root path"). */
|
|
35
|
+
reason: string
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic policy floor: when true, the on-chain policy engine has
|
|
38
|
+
* flagged this action as material-risk and it MUST be approved by a human
|
|
39
|
+
* regardless of the session permission mode — even under YOLO. In `strict`
|
|
40
|
+
* mode a forced request is denied outright. This is how fund controls are
|
|
41
|
+
* enforced in code rather than left to the model (CLAUDE.md).
|
|
42
|
+
*/
|
|
43
|
+
force?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PermissionPrompter = (req: PermissionRequest) => Promise<PermissionDecision>
|
|
47
|
+
|
|
48
|
+
export interface PermissionServiceOpts {
|
|
49
|
+
mode: PermissionMode
|
|
50
|
+
prompter?: PermissionPrompter
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_DENY_PROMPTER: PermissionPrompter = async () => 'deny'
|
|
54
|
+
|
|
55
|
+
export class PermissionService {
|
|
56
|
+
private mode: PermissionMode
|
|
57
|
+
private prompter: PermissionPrompter
|
|
58
|
+
private readonly sessionAllowed = new Set<string>()
|
|
59
|
+
|
|
60
|
+
constructor(opts: PermissionServiceOpts) {
|
|
61
|
+
this.mode = opts.mode
|
|
62
|
+
this.prompter = opts.prompter ?? DEFAULT_DENY_PROMPTER
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setMode(mode: PermissionMode): void {
|
|
66
|
+
this.mode = mode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setPrompter(p: PermissionPrompter): void {
|
|
70
|
+
this.prompter = p
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
isYolo(): boolean {
|
|
74
|
+
return this.mode === 'off'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getMode(): PermissionMode {
|
|
78
|
+
return this.mode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a permission for a tool call.
|
|
83
|
+
* - `force` (policy floor): material-risk per the on-chain policy — prompt
|
|
84
|
+
* beneath any mode; deny in strict; even YOLO must approve.
|
|
85
|
+
* - YOLO ('off'): otherwise always allow.
|
|
86
|
+
* - Strict: dangerous pattern => deny, otherwise allow.
|
|
87
|
+
* - Prompt: dangerous pattern OR shell.run => consult `prompter`,
|
|
88
|
+
* honour session-allow on subsequent identical signatures.
|
|
89
|
+
*/
|
|
90
|
+
async resolve(req: PermissionRequest): Promise<{
|
|
91
|
+
allowed: boolean
|
|
92
|
+
reason?: string
|
|
93
|
+
via: 'yolo' | 'allow' | 'session-allow' | 'once' | 'deny' | 'strict-deny'
|
|
94
|
+
}> {
|
|
95
|
+
const dangerous = req.command ? detectDangerousCommand(req.command) : { match: false as const }
|
|
96
|
+
|
|
97
|
+
// Signature for session-allow tracking. When the request matched a
|
|
98
|
+
// dangerous pattern, key on the PATTERN (e.g. "delete in root path") so
|
|
99
|
+
// the user's "allow session" covers every match of the same pattern for
|
|
100
|
+
// the rest of the session, not just the literal command.
|
|
101
|
+
const sigKey = dangerous.match ? this.signature(req, dangerous.key) : this.signature(req)
|
|
102
|
+
if (this.sessionAllowed.has(sigKey)) {
|
|
103
|
+
return { allowed: true, via: 'session-allow' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Deterministic policy floor: a `force`-flagged action is material-risk per
|
|
107
|
+
// the on-chain policy engine and requires human approval BENEATH the
|
|
108
|
+
// session mode — even under YOLO. strict denies it outright; otherwise it
|
|
109
|
+
// consults the prompter regardless of mode. Fund controls live in code.
|
|
110
|
+
if (req.force) {
|
|
111
|
+
if (this.mode === 'strict') {
|
|
112
|
+
return {
|
|
113
|
+
allowed: false,
|
|
114
|
+
reason: `${req.reason} (policy: approval required, denied in strict mode)`,
|
|
115
|
+
via: 'strict-deny',
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const decision = await this.prompter(req)
|
|
119
|
+
return this.applyDecision(decision, sigKey)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (this.mode === 'off') return { allowed: true, via: 'yolo' }
|
|
123
|
+
|
|
124
|
+
// Value-moving on-chain tools: ALWAYS prompt in `prompt` mode regardless
|
|
125
|
+
// of dangerous-pattern match (which is regex-based and doesn't fire here).
|
|
126
|
+
// In `strict` mode they're denied — strict means "no autonomous spending".
|
|
127
|
+
const isValueMoving =
|
|
128
|
+
req.kind === 'chain.send' || req.kind === 'chain.swap' || req.kind === 'chain.write'
|
|
129
|
+
if (this.mode === 'strict') {
|
|
130
|
+
if (isValueMoving) {
|
|
131
|
+
return {
|
|
132
|
+
allowed: false,
|
|
133
|
+
reason: 'value-moving tx denied in strict mode',
|
|
134
|
+
via: 'strict-deny',
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (dangerous.match) {
|
|
138
|
+
return { allowed: false, reason: dangerous.description, via: 'strict-deny' }
|
|
139
|
+
}
|
|
140
|
+
return { allowed: true, via: 'allow' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// mode === 'prompt': dangerous patterns + every shell-class invocation
|
|
144
|
+
// + every value-moving on-chain tx consult the prompter.
|
|
145
|
+
if (dangerous.match) {
|
|
146
|
+
const decision = await this.prompter({ ...req, reason: dangerous.description })
|
|
147
|
+
return this.applyDecision(decision, sigKey)
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
req.kind === 'shell.run' ||
|
|
151
|
+
req.kind === 'shell.process' ||
|
|
152
|
+
req.kind === 'code.execute' ||
|
|
153
|
+
isValueMoving
|
|
154
|
+
) {
|
|
155
|
+
const decision = await this.prompter(req)
|
|
156
|
+
return this.applyDecision(decision, sigKey)
|
|
157
|
+
}
|
|
158
|
+
return { allowed: true, via: 'allow' }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Approve a fs.write/fs.patch path explicitly (skip prompter). Tests use
|
|
163
|
+
* this; chat.tsx wires it through the approval modal.
|
|
164
|
+
*/
|
|
165
|
+
approveSession(req: PermissionRequest): void {
|
|
166
|
+
this.sessionAllowed.add(this.signature(req))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private applyDecision(
|
|
170
|
+
decision: PermissionDecision,
|
|
171
|
+
sigKey: string,
|
|
172
|
+
): { allowed: boolean; reason?: string; via: 'session-allow' | 'once' | 'deny' } {
|
|
173
|
+
if (decision === 'allow-session') {
|
|
174
|
+
this.sessionAllowed.add(sigKey)
|
|
175
|
+
return { allowed: true, via: 'session-allow' }
|
|
176
|
+
}
|
|
177
|
+
if (decision === 'allow-once') return { allowed: true, via: 'once' }
|
|
178
|
+
return { allowed: false, reason: 'rejected in approval modal', via: 'deny' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private signature(req: PermissionRequest, dangerousKey?: string): string {
|
|
182
|
+
// For dangerous-pattern matches, key on the pattern type ("delete in
|
|
183
|
+
// root path", "recursive delete", ...) so allow-session generalises
|
|
184
|
+
// across every command that triggers the same pattern. Without this,
|
|
185
|
+
// each unique rm path was a fresh decision.
|
|
186
|
+
if (dangerousKey) return `${req.kind}|dangerous:${dangerousKey}`
|
|
187
|
+
return [req.kind, req.command ?? '', req.path ?? '', req.recipient ?? '', req.token ?? ''].join(
|
|
188
|
+
'|',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ClaudeAgent } from '../claude-plugins/types'
|
|
2
|
+
import type { Listener } from '../events/listeners'
|
|
3
|
+
import type { SandboxBackend } from '../sandbox/types'
|
|
4
|
+
import type { ToolRegistry } from '../tools/registry'
|
|
5
|
+
import type { ToolDef, ToolSchema } from '../tools/types'
|
|
6
|
+
import type { HookBus, HookHandler, HookName } from './hooks'
|
|
7
|
+
|
|
8
|
+
/** Vision inputs for image-capable models (provider-agnostic). */
|
|
9
|
+
export type VisionInferImage = { bytes: Uint8Array; mediaType: string }
|
|
10
|
+
export type VisionInferInput = {
|
|
11
|
+
prompt: string
|
|
12
|
+
images: VisionInferImage[]
|
|
13
|
+
maxOutputTokens?: number
|
|
14
|
+
}
|
|
15
|
+
export type VisionInferResult = {
|
|
16
|
+
content: string
|
|
17
|
+
model?: string | null
|
|
18
|
+
usage?: {
|
|
19
|
+
promptTokens?: number
|
|
20
|
+
completionTokens?: number
|
|
21
|
+
totalTokens?: number
|
|
22
|
+
cachedTokens?: number
|
|
23
|
+
}
|
|
24
|
+
finishReason?: string
|
|
25
|
+
}
|
|
26
|
+
export type VisionInferFn = (input: VisionInferInput) => Promise<VisionInferResult>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Factory chat.tsx supplies for `delegate.task` to spin up a sub-brain. The
|
|
30
|
+
* implementation typically wraps `OGComputeBrain` with a custom system prompt
|
|
31
|
+
* + restricted tool surface.
|
|
32
|
+
*/
|
|
33
|
+
export interface DelegateBrainFactoryOpts {
|
|
34
|
+
systemPrompt: string
|
|
35
|
+
tools: ToolSchema[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DelegateBrainTurn {
|
|
39
|
+
content: string | null
|
|
40
|
+
finishReason?: string
|
|
41
|
+
toolCalls?: Array<{ id: string; name: string; args: unknown }>
|
|
42
|
+
usage?: {
|
|
43
|
+
totalTokens?: number
|
|
44
|
+
cachedTokens?: number
|
|
45
|
+
promptTokens?: number
|
|
46
|
+
completionTokens?: number
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DelegateBrainHandle {
|
|
51
|
+
infer(input: {
|
|
52
|
+
event: { id: string; source: 'stdin'; payload: { label: string; data: string }; ts: number }
|
|
53
|
+
}): Promise<DelegateBrainTurn>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type DelegateBrainFactory = (opts: DelegateBrainFactoryOpts) => Promise<DelegateBrainHandle>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Context handed to a plugin's `register(ctx)` function. Plugins use this
|
|
60
|
+
* to contribute tools, listeners, and lifecycle hooks; we deliberately
|
|
61
|
+
* keep the surface tiny so future plugin features extend it without
|
|
62
|
+
* breaking existing native plugins.
|
|
63
|
+
*/
|
|
64
|
+
export interface PluginContext {
|
|
65
|
+
registerTool: (def: ToolDef) => void
|
|
66
|
+
registerListener: (l: Listener) => void
|
|
67
|
+
addHook: <TIn = unknown, TOut = void>(name: HookName, fn: HookHandler<TIn, TOut>) => void
|
|
68
|
+
/** Network the agent is configured for. */
|
|
69
|
+
network: 'mantle-mainnet' | 'mantle-testnet'
|
|
70
|
+
/** Agent state directory (`~/.nebula/agents/<id>/`). */
|
|
71
|
+
agentDir: string
|
|
72
|
+
/** Per-agent unique id (matches `iNFTAgentId(...)` for non-stub agents). */
|
|
73
|
+
agentId: string
|
|
74
|
+
/** Absolute path to ~/.nebula/config.ts. Plugins that persist user-level state write here. */
|
|
75
|
+
configPath: string
|
|
76
|
+
/** Imports surface from config (e.g. claudeCode toggle for skills + MCP discovery). */
|
|
77
|
+
imports: { claudeCode: boolean }
|
|
78
|
+
/**
|
|
79
|
+
* Mutable cell holding the user-disabled skill ids. Plugin tools that
|
|
80
|
+
* change the list update this, and the chat rebuilds the skill index from
|
|
81
|
+
* the current value next turn.
|
|
82
|
+
*/
|
|
83
|
+
skillsDisabled: { current: string[] }
|
|
84
|
+
/** Path to the agent's activity log (~/.nebula/agents/<id>/activity.jsonl). */
|
|
85
|
+
activityLogPath: string
|
|
86
|
+
/** Workspace cwd. Used by tools that spawn subprocesses. */
|
|
87
|
+
workspaceRoot: string
|
|
88
|
+
/**
|
|
89
|
+
* Sub-brain factory (Phase 9.3 delegate.task). Chat.tsx supplies a closure
|
|
90
|
+
* that builds an OGComputeBrain with broker creds. Tools without a brain
|
|
91
|
+
* dependency ignore this.
|
|
92
|
+
*/
|
|
93
|
+
delegateFactory?: DelegateBrainFactory
|
|
94
|
+
/** Claude Code agents discovered from the local plugin cache. */
|
|
95
|
+
claudeAgents: ClaudeAgent[]
|
|
96
|
+
/** Whether the configured brain supports image inputs. */
|
|
97
|
+
brainSupportsVision: boolean
|
|
98
|
+
/** Brain model label (string). Surfaces in tool error messages. */
|
|
99
|
+
brainModelLabel: string | null
|
|
100
|
+
/**
|
|
101
|
+
* v0.11 vision routing: when set, vision.analyze + browser.vision call
|
|
102
|
+
* this function (backed by a BrokerPool entry pinned to the configured
|
|
103
|
+
* vision provider). Null when no vision provider is configured.
|
|
104
|
+
*/
|
|
105
|
+
visionInfer?: VisionInferFn | null
|
|
106
|
+
/**
|
|
107
|
+
* Phase 9.5: sandbox backend wrapping every spawn() in shell.run / code.execute /
|
|
108
|
+
* shell.process_start. Optional for back-compat: legacy callers + tests that
|
|
109
|
+
* don't supply one get a LocalBackend (passthrough) inside the plugin.
|
|
110
|
+
*/
|
|
111
|
+
sandbox?: SandboxBackend
|
|
112
|
+
/**
|
|
113
|
+
* Phase 7 side-band runtime context for plugin-comms. Opaque to core; the
|
|
114
|
+
* plugin reads its concrete shape via a typed cast. Holding the field as
|
|
115
|
+
* `unknown` keeps core free of a back-edge to the comms package.
|
|
116
|
+
*/
|
|
117
|
+
comms?: unknown
|
|
118
|
+
/**
|
|
119
|
+
* Phase 10 side-band runtime context for plugin-onchain. Same opaque
|
|
120
|
+
* pattern as `comms`.
|
|
121
|
+
*/
|
|
122
|
+
onchain?: unknown
|
|
123
|
+
/**
|
|
124
|
+
* Phase 12 side-band runtime context for plugin-telegram. Same opaque
|
|
125
|
+
* pattern: chat.tsx (local) or build-runtime.ts (sandbox) builds the typed
|
|
126
|
+
* `TelegramRuntimeContext` and passes it; the plugin casts on read.
|
|
127
|
+
*/
|
|
128
|
+
telegram?: unknown
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface NativePlugin {
|
|
132
|
+
name: string
|
|
133
|
+
register: (ctx: PluginContext) => void | Promise<void>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PluginLoadResult {
|
|
137
|
+
loaded: string[]
|
|
138
|
+
errors: { plugin: string; error: string }[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PluginLoaderDeps {
|
|
142
|
+
tools: ToolRegistry
|
|
143
|
+
hooks: HookBus
|
|
144
|
+
listeners: { register: (l: Listener) => void }
|
|
145
|
+
agentDir: string
|
|
146
|
+
agentId: string
|
|
147
|
+
network: 'mantle-mainnet' | 'mantle-testnet'
|
|
148
|
+
configPath: string
|
|
149
|
+
imports: { claudeCode: boolean }
|
|
150
|
+
skillsDisabled: { current: string[] }
|
|
151
|
+
activityLogPath: string
|
|
152
|
+
workspaceRoot: string
|
|
153
|
+
delegateFactory?: DelegateBrainFactory
|
|
154
|
+
claudeAgents?: ClaudeAgent[]
|
|
155
|
+
brainSupportsVision?: boolean
|
|
156
|
+
brainModelLabel?: string | null
|
|
157
|
+
visionInfer?: VisionInferFn | null
|
|
158
|
+
/** Phase 9.5 sandbox backend, propagated to plugin context. Optional. */
|
|
159
|
+
sandbox?: SandboxBackend
|
|
160
|
+
/** Phase 7 side-band runtime context for plugin-comms. Opaque to core. */
|
|
161
|
+
comms?: unknown
|
|
162
|
+
/** Phase 10 side-band runtime context for plugin-onchain. Opaque to core. */
|
|
163
|
+
onchain?: unknown
|
|
164
|
+
/** Phase 12 side-band runtime context for plugin-telegram. Opaque to core. */
|
|
165
|
+
telegram?: unknown
|
|
166
|
+
/**
|
|
167
|
+
* Resolver for `name` → ESM module path. Defaults to dynamic import of
|
|
168
|
+
* `nebula-ai-plugin-<name>`. Tests pass a stub.
|
|
169
|
+
*/
|
|
170
|
+
resolve?: (name: string) => Promise<{ default?: NativePlugin } & Partial<NativePlugin>>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function loadPlugins(
|
|
174
|
+
names: readonly string[],
|
|
175
|
+
deps: PluginLoaderDeps,
|
|
176
|
+
): Promise<PluginLoadResult> {
|
|
177
|
+
const loaded: string[] = []
|
|
178
|
+
const errors: PluginLoadResult['errors'] = []
|
|
179
|
+
const ctx: PluginContext = {
|
|
180
|
+
registerTool: def => deps.tools.register(def),
|
|
181
|
+
registerListener: l => deps.listeners.register(l),
|
|
182
|
+
addHook: (name, fn) => deps.hooks.add(name, fn),
|
|
183
|
+
network: deps.network,
|
|
184
|
+
agentDir: deps.agentDir,
|
|
185
|
+
agentId: deps.agentId,
|
|
186
|
+
configPath: deps.configPath,
|
|
187
|
+
imports: deps.imports,
|
|
188
|
+
skillsDisabled: deps.skillsDisabled,
|
|
189
|
+
activityLogPath: deps.activityLogPath,
|
|
190
|
+
workspaceRoot: deps.workspaceRoot,
|
|
191
|
+
delegateFactory: deps.delegateFactory,
|
|
192
|
+
claudeAgents: deps.claudeAgents ?? [],
|
|
193
|
+
brainSupportsVision: deps.brainSupportsVision ?? false,
|
|
194
|
+
brainModelLabel: deps.brainModelLabel ?? null,
|
|
195
|
+
visionInfer: deps.visionInfer ?? null,
|
|
196
|
+
sandbox: deps.sandbox,
|
|
197
|
+
comms: deps.comms,
|
|
198
|
+
onchain: deps.onchain,
|
|
199
|
+
telegram: deps.telegram,
|
|
200
|
+
}
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
try {
|
|
203
|
+
const mod = deps.resolve
|
|
204
|
+
? await deps.resolve(name)
|
|
205
|
+
: ((await import(`nebula-ai-plugin-${name}`)) as {
|
|
206
|
+
default?: NativePlugin
|
|
207
|
+
} & Partial<NativePlugin>)
|
|
208
|
+
const plugin: NativePlugin | undefined =
|
|
209
|
+
mod.default && 'register' in mod.default
|
|
210
|
+
? mod.default
|
|
211
|
+
: 'register' in mod && typeof mod.register === 'function'
|
|
212
|
+
? (mod as unknown as NativePlugin)
|
|
213
|
+
: undefined
|
|
214
|
+
if (!plugin) {
|
|
215
|
+
errors.push({ plugin: name, error: 'no exported register(ctx)' })
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
await plugin.register(ctx)
|
|
219
|
+
loaded.push(name)
|
|
220
|
+
} catch (e) {
|
|
221
|
+
errors.push({ plugin: name, error: (e as Error).message ?? String(e) })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { loaded, errors }
|
|
225
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ToolCall, ToolResult } from '../tools/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle hook surface. Plugins register handlers via `ctx.addHook(name, fn)`.
|
|
5
|
+
* Hooks run in registration order; pre-* hooks may return a replacement
|
|
6
|
+
* payload to mutate the input, while post-* hooks observe only.
|
|
7
|
+
*
|
|
8
|
+
* Phase 9.0 wires `pre_tool_call` + `post_tool_call` into the chat loop.
|
|
9
|
+
* The other 8 names exist as no-op stubs so plugins can target them now and
|
|
10
|
+
* future phases (LLM call, session lifecycle) can light them up without
|
|
11
|
+
* breaking the contract.
|
|
12
|
+
*/
|
|
13
|
+
export type HookName =
|
|
14
|
+
| 'pre_tool_call'
|
|
15
|
+
| 'post_tool_call'
|
|
16
|
+
| 'pre_llm_call'
|
|
17
|
+
| 'post_llm_call'
|
|
18
|
+
| 'pre_api_request'
|
|
19
|
+
| 'post_api_request'
|
|
20
|
+
| 'on_session_start'
|
|
21
|
+
| 'on_session_end'
|
|
22
|
+
| 'on_session_finalize'
|
|
23
|
+
| 'on_session_reset'
|
|
24
|
+
|
|
25
|
+
export interface PreToolCallContext {
|
|
26
|
+
call: ToolCall
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PreToolCallResult {
|
|
30
|
+
/** Replacement call (e.g. permission injection edits args). undefined = no change. */
|
|
31
|
+
call?: ToolCall
|
|
32
|
+
/** If set, short-circuit dispatch with this result. */
|
|
33
|
+
short?: ToolResult
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PostToolCallContext {
|
|
37
|
+
call: ToolCall
|
|
38
|
+
result: ToolResult
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type HookHandler<TIn, TOut = void> = (
|
|
42
|
+
ctx: TIn,
|
|
43
|
+
) => Promise<TOut | undefined> | TOut | undefined
|
|
44
|
+
|
|
45
|
+
export class HookBus {
|
|
46
|
+
private readonly handlers = new Map<HookName, HookHandler<unknown, unknown>[]>()
|
|
47
|
+
|
|
48
|
+
add<TIn = unknown, TOut = void>(name: HookName, fn: HookHandler<TIn, TOut>): void {
|
|
49
|
+
const list = this.handlers.get(name) ?? []
|
|
50
|
+
list.push(fn as HookHandler<unknown, unknown>)
|
|
51
|
+
this.handlers.set(name, list)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run pre-tool-call hooks in order. Each handler may return a replacement
|
|
56
|
+
* call or a short-circuit result. The first short-circuit wins. Returns the
|
|
57
|
+
* effective call + optional short-circuit.
|
|
58
|
+
*/
|
|
59
|
+
async runPreToolCall(input: PreToolCallContext): Promise<PreToolCallResult> {
|
|
60
|
+
let current: ToolCall = input.call
|
|
61
|
+
const fns = this.handlers.get('pre_tool_call') ?? []
|
|
62
|
+
for (const fn of fns) {
|
|
63
|
+
const out = (await fn({ call: current })) as PreToolCallResult | undefined
|
|
64
|
+
if (!out) continue
|
|
65
|
+
if (out.short) return { short: out.short, call: current }
|
|
66
|
+
if (out.call) current = out.call
|
|
67
|
+
}
|
|
68
|
+
return { call: current }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async runPostToolCall(input: PostToolCallContext): Promise<void> {
|
|
72
|
+
const fns = this.handlers.get('post_tool_call') ?? []
|
|
73
|
+
for (const fn of fns) {
|
|
74
|
+
try {
|
|
75
|
+
await fn(input)
|
|
76
|
+
} catch {
|
|
77
|
+
// Post hooks must never break the chat loop.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export {
|
|
2
|
+
HookBus,
|
|
3
|
+
type HookName,
|
|
4
|
+
type HookHandler,
|
|
5
|
+
type PreToolCallContext,
|
|
6
|
+
type PreToolCallResult,
|
|
7
|
+
type PostToolCallContext,
|
|
8
|
+
} from './hooks'
|
|
9
|
+
export {
|
|
10
|
+
loadPlugins,
|
|
11
|
+
type PluginContext,
|
|
12
|
+
type NativePlugin,
|
|
13
|
+
type PluginLoadResult,
|
|
14
|
+
type PluginLoaderDeps,
|
|
15
|
+
type DelegateBrainFactory,
|
|
16
|
+
type DelegateBrainFactoryOpts,
|
|
17
|
+
type DelegateBrainHandle,
|
|
18
|
+
type DelegateBrainTurn,
|
|
19
|
+
type VisionInferFn,
|
|
20
|
+
type VisionInferInput,
|
|
21
|
+
type VisionInferImage,
|
|
22
|
+
type VisionInferResult,
|
|
23
|
+
} from './context'
|
|
24
|
+
export { makeToolSearchTool, type ToolSearchArgs } from './tool-search'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { ToolRegistry } from '../tools/registry'
|
|
3
|
+
import type { ToolDef } from '../tools/types'
|
|
4
|
+
import { coerceInt } from '../tools/zod-helpers'
|
|
5
|
+
import { zodToJsonSchema } from '../tools/zod-schema'
|
|
6
|
+
|
|
7
|
+
const ToolSearchSchema = z.object({
|
|
8
|
+
query: z.string().min(1, 'query is required'),
|
|
9
|
+
max_results: coerceInt.refine(n => n > 0 && n <= 20, 'max_results must be 1..20').optional(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export type ToolSearchArgs = z.infer<typeof ToolSearchSchema>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `tool.search` meta-tool: brain calls this to hydrate deferred tool
|
|
16
|
+
* schemas. Mirrors Claude Code's ToolSearch surface, accepts either an
|
|
17
|
+
* explicit `select:foo,bar` query or a free-text keyword query.
|
|
18
|
+
*/
|
|
19
|
+
export function makeToolSearchTool(registry: ToolRegistry): ToolDef<ToolSearchArgs> {
|
|
20
|
+
return {
|
|
21
|
+
name: 'tool.search',
|
|
22
|
+
description:
|
|
23
|
+
'Look up deferred tool schemas. Use `select:name1,name2` to fetch by name, or free-text keywords (e.g., "filesystem read") to search descriptions and hints. Returns matching tools with their full parameter schema; the brain can immediately call them next turn.',
|
|
24
|
+
alwaysLoad: true,
|
|
25
|
+
searchHint: 'meta search deferred tools schemas',
|
|
26
|
+
schema: ToolSearchSchema,
|
|
27
|
+
handler: args => {
|
|
28
|
+
const max = args.max_results ?? 5
|
|
29
|
+
const matches = registry.search(args.query, max)
|
|
30
|
+
const schemas = matches.map(t => {
|
|
31
|
+
registry.unlock(t.name)
|
|
32
|
+
return {
|
|
33
|
+
name: t.name,
|
|
34
|
+
description: t.description,
|
|
35
|
+
parameters: t.parametersOverride ?? zodToJsonSchema(t.schema),
|
|
36
|
+
searchHint: t.searchHint,
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
data: {
|
|
42
|
+
query: args.query,
|
|
43
|
+
matched: schemas.length,
|
|
44
|
+
tools: schemas,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import matter from 'gray-matter'
|
|
2
|
+
|
|
3
|
+
export interface CardFrontmatter {
|
|
4
|
+
/** Display name, e.g. "Alice". */
|
|
5
|
+
name: string
|
|
6
|
+
/** Short one-line bio, <= 140 chars. */
|
|
7
|
+
bio?: string
|
|
8
|
+
/** Skills / domains the agent is competent in. */
|
|
9
|
+
skills?: string[]
|
|
10
|
+
/** Endpoints the agent exposes (URLs). */
|
|
11
|
+
endpoints?: string[]
|
|
12
|
+
/** Avatar: either a Mantle Storage CID or an absolute URL. */
|
|
13
|
+
avatar?: string
|
|
14
|
+
/** Fully-qualified .0g subname, e.g. "alice.nebula.0g". */
|
|
15
|
+
subname?: string
|
|
16
|
+
/** iNFT pointer, CAIP-10-ish: eip155:<chainId>:<contract>:<tokenId> */
|
|
17
|
+
inft?: string
|
|
18
|
+
[key: string]: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Card {
|
|
22
|
+
frontmatter: CardFrontmatter
|
|
23
|
+
body: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CARD: Card = {
|
|
27
|
+
frontmatter: {
|
|
28
|
+
name: '',
|
|
29
|
+
bio: '',
|
|
30
|
+
skills: [],
|
|
31
|
+
endpoints: [],
|
|
32
|
+
},
|
|
33
|
+
body: '',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseCard(markdown: string): Card {
|
|
37
|
+
const parsed = matter(markdown)
|
|
38
|
+
const fm = (parsed.data ?? {}) as CardFrontmatter
|
|
39
|
+
if (typeof fm.name !== 'string') {
|
|
40
|
+
throw new Error('CARD.md requires a "name" frontmatter field')
|
|
41
|
+
}
|
|
42
|
+
return { frontmatter: fm, body: parsed.content ?? '' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderCard(card: Card): string {
|
|
46
|
+
return matter.stringify(card.body, card.frontmatter as Record<string, unknown>)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function emptyCard(): Card {
|
|
50
|
+
return {
|
|
51
|
+
frontmatter: { ...DEFAULT_CARD.frontmatter },
|
|
52
|
+
body: DEFAULT_CARD.body,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Map a Card to the text-record key/value pairs we publish to .0g. */
|
|
57
|
+
export function cardToTextRecords(card: Card, agentEoa?: string): Record<string, string> {
|
|
58
|
+
const rec: Record<string, string> = {}
|
|
59
|
+
const fm = card.frontmatter
|
|
60
|
+
if (agentEoa) rec.address = agentEoa
|
|
61
|
+
if (fm.bio) rec['agent:bio'] = fm.bio
|
|
62
|
+
if (fm.skills?.length) rec['agent:skills'] = fm.skills.join(',')
|
|
63
|
+
if (fm.endpoints?.length) rec['agent:endpoints'] = fm.endpoints.join(',')
|
|
64
|
+
if (fm.avatar) rec.avatar = fm.avatar
|
|
65
|
+
if (fm.inft) rec['agent:inft'] = fm.inft
|
|
66
|
+
return rec
|
|
67
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface ActivityEntry {
|
|
5
|
+
ts: number
|
|
6
|
+
kind:
|
|
7
|
+
| 'wake'
|
|
8
|
+
| 'tool-call'
|
|
9
|
+
| 'tool-result'
|
|
10
|
+
| 'brain-response'
|
|
11
|
+
| 'error'
|
|
12
|
+
| 'context-compacted'
|
|
13
|
+
| 'auto-topup'
|
|
14
|
+
data: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ActivityLog {
|
|
18
|
+
private dirEnsured = false
|
|
19
|
+
|
|
20
|
+
constructor(private readonly path: string) {}
|
|
21
|
+
|
|
22
|
+
async append(entry: ActivityEntry): Promise<void> {
|
|
23
|
+
if (!this.dirEnsured) {
|
|
24
|
+
await mkdir(dirname(this.path), { recursive: true })
|
|
25
|
+
this.dirEnsured = true
|
|
26
|
+
}
|
|
27
|
+
await appendFile(this.path, `${JSON.stringify(entry)}\n`, 'utf8')
|
|
28
|
+
}
|
|
29
|
+
}
|