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,200 @@
1
+ import type { ToolCall, ToolResult } from './types'
2
+
3
+ /**
4
+ * v0.21.2/v0.21.3: web.fetch can fail in many ways. Some failures are transient
5
+ * (Cloudflare interstitial, Google bot block, captcha, rate-limit, HTTP 5xx,
6
+ * timeout, network error) and the browser primitive often recovers because it
7
+ * runs a real headless Chromium that handles cookies, JS challenges, and uses
8
+ * different DNS / network stack. Other failures are permanent (invalid URL,
9
+ * unsupported protocol, private/loopback host) and re-trying via browser would
10
+ * not help.
11
+ *
12
+ * The dispatcher (chat.tsx onToolCall + build-runtime.ts onToolCall) calls
13
+ * `runEscalation`. Both wrappers share orchestration (pre/post hooks, dispatch,
14
+ * activity append, merge) here; only the UX side effects (state.pushRow vs
15
+ * events.publish) are passed as callbacks. Brain sees one merged tool message
16
+ * regardless of how many wrapper-level retries happened.
17
+ *
18
+ * Live drives showed Qwen3.6 ignores conditional escalation rules in long
19
+ * contexts: pre-fix, the brain ended turn with toolCalls=0 on a blocked fetch
20
+ * and replied "(no reply)" / memory disclaimer. v0.21.2 escalated on the
21
+ * structured `blocked:true` signal. v0.21.3 extends to ANY transient web.fetch
22
+ * failure ("ensure browser routing is active so agent proactively go with
23
+ * browser every time it gets any hiccup or issues").
24
+ */
25
+
26
+ const ESCALATED_ID_PREFIX = 'auto-escalate-'
27
+
28
+ /**
29
+ * Errors web.fetch returns for input it must refuse outright. Browser would
30
+ * not fix them and we don't want to drive it to file:// / private IPs / etc.
31
+ * Match-prefix on the error string.
32
+ */
33
+ const PERMANENT_FAILURE_PATTERNS: readonly RegExp[] = [
34
+ /^invalid URL$/i,
35
+ /^unsupported protocol/i,
36
+ /^host blocked/i,
37
+ ]
38
+
39
+ export interface FetchEscalation {
40
+ needed: boolean
41
+ escalatedCall?: ToolCall
42
+ reason?: string
43
+ url?: string
44
+ }
45
+
46
+ /**
47
+ * Decide whether a web.fetch result warrants an automatic browser.navigate
48
+ * retry. Fires on:
49
+ * - bot-block / captcha / rate-limit interstitial (data.blocked === true)
50
+ * - any web.fetch failure (result.ok === false) UNLESS the error is one of
51
+ * the permanent-failure patterns above (invalid URL, unsupported
52
+ * protocol, host blocked for security)
53
+ *
54
+ * URL preference order: `result.data.final_url` (post-redirect canonical URL),
55
+ * then `call.args.url` (original request). If neither is present we cannot
56
+ * synthesize a browser.navigate call, so escalation is skipped. Recursion
57
+ * guard refuses to re-escalate calls that are themselves synthetic
58
+ * escalations.
59
+ */
60
+ export function detectFetchEscalation(call: ToolCall, result: ToolResult): FetchEscalation {
61
+ if (call.name !== 'web.fetch') return { needed: false }
62
+ if (typeof call.id === 'string' && call.id.startsWith(ESCALATED_ID_PREFIX))
63
+ return { needed: false }
64
+
65
+ const data = result.data as Record<string, unknown> | undefined
66
+ const finalUrl = data && typeof data.final_url === 'string' ? data.final_url : null
67
+ const argUrl = extractUrlFromCallArgs(call)
68
+ const url = finalUrl && finalUrl.length > 0 ? finalUrl : argUrl
69
+ if (!url) return { needed: false }
70
+
71
+ if (result.ok && data?.blocked === true) {
72
+ const reason = typeof data.block_reason === 'string' ? data.block_reason : 'unknown'
73
+ return synthesize(call, url, reason)
74
+ }
75
+ if (!result.ok) {
76
+ const error = typeof result.error === 'string' ? result.error : 'unknown'
77
+ if (PERMANENT_FAILURE_PATTERNS.some(re => re.test(error))) return { needed: false }
78
+ return synthesize(call, url, classifyError(error))
79
+ }
80
+ return { needed: false }
81
+ }
82
+
83
+ function synthesize(call: ToolCall, url: string, reason: string): FetchEscalation {
84
+ return {
85
+ needed: true,
86
+ escalatedCall: {
87
+ id: `${ESCALATED_ID_PREFIX}${call.id}`,
88
+ name: 'browser.navigate',
89
+ args: { url },
90
+ },
91
+ reason,
92
+ url,
93
+ }
94
+ }
95
+
96
+ function extractUrlFromCallArgs(call: ToolCall): string | null {
97
+ if (!call.args || typeof call.args !== 'object') return null
98
+ const args = call.args as Record<string, unknown>
99
+ return typeof args.url === 'string' && args.url.length > 0 ? args.url : null
100
+ }
101
+
102
+ /**
103
+ * Classify a non-block web.fetch error into a short symbolic reason. Keeps
104
+ * the merged auto_escalation.reason field readable for the brain instead of
105
+ * pasting raw stack traces.
106
+ */
107
+ function classifyError(error: string): string {
108
+ if (/^timeout/i.test(error)) return 'timeout'
109
+ const httpMatch = error.match(/^http\s+(\d{3})/i)
110
+ if (httpMatch) return `http-${httpMatch[1]}`
111
+ if (/dns|enotfound|getaddrinfo/i.test(error)) return 'dns'
112
+ if (/connection|econnrefused|econnreset|socket/i.test(error)) return 'connection'
113
+ if (/aborted|abortError/i.test(error)) return 'aborted'
114
+ return 'fetch-error'
115
+ }
116
+
117
+ /**
118
+ * Merge a failed/blocked web.fetch result with an escalated browser.navigate
119
+ * result into the single tool message the brain receives. Original `data.body`
120
+ * (the bot-block markdown, if any) is preserved alongside `data.auto_escalation`
121
+ * so the brain has full context to call `browser.snapshot` next.
122
+ *
123
+ * `mergedResult.ok` reflects the ESCALATED call's success: if browser.navigate
124
+ * also failed (no agent-browser binary, headless Chrome flake, etc.), the
125
+ * brain sees ok:false and can degrade gracefully.
126
+ */
127
+ export function mergeEscalationResult(
128
+ original: ToolResult,
129
+ escalated: ToolResult,
130
+ escalation: FetchEscalation,
131
+ ): ToolResult {
132
+ const originalData =
133
+ original.data && typeof original.data === 'object'
134
+ ? (original.data as Record<string, unknown>)
135
+ : {}
136
+ const merged: ToolResult = {
137
+ ok: escalated.ok,
138
+ data: {
139
+ ...originalData,
140
+ auto_escalation: {
141
+ triggered: true,
142
+ from: 'web.fetch',
143
+ to: 'browser.navigate',
144
+ reason: escalation.reason ?? 'unknown',
145
+ url: escalation.url ?? '',
146
+ original_error: original.error,
147
+ result: escalated,
148
+ },
149
+ },
150
+ }
151
+ if (!escalated.ok) {
152
+ merged.error = `auto-escalation failed: ${escalated.error ?? 'unknown'}`
153
+ }
154
+ return merged
155
+ }
156
+
157
+ export interface EscalationDeps {
158
+ /** Run pre-tool hooks (permission, sandbox bridge). Mirrors HookBus shape. */
159
+ runPreCall: (call: ToolCall) => Promise<{ short?: ToolResult; call?: ToolCall }>
160
+ /** Run post-tool hooks (audit, telemetry). */
161
+ runPostCall: (call: ToolCall, result: ToolResult) => Promise<void>
162
+ /** Dispatch the (possibly hook-replaced) call to the tool registry. */
163
+ dispatch: (call: ToolCall) => Promise<ToolResult>
164
+ /** Append a `kind:'tool-call'` activity entry tagged `autoEscalated:true`. */
165
+ appendActivity: (call: ToolCall, result: ToolResult) => Promise<void>
166
+ /** UX sink: notify the operator a follow-up call is starting. */
167
+ onStart: (call: ToolCall) => void
168
+ /** UX sink: notify the operator the follow-up call finished. */
169
+ onEnd: (call: ToolCall, result: ToolResult, durationMs: number) => void
170
+ }
171
+
172
+ /**
173
+ * Orchestrate the escalated browser.navigate dispatch on behalf of the brain
174
+ * wrapper. Owns: UX start → pre-hook → dispatch (or short-circuit) → post-hook →
175
+ * activity append → UX end → merge. Both `chat.tsx` and `build-runtime.ts` call
176
+ * this so any future change (extra hook, retry policy, telemetry) lands in one
177
+ * place instead of drifting between TUI and gateway paths.
178
+ */
179
+ export async function runEscalation(
180
+ escalation: FetchEscalation,
181
+ originalResult: ToolResult,
182
+ deps: EscalationDeps,
183
+ ): Promise<ToolResult> {
184
+ if (!escalation.needed || !escalation.escalatedCall) return originalResult
185
+ const synthCall = escalation.escalatedCall
186
+ const startedAt = Date.now()
187
+ deps.onStart(synthCall)
188
+ const pre = await deps.runPreCall(synthCall)
189
+ const effective = pre.call ?? synthCall
190
+ let result: ToolResult
191
+ if (pre.short) {
192
+ result = pre.short
193
+ } else {
194
+ result = await deps.dispatch(effective)
195
+ await deps.runPostCall(effective, result)
196
+ }
197
+ await deps.appendActivity(effective, result)
198
+ deps.onEnd(effective, result, Date.now() - startedAt)
199
+ return mergeEscalationResult(originalResult, result, escalation)
200
+ }
@@ -0,0 +1,11 @@
1
+ export type { ToolCall, ToolDef, ToolResult, ToolSchema, JSONSchema } from './types'
2
+ export { ToolRegistry } from './registry'
3
+ export { zodToJsonSchema } from './zod-schema'
4
+ export { coerceBool, coerceInt } from './zod-helpers'
5
+ export {
6
+ detectFetchEscalation,
7
+ mergeEscalationResult,
8
+ runEscalation,
9
+ type EscalationDeps,
10
+ type FetchEscalation,
11
+ } from './escalation'
@@ -0,0 +1,152 @@
1
+ import type { ToolCall, ToolDef, ToolResult, ToolSchema } from './types'
2
+ import { zodToJsonSchema } from './zod-schema'
3
+
4
+ interface EnablementRule {
5
+ pattern: string
6
+ regex: RegExp | null
7
+ enabled: boolean
8
+ }
9
+
10
+ /**
11
+ * Symbol-based tool registry. Tools self-register at import time (plugins
12
+ * contribute by importing their entry module, which triggers the registry
13
+ * call). Glob-style enable/disable via `config.tools` is applied at `list()`.
14
+ *
15
+ * Deferred-tool model (Claude Code-compatible): tools default to alwaysLoad
16
+ * (eager). A tool with `shouldDefer: true` (and not `alwaysLoad: true`) is
17
+ * hidden from `schemas()` until `unlock(name)` is called. The brain hydrates
18
+ * deferred schemas via the `tool.search` meta-tool.
19
+ */
20
+ export class ToolRegistry {
21
+ private readonly tools = new Map<string, ToolDef>()
22
+ private readonly rules: EnablementRule[]
23
+ private readonly unlocked = new Set<string>()
24
+
25
+ constructor(enabled: Record<string, boolean> = {}) {
26
+ this.rules = Object.entries(enabled).map(([pattern, on]) => ({
27
+ pattern,
28
+ regex: pattern.includes('*') ? new RegExp(`^${pattern.replace(/\*/g, '.*')}$`) : null,
29
+ enabled: on,
30
+ }))
31
+ }
32
+
33
+ register(def: ToolDef): void {
34
+ if (this.tools.has(def.name)) {
35
+ throw new Error(`Tool already registered: ${def.name}`)
36
+ }
37
+ this.tools.set(def.name, def as ToolDef<unknown>)
38
+ }
39
+
40
+ find(name: string): ToolDef | undefined {
41
+ const tool = this.tools.get(name)
42
+ if (!tool) return undefined
43
+ if (!this.isEnabled(name)) return undefined
44
+ return tool
45
+ }
46
+
47
+ /** All registered + enabled tools, regardless of defer state. */
48
+ list(): ToolDef[] {
49
+ return [...this.tools.values()].filter(t => this.isEnabled(t.name))
50
+ }
51
+
52
+ /** Tools whose schemas the brain should see this turn. */
53
+ loadedList(): ToolDef[] {
54
+ return this.list().filter(t => this.isLoaded(t))
55
+ }
56
+
57
+ /** OpenAI-format schemas for the eager (loaded) set; sent to Mantle Compute. */
58
+ schemas(): ToolSchema[] {
59
+ return this.loadedList().map(t => ({
60
+ type: 'function',
61
+ function: {
62
+ name: t.name,
63
+ description: t.description,
64
+ parameters: t.parametersOverride ?? zodToJsonSchema(t.schema),
65
+ },
66
+ }))
67
+ }
68
+
69
+ /**
70
+ * Mark a deferred tool as loaded so its schema appears in the next
71
+ * `schemas()` call. Idempotent.
72
+ */
73
+ unlock(name: string): boolean {
74
+ const tool = this.tools.get(name)
75
+ if (!tool) return false
76
+ this.unlocked.add(name)
77
+ return true
78
+ }
79
+
80
+ /** Whether the brain currently sees the tool's schema. */
81
+ isLoaded(tool: ToolDef): boolean {
82
+ if (tool.shouldDefer && tool.alwaysLoad !== true) {
83
+ return this.unlocked.has(tool.name)
84
+ }
85
+ return true
86
+ }
87
+
88
+ /**
89
+ * Search the registry for tools matching either an exact-name select query
90
+ * (`select:fs.read,fs.write`) or a free-text keyword query that matches
91
+ * names, descriptions, and searchHints.
92
+ */
93
+ search(query: string, maxResults = 5): ToolDef[] {
94
+ const trimmed = query.trim()
95
+ if (trimmed.startsWith('select:')) {
96
+ const names = trimmed
97
+ .slice('select:'.length)
98
+ .split(',')
99
+ .map(s => s.trim())
100
+ .filter(Boolean)
101
+ return names
102
+ .map(n => this.tools.get(n))
103
+ .filter((t): t is ToolDef => !!t && this.isEnabled(t.name))
104
+ .slice(0, maxResults)
105
+ }
106
+ const required: string[] = []
107
+ const keywords: string[] = []
108
+ for (const part of trimmed.toLowerCase().split(/\s+/).filter(Boolean)) {
109
+ if (part.startsWith('+')) required.push(part.slice(1))
110
+ else keywords.push(part)
111
+ }
112
+ const scored: { tool: ToolDef; score: number }[] = []
113
+ for (const tool of this.list()) {
114
+ const haystack = [tool.name, tool.description, tool.searchHint ?? ''].join(' ').toLowerCase()
115
+ if (!required.every(r => haystack.includes(r))) continue
116
+ let score = required.length > 0 ? 1 : 0
117
+ for (const kw of keywords) {
118
+ if (haystack.includes(kw)) score++
119
+ }
120
+ if (score > 0) scored.push({ tool, score })
121
+ }
122
+ return scored
123
+ .sort((a, b) => b.score - a.score)
124
+ .slice(0, maxResults)
125
+ .map(c => c.tool)
126
+ }
127
+
128
+ async dispatch(call: ToolCall): Promise<ToolResult> {
129
+ const tool = this.find(call.name)
130
+ if (!tool) return { ok: false, error: `Unknown tool: ${call.name}` }
131
+ const parsed = tool.schema.safeParse(call.args)
132
+ if (!parsed.success) {
133
+ return { ok: false, error: `Invalid args: ${parsed.error.message}` }
134
+ }
135
+ try {
136
+ return await tool.handler(parsed.data)
137
+ } catch (e) {
138
+ const msg = e instanceof Error ? e.message : String(e)
139
+ return { ok: false, error: msg }
140
+ }
141
+ }
142
+
143
+ private isEnabled(name: string): boolean {
144
+ // Right-most matching rule wins. No explicit rule = enabled by default.
145
+ let decision: boolean | null = null
146
+ for (const rule of this.rules) {
147
+ const matches = rule.regex ? rule.regex.test(name) : rule.pattern === name
148
+ if (matches) decision = rule.enabled
149
+ }
150
+ return decision ?? true
151
+ }
152
+ }
@@ -0,0 +1,65 @@
1
+ import type { z } from 'zod'
2
+
3
+ /** OpenAI-compatible JSON Schema for a function parameter spec. */
4
+ export interface JSONSchema {
5
+ type: 'object'
6
+ properties: Record<string, unknown>
7
+ required?: string[]
8
+ additionalProperties?: boolean
9
+ description?: string
10
+ }
11
+
12
+ /** Shape we hand to the brain when asking it to plan with tools. */
13
+ export interface ToolSchema {
14
+ type: 'function'
15
+ function: {
16
+ name: string
17
+ description: string
18
+ parameters: JSONSchema
19
+ }
20
+ }
21
+
22
+ export interface ToolCall {
23
+ id: string
24
+ name: string
25
+ args: unknown
26
+ }
27
+
28
+ export interface ToolDef<TArgs = unknown> {
29
+ name: string
30
+ description: string
31
+ /** zod schema the runtime uses to both validate AND build JSONSchema for the brain. */
32
+ schema: z.ZodType<TArgs>
33
+ handler: (args: TArgs) => Promise<ToolResult> | ToolResult
34
+ /**
35
+ * When set with `shouldDefer`, overrides the deferral so the tool's schema
36
+ * still ships every turn. Has no effect when `shouldDefer` is false/unset.
37
+ */
38
+ alwaysLoad?: boolean
39
+ /**
40
+ * Hide this tool's schema by default; the brain only sees it after
41
+ * `tool.search` matches it (mirrors Claude Code's shouldDefer). Combine with
42
+ * `alwaysLoad: true` to force eager loading even though the tool is meant
43
+ * to be searchable.
44
+ */
45
+ shouldDefer?: boolean
46
+ /**
47
+ * 3-10 word hint used by `tool.search` keyword matching when this tool is
48
+ * deferred. Should describe domain ("filesystem read text"), not phrasing.
49
+ */
50
+ searchHint?: string
51
+ /**
52
+ * Optional JSON Schema override for tools whose param shape isn't expressed
53
+ * as a top-level `z.object({})` (MCP tools, dynamically-discovered remote
54
+ * tools). When set, `registry.schemas()` and `tool.search` use this verbatim
55
+ * instead of running `zodToJsonSchema(schema)`. The `schema.safeParse()`
56
+ * still gates dispatch.
57
+ */
58
+ parametersOverride?: JSONSchema
59
+ }
60
+
61
+ export interface ToolResult {
62
+ ok: boolean
63
+ data?: unknown
64
+ error?: string
65
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Some Mantle Compute providers (qwen3.6-plus among them) serialize tool-call
5
+ * boolean args as the strings "true"/"false" instead of JSON booleans. This
6
+ * accepts either form and falls back to actual booleans + 0/1 numbers.
7
+ */
8
+ export const coerceBool: z.ZodType<boolean> = z.preprocess(v => {
9
+ if (typeof v === 'boolean') return v
10
+ if (typeof v === 'number') return v !== 0
11
+ if (typeof v === 'string') {
12
+ const lower = v.trim().toLowerCase()
13
+ if (lower === 'true' || lower === '1' || lower === 'yes') return true
14
+ if (lower === 'false' || lower === '0' || lower === 'no') return false
15
+ }
16
+ return v
17
+ }, z.boolean()) as unknown as z.ZodType<boolean>
18
+
19
+ /**
20
+ * Same shape as coerceBool but for integers. qwen3.6-plus and other Mantle
21
+ * Compute providers sometimes serialize numeric tool-call args as strings
22
+ * ("400" instead of 400). zod's z.number() rejects them with
23
+ * "Expected number, received string". Wrap any numeric tool arg with
24
+ * `coerceInt` (or `coerceInt.refine(n => n > 0, 'must be positive')`) so the
25
+ * validation passes regardless of how the brain stringifies it.
26
+ */
27
+ export const coerceInt: z.ZodType<number> = z.preprocess(v => {
28
+ if (typeof v === 'number') return v
29
+ if (typeof v === 'string') {
30
+ const trimmed = v.trim()
31
+ if (trimmed === '') return v
32
+ const n = Number(trimmed)
33
+ if (Number.isFinite(n) && Math.trunc(n) === n) return n
34
+ }
35
+ return v
36
+ }, z.number().int()) as unknown as z.ZodType<number>
@@ -0,0 +1,99 @@
1
+ import type { z } from 'zod'
2
+ import type { JSONSchema } from './types'
3
+
4
+ /**
5
+ * Convert a zod object schema to the minimal JSON Schema dialect Mantle Compute
6
+ * (and the OpenAI tool-calling format) expects. Handles: string, number,
7
+ * boolean, enum, optional, array, nested object. Good enough for phase 1-3
8
+ * MVP tools; revisit when we need deeper schema features.
9
+ */
10
+ export function zodToJsonSchema(schema: z.ZodType, description?: string): JSONSchema {
11
+ const shape = unwrapObjectShape(schema)
12
+ if (!shape) throw new Error('Top-level tool schema must be a z.object({ ... })')
13
+ return objectShapeToJson(shape, description) as unknown as JSONSchema
14
+ }
15
+
16
+ type ZodObjectLike = { _def: { typeName: string; shape: () => Record<string, z.ZodType> } }
17
+
18
+ function unwrapObjectShape(schema: z.ZodType): Record<string, z.ZodType> | null {
19
+ const s = schema as unknown as ZodObjectLike
20
+ if (s._def?.typeName === 'ZodObject') return s._def.shape()
21
+ return null
22
+ }
23
+
24
+ function unwrapOptional(schema: z.ZodType): { schema: z.ZodType; optional: boolean } {
25
+ const s = schema as unknown as {
26
+ _def: { typeName: string; innerType?: z.ZodType; schema?: z.ZodType }
27
+ }
28
+ if (s._def?.typeName === 'ZodOptional' || s._def?.typeName === 'ZodDefault') {
29
+ return { schema: s._def.innerType!, optional: true }
30
+ }
31
+ if (s._def?.typeName === 'ZodEffects') {
32
+ const inner = unwrapOptional(s._def.schema!)
33
+ return inner
34
+ }
35
+ return { schema, optional: false }
36
+ }
37
+
38
+ function objectShapeToJson(
39
+ shape: Record<string, z.ZodType>,
40
+ description?: string,
41
+ ): Record<string, unknown> {
42
+ const properties: Record<string, unknown> = {}
43
+ const required: string[] = []
44
+ for (const [key, value] of Object.entries(shape)) {
45
+ const { schema: prop, optional } = unwrapOptional(value)
46
+ properties[key] = zodTypeToJson(prop)
47
+ if (!optional) required.push(key)
48
+ }
49
+ return {
50
+ type: 'object',
51
+ properties,
52
+ ...(required.length ? { required } : {}),
53
+ additionalProperties: false,
54
+ ...(description ? { description } : {}),
55
+ }
56
+ }
57
+
58
+ function zodTypeToJson(schema: z.ZodType): unknown {
59
+ const s = schema as unknown as {
60
+ _def: {
61
+ typeName: string
62
+ description?: string
63
+ values?: string[]
64
+ type?: z.ZodType
65
+ shape?: () => Record<string, z.ZodType>
66
+ }
67
+ }
68
+ const t = s._def.typeName
69
+ const description = s._def.description
70
+
71
+ switch (t) {
72
+ case 'ZodString':
73
+ return { type: 'string', ...(description ? { description } : {}) }
74
+ case 'ZodNumber':
75
+ return { type: 'number', ...(description ? { description } : {}) }
76
+ case 'ZodBoolean':
77
+ return { type: 'boolean', ...(description ? { description } : {}) }
78
+ case 'ZodEnum':
79
+ return {
80
+ type: 'string',
81
+ enum: s._def.values,
82
+ ...(description ? { description } : {}),
83
+ }
84
+ case 'ZodArray':
85
+ return {
86
+ type: 'array',
87
+ items: zodTypeToJson(s._def.type!),
88
+ ...(description ? { description } : {}),
89
+ }
90
+ case 'ZodObject':
91
+ return objectShapeToJson(s._def.shape?.() ?? {}, description)
92
+ case 'ZodEffects': {
93
+ const inner = (s._def as unknown as { schema: z.ZodType }).schema
94
+ return zodTypeToJson(inner)
95
+ }
96
+ default:
97
+ return { description: description ?? 'unspecified' }
98
+ }
99
+ }
@@ -0,0 +1,79 @@
1
+ import { type Address, type Hex, formatEther } from 'viem'
2
+ import { getGasPriceWithFloor, makeViemClients } from '../chain'
3
+ import type { NebulaNetwork } from '../config'
4
+ import { waitForReceiptResilient } from '../identity/receipt'
5
+
6
+ /**
7
+ * Sweep an agent EOA's native balance to a target address. Reserves enough
8
+ * for the sweep tx itself (21000 gas at the live max-fee), so the resulting
9
+ * balance is "as close to 0 as the gas reserve allows" without underpaying.
10
+ *
11
+ * Used by `nebula drain` for fund recovery on a retiring agent. Does not
12
+ * touch the compute ledger; that's `nebula ledger refund`.
13
+ */
14
+
15
+ export interface DrainAgentResult {
16
+ txHash: Hex
17
+ amountSent: bigint
18
+ gasReserved: bigint
19
+ }
20
+
21
+ export const SWEEP_GAS_LIMIT = 21_000n
22
+
23
+ /**
24
+ * Pure helper: given balance + gasPrice + optional override, return the value
25
+ * to send, the gas reserve, and an error message if the balance can't cover
26
+ * the sweep. Lifted out of drainAgentEOA so it can be unit-tested without a
27
+ * live RPC.
28
+ */
29
+ export function computeSweepAmount(opts: {
30
+ balance: bigint
31
+ gasPrice: bigint
32
+ agentAddress: Address
33
+ gasReserveOverride?: bigint
34
+ }): { value: bigint; gasReserve: bigint; error?: string } {
35
+ const gasReserve = opts.gasReserveOverride ?? SWEEP_GAS_LIMIT * opts.gasPrice
36
+ if (opts.balance <= gasReserve) {
37
+ return {
38
+ value: 0n,
39
+ gasReserve,
40
+ error: `agent EOA ${opts.agentAddress} has ${formatEther(opts.balance)} Mantle; below gas reserve ${formatEther(gasReserve)} Mantle`,
41
+ }
42
+ }
43
+ return { value: opts.balance - gasReserve, gasReserve }
44
+ }
45
+
46
+ export async function drainAgentEOA(opts: {
47
+ network: NebulaNetwork
48
+ privkeyHex: Hex
49
+ to: Address
50
+ /** Override the gas reserve (in wei). Default = 21000 * live max-fee. */
51
+ gasReserveWei?: bigint
52
+ }): Promise<DrainAgentResult> {
53
+ const { account, publicClient, walletClient, chain } = makeViemClients({
54
+ network: opts.network,
55
+ privkeyHex: opts.privkeyHex,
56
+ })
57
+
58
+ const balance = await publicClient.getBalance({ address: account.address })
59
+ const gasPrice = await getGasPriceWithFloor(publicClient)
60
+ const sweep = computeSweepAmount({
61
+ balance,
62
+ gasPrice,
63
+ agentAddress: account.address,
64
+ gasReserveOverride: opts.gasReserveWei,
65
+ })
66
+ if (sweep.error) throw new Error(sweep.error)
67
+
68
+ const txHash = await walletClient.sendTransaction({
69
+ account,
70
+ chain,
71
+ to: opts.to,
72
+ value: sweep.value,
73
+ gas: SWEEP_GAS_LIMIT,
74
+ maxFeePerGas: gasPrice,
75
+ maxPriorityFeePerGas: gasPrice,
76
+ })
77
+ await waitForReceiptResilient(publicClient, txHash)
78
+ return { txHash, amountSent: sweep.value, gasReserved: sweep.gasReserve }
79
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { bytesToHex, hexToBytes } from 'viem'
4
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
5
+ import { type EncryptedKeystore, decryptKey, encryptKey } from './keystore'
6
+
7
+ /**
8
+ * Library-agnostic agent wallet material. Callers instantiate the right
9
+ * chain library (viem `privateKeyToAccount`, ethers `new Wallet(hex)`, etc.)
10
+ * at point of use.
11
+ */
12
+ export interface AgentWalletMaterial {
13
+ /** 0x-prefixed hex private key. */
14
+ privkeyHex: `0x${string}`
15
+ /** EIP-55 address derived from the key. */
16
+ address: `0x${string}`
17
+ }
18
+
19
+ export function generateAgentWallet(): AgentWalletMaterial {
20
+ const privkeyHex = generatePrivateKey()
21
+ const account = privateKeyToAccount(privkeyHex)
22
+ return { privkeyHex, address: account.address }
23
+ }
24
+
25
+ export async function saveKeystore(
26
+ path: string,
27
+ privkeyHex: string,
28
+ passphrase: string,
29
+ ): Promise<void> {
30
+ const privkey = hexToBytes(privkeyHex as `0x${string}`)
31
+ const encrypted = encryptKey(privkey, passphrase)
32
+ await mkdir(dirname(path), { recursive: true })
33
+ await writeFile(path, JSON.stringify(encrypted, null, 2), 'utf8')
34
+ }
35
+
36
+ export async function loadKeystore(path: string, passphrase: string): Promise<AgentWalletMaterial> {
37
+ let raw: string
38
+ try {
39
+ raw = await readFile(path, 'utf8')
40
+ } catch (e) {
41
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
42
+ throw new Error(`Keystore not found at ${path}`)
43
+ }
44
+ throw e
45
+ }
46
+ const encrypted = JSON.parse(raw) as EncryptedKeystore
47
+ const privkey = decryptKey(encrypted, passphrase)
48
+ const privkeyHex = bytesToHex(privkey)
49
+ const account = privateKeyToAccount(privkeyHex)
50
+ return { privkeyHex, address: account.address }
51
+ }