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.
Files changed (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. 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
+ }