lyra-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 +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -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 +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -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.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -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.test.ts +43 -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 +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -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.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -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.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -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.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -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/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -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/sandbox.test.ts +386 -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.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -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.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- package/src/wallet/keystore.ts +50 -0
|
@@ -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 Sui 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,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { coerceBool, coerceInt } from './zod-helpers'
|
|
4
|
+
import { zodToJsonSchema } from './zod-schema'
|
|
5
|
+
|
|
6
|
+
describe('coerceBool', () => {
|
|
7
|
+
it('accepts native booleans', () => {
|
|
8
|
+
expect(coerceBool.parse(true)).toBe(true)
|
|
9
|
+
expect(coerceBool.parse(false)).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('coerces "true"/"false" strings (qwen3.6-plus quirk)', () => {
|
|
13
|
+
expect(coerceBool.parse('true')).toBe(true)
|
|
14
|
+
expect(coerceBool.parse('True')).toBe(true)
|
|
15
|
+
expect(coerceBool.parse('false')).toBe(false)
|
|
16
|
+
expect(coerceBool.parse('False')).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('coerces 0/1 + yes/no', () => {
|
|
20
|
+
expect(coerceBool.parse(1)).toBe(true)
|
|
21
|
+
expect(coerceBool.parse(0)).toBe(false)
|
|
22
|
+
expect(coerceBool.parse('yes')).toBe(true)
|
|
23
|
+
expect(coerceBool.parse('no')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('rejects garbage', () => {
|
|
27
|
+
expect(() => coerceBool.parse('maybe')).toThrow()
|
|
28
|
+
expect(() => coerceBool.parse({})).toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('zodToJsonSchema unwraps to type:boolean', () => {
|
|
32
|
+
const schema = z.object({ flag: coerceBool.optional() })
|
|
33
|
+
const json = zodToJsonSchema(schema)
|
|
34
|
+
expect((json.properties.flag as { type: string }).type).toBe('boolean')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('coerceInt', () => {
|
|
39
|
+
it('accepts native integers', () => {
|
|
40
|
+
expect(coerceInt.parse(0)).toBe(0)
|
|
41
|
+
expect(coerceInt.parse(42)).toBe(42)
|
|
42
|
+
expect(coerceInt.parse(-7)).toBe(-7)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('coerces "400"/"42" strings (qwen3.6-plus numeric quirk)', () => {
|
|
46
|
+
expect(coerceInt.parse('400')).toBe(400)
|
|
47
|
+
expect(coerceInt.parse(' 42 ')).toBe(42)
|
|
48
|
+
expect(coerceInt.parse('-7')).toBe(-7)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rejects floats + garbage strings', () => {
|
|
52
|
+
expect(() => coerceInt.parse('1.5')).toThrow()
|
|
53
|
+
expect(() => coerceInt.parse('abc')).toThrow()
|
|
54
|
+
expect(() => coerceInt.parse('')).toThrow()
|
|
55
|
+
expect(() => coerceInt.parse({})).toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('zodToJsonSchema unwraps to type:number', () => {
|
|
59
|
+
const schema = z.object({ count: coerceInt.optional() })
|
|
60
|
+
const json = zodToJsonSchema(schema)
|
|
61
|
+
expect((json.properties.count as { type: string }).type).toBe('number')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Some Sui 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 Sui
|
|
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 Sui 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,41 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { SWEEP_GAS_RESERVE_MIST, computeSweepAmount } from './drain'
|
|
3
|
+
|
|
4
|
+
const AGENT = `0x${'1'.repeat(64)}`
|
|
5
|
+
/** 1 SUI in MIST. */
|
|
6
|
+
const sui = (n: number): bigint => BigInt(Math.round(n * 1e9))
|
|
7
|
+
|
|
8
|
+
describe('computeSweepAmount', () => {
|
|
9
|
+
test('subtracts default gas reserve from balance when comfortable', () => {
|
|
10
|
+
const balanceMist = sui(0.1)
|
|
11
|
+
const r = computeSweepAmount({ balanceMist, agentAddress: AGENT })
|
|
12
|
+
expect(r.error).toBeUndefined()
|
|
13
|
+
expect(r.gasReserve).toBe(SWEEP_GAS_RESERVE_MIST)
|
|
14
|
+
expect(r.value).toBe(balanceMist - SWEEP_GAS_RESERVE_MIST)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('returns error string when balance below reserve', () => {
|
|
18
|
+
const balanceMist = SWEEP_GAS_RESERVE_MIST
|
|
19
|
+
const r = computeSweepAmount({ balanceMist, agentAddress: AGENT })
|
|
20
|
+
expect(r.value).toBe(0n)
|
|
21
|
+
expect(r.error).toContain('below gas reserve')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('honors gasReserveOverride', () => {
|
|
25
|
+
const balanceMist = sui(1)
|
|
26
|
+
const override = sui(0.005)
|
|
27
|
+
const r = computeSweepAmount({
|
|
28
|
+
balanceMist,
|
|
29
|
+
agentAddress: AGENT,
|
|
30
|
+
gasReserveOverride: override,
|
|
31
|
+
})
|
|
32
|
+
expect(r.gasReserve).toBe(override)
|
|
33
|
+
expect(r.value).toBe(balanceMist - override)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('error wording surfaces the agent address + balance + reserve', () => {
|
|
37
|
+
const r = computeSweepAmount({ balanceMist: 0n, agentAddress: AGENT })
|
|
38
|
+
expect(r.error).toContain(AGENT)
|
|
39
|
+
expect(r.error).toContain('0.000000 SUI')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
2
|
+
import { getSuiBalanceMist, keypairFromSecret, makeSuiClient } from '../chain'
|
|
3
|
+
import type { LyraNetwork } from '../config'
|
|
4
|
+
import { formatSui } from '../format'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sweep an agent's SUI balance to a recipient address. Reserves a fixed amount
|
|
8
|
+
* of MIST for the sweep transaction's own gas, so the resulting balance is "as
|
|
9
|
+
* close to 0 as the gas reserve allows" without underpaying.
|
|
10
|
+
*
|
|
11
|
+
* Used by `lyra drain` for fund recovery on a retiring agent.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface DrainAgentResult {
|
|
15
|
+
digest: string
|
|
16
|
+
amountSent: bigint
|
|
17
|
+
gasReserved: bigint
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Default MIST kept aside to cover the sweep transaction's gas. ~0.01 SUI. */
|
|
21
|
+
export const SWEEP_GAS_RESERVE_MIST = 10_000_000n
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pure helper: given balance + optional reserve override, return the value to
|
|
25
|
+
* send, the gas reserve, and an error message if the balance can't cover the
|
|
26
|
+
* sweep. Lifted out so it can be unit-tested without a live RPC.
|
|
27
|
+
*/
|
|
28
|
+
export function computeSweepAmount(opts: {
|
|
29
|
+
balanceMist: bigint
|
|
30
|
+
agentAddress: string
|
|
31
|
+
gasReserveOverride?: bigint
|
|
32
|
+
}): { value: bigint; gasReserve: bigint; error?: string } {
|
|
33
|
+
const gasReserve = opts.gasReserveOverride ?? SWEEP_GAS_RESERVE_MIST
|
|
34
|
+
if (opts.balanceMist <= gasReserve) {
|
|
35
|
+
return {
|
|
36
|
+
value: 0n,
|
|
37
|
+
gasReserve,
|
|
38
|
+
error: `agent ${opts.agentAddress} has ${formatSui(opts.balanceMist)} SUI; below gas reserve ${formatSui(gasReserve)} SUI`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { value: opts.balanceMist - gasReserve, gasReserve }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function drainAgentEOA(opts: {
|
|
45
|
+
network: LyraNetwork
|
|
46
|
+
secret: string
|
|
47
|
+
to: string
|
|
48
|
+
/** Override the gas reserve (in MIST). Default = SWEEP_GAS_RESERVE_MIST. */
|
|
49
|
+
gasReserveMist?: bigint
|
|
50
|
+
}): Promise<DrainAgentResult> {
|
|
51
|
+
const client = makeSuiClient(opts.network)
|
|
52
|
+
const keypair = keypairFromSecret(opts.secret)
|
|
53
|
+
const address = keypair.getPublicKey().toSuiAddress()
|
|
54
|
+
|
|
55
|
+
const balance = await getSuiBalanceMist(client, address)
|
|
56
|
+
const sweep = computeSweepAmount({
|
|
57
|
+
balanceMist: balance,
|
|
58
|
+
agentAddress: address,
|
|
59
|
+
gasReserveOverride: opts.gasReserveMist,
|
|
60
|
+
})
|
|
61
|
+
if (sweep.error) throw new Error(sweep.error)
|
|
62
|
+
|
|
63
|
+
const tx = new Transaction()
|
|
64
|
+
tx.setGasBudget(sweep.gasReserve)
|
|
65
|
+
const [coin] = tx.splitCoins(tx.gas, [tx.pure.u64(sweep.value)])
|
|
66
|
+
tx.transferObjects([coin], tx.pure.address(opts.to))
|
|
67
|
+
|
|
68
|
+
const result = await client.signAndExecuteTransaction({
|
|
69
|
+
signer: keypair,
|
|
70
|
+
transaction: tx,
|
|
71
|
+
options: { showEffects: true },
|
|
72
|
+
})
|
|
73
|
+
await client.waitForTransaction({ digest: result.digest })
|
|
74
|
+
|
|
75
|
+
return { digest: result.digest, amountSent: sweep.value, gasReserved: sweep.gasReserve }
|
|
76
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
|
|
4
|
+
import { type EncryptedKeystore, decryptKey, encryptKey } from './keystore'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sui agent wallet material. Callers reconstruct the keypair at point of use
|
|
8
|
+
* via `Ed25519Keypair.fromSecretKey(secret)` (or `keypairFromSecret`).
|
|
9
|
+
*/
|
|
10
|
+
export interface AgentWalletMaterial {
|
|
11
|
+
/** Bech32 Sui secret key (`suiprivkey1...`). */
|
|
12
|
+
secret: string
|
|
13
|
+
/** Sui address derived from the key (`0x`-prefixed). */
|
|
14
|
+
address: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a fresh Ed25519 agent keypair. Returns the bech32 secret
|
|
19
|
+
* (`suiprivkey1...`) and the derived Sui address.
|
|
20
|
+
*/
|
|
21
|
+
export function generateAgentKeypair(): { address: string; secret: string } {
|
|
22
|
+
const kp = Ed25519Keypair.generate()
|
|
23
|
+
return {
|
|
24
|
+
address: kp.getPublicKey().toSuiAddress(),
|
|
25
|
+
secret: kp.getSecretKey(),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Back-compat alias used across Lyra: an agent "wallet" is its keypair. */
|
|
30
|
+
export function generateAgentWallet(): AgentWalletMaterial {
|
|
31
|
+
return generateAgentKeypair()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function saveKeystore(
|
|
35
|
+
path: string,
|
|
36
|
+
secret: string,
|
|
37
|
+
passphrase: string,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
// Persist the raw secret string bytes; chain-agnostic AES-GCM keystore.
|
|
40
|
+
const secretBytes = new TextEncoder().encode(secret)
|
|
41
|
+
const encrypted = encryptKey(secretBytes, passphrase)
|
|
42
|
+
await mkdir(dirname(path), { recursive: true })
|
|
43
|
+
await writeFile(path, JSON.stringify(encrypted, null, 2), 'utf8')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function loadKeystore(path: string, passphrase: string): Promise<AgentWalletMaterial> {
|
|
47
|
+
let raw: string
|
|
48
|
+
try {
|
|
49
|
+
raw = await readFile(path, 'utf8')
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
52
|
+
throw new Error(`Keystore not found at ${path}`)
|
|
53
|
+
}
|
|
54
|
+
throw e
|
|
55
|
+
}
|
|
56
|
+
const encrypted = JSON.parse(raw) as EncryptedKeystore
|
|
57
|
+
const secretBytes = decryptKey(encrypted, passphrase)
|
|
58
|
+
const secret = new TextDecoder().decode(secretBytes)
|
|
59
|
+
const kp = Ed25519Keypair.fromSecretKey(secret)
|
|
60
|
+
return { secret, address: kp.getPublicKey().toSuiAddress() }
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { encryptKey, decryptKey, type EncryptedKeystore } from './keystore'
|
|
2
|
+
export {
|
|
3
|
+
generateAgentKeypair,
|
|
4
|
+
generateAgentWallet,
|
|
5
|
+
saveKeystore,
|
|
6
|
+
loadKeystore,
|
|
7
|
+
type AgentWalletMaterial,
|
|
8
|
+
} from './eoa'
|
|
9
|
+
export {
|
|
10
|
+
drainAgentEOA,
|
|
11
|
+
computeSweepAmount,
|
|
12
|
+
SWEEP_GAS_RESERVE_MIST,
|
|
13
|
+
type DrainAgentResult,
|
|
14
|
+
} from './drain'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { decryptKey, encryptKey } from './keystore'
|
|
4
|
+
|
|
5
|
+
test('encrypt + decrypt round-trip', () => {
|
|
6
|
+
const pk = new Uint8Array(randomBytes(32))
|
|
7
|
+
const encrypted = encryptKey(pk, 'test-passphrase')
|
|
8
|
+
expect(encrypted.version).toBe(1)
|
|
9
|
+
const decrypted = decryptKey(encrypted, 'test-passphrase')
|
|
10
|
+
expect(Buffer.from(decrypted).toString('hex')).toBe(Buffer.from(pk).toString('hex'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('wrong passphrase fails', () => {
|
|
14
|
+
const pk = new Uint8Array(32).fill(0xab)
|
|
15
|
+
const encrypted = encryptKey(pk, 'right')
|
|
16
|
+
expect(() => decryptKey(encrypted, 'wrong')).toThrow()
|
|
17
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simple AES-256-GCM keystore for the agent EOA privkey. Passphrase-derived
|
|
5
|
+
* key via scrypt. Format packs salt || iv || tag || ciphertext in base64.
|
|
6
|
+
*/
|
|
7
|
+
export interface EncryptedKeystore {
|
|
8
|
+
version: 1
|
|
9
|
+
/** Base64-encoded `salt(16) || iv(12) || tag(16) || ciphertext`. */
|
|
10
|
+
blob: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KEY_LEN = 32
|
|
14
|
+
const SCRYPT_N = 2 ** 15
|
|
15
|
+
const SCRYPT_R = 8
|
|
16
|
+
const SCRYPT_P = 1
|
|
17
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
function derive(passphrase: string, salt: Buffer): Buffer {
|
|
20
|
+
return scryptSync(passphrase, salt, KEY_LEN, {
|
|
21
|
+
N: SCRYPT_N,
|
|
22
|
+
r: SCRYPT_R,
|
|
23
|
+
p: SCRYPT_P,
|
|
24
|
+
maxmem: SCRYPT_MAXMEM,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encryptKey(privkey: Uint8Array, passphrase: string): EncryptedKeystore {
|
|
29
|
+
const salt = randomBytes(16)
|
|
30
|
+
const iv = randomBytes(12)
|
|
31
|
+
const key = derive(passphrase, salt)
|
|
32
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
33
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(privkey)), cipher.final()])
|
|
34
|
+
const tag = cipher.getAuthTag()
|
|
35
|
+
const blob = Buffer.concat([salt, iv, tag, ct]).toString('base64')
|
|
36
|
+
return { version: 1, blob }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function decryptKey(keystore: EncryptedKeystore, passphrase: string): Uint8Array {
|
|
40
|
+
if (keystore.version !== 1) throw new Error(`Unsupported keystore version: ${keystore.version}`)
|
|
41
|
+
const buf = Buffer.from(keystore.blob, 'base64')
|
|
42
|
+
const salt = buf.subarray(0, 16)
|
|
43
|
+
const iv = buf.subarray(16, 28)
|
|
44
|
+
const tag = buf.subarray(28, 44)
|
|
45
|
+
const ct = buf.subarray(44)
|
|
46
|
+
const key = derive(passphrase, salt)
|
|
47
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
48
|
+
decipher.setAuthTag(tag)
|
|
49
|
+
return new Uint8Array(Buffer.concat([decipher.update(ct), decipher.final()]))
|
|
50
|
+
}
|