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.
Files changed (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. 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
+ }
@@ -0,0 +1,2 @@
1
+ export { Runtime, type RuntimeDeps } from './runtime'
2
+ export { ActivityLog, type ActivityEntry } from './activity'