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,255 @@
1
+ /**
2
+ * Shared slash-command registry. The TUI autocomplete popup, TG
3
+ * `setMyCommands` registration, and the various bypass dispatchers all read
4
+ * from this single source so command lists never drift between surfaces.
5
+ */
6
+
7
+ export type CommandSurface = 'tui' | 'tg'
8
+
9
+ /**
10
+ * - `local` : runs in the CLI process (TUI handler, local TG via chat-telegram)
11
+ * - `gateway`: runs in the gateway process (sandbox harness or local-mode gateway daemon)
12
+ * - `both` : valid in either surface; routing decided by where the command arrives
13
+ */
14
+ export type CommandScope = 'local' | 'gateway' | 'both'
15
+
16
+ export interface SlashCommand {
17
+ /** Command name without leading slash, e.g. "yolo". Lowercased. */
18
+ name: string
19
+ /** One-line human description used in TUI menu + TG client menu. */
20
+ description: string
21
+ /** Surfaces the command should appear in for discovery. */
22
+ surfaces: CommandSurface[]
23
+ /** Where the command actually executes. */
24
+ scope: CommandScope
25
+ /** True when the command short-circuits before brain.infer (control commands). */
26
+ bypassesBrain: boolean
27
+ /** Optional argument hint shown next to the name in menus, e.g. "off|prompt|strict". */
28
+ argHint?: string
29
+ }
30
+
31
+ export const COMMAND_REGISTRY: SlashCommand[] = [
32
+ {
33
+ name: 'yolo',
34
+ description: 'Toggle approval prompts on/off',
35
+ surfaces: ['tui', 'tg'],
36
+ scope: 'both',
37
+ bypassesBrain: true,
38
+ },
39
+ {
40
+ name: 'perms',
41
+ description: 'Set permission mode',
42
+ surfaces: ['tui', 'tg'],
43
+ scope: 'both',
44
+ bypassesBrain: true,
45
+ argHint: 'off|prompt|strict',
46
+ },
47
+ {
48
+ name: 'reset',
49
+ description: 'Clear conversation history for this channel',
50
+ surfaces: ['tui', 'tg'],
51
+ scope: 'both',
52
+ bypassesBrain: true,
53
+ },
54
+ {
55
+ name: 'sync',
56
+ description: 'Force memory sync to Sui Storage',
57
+ surfaces: ['tui'],
58
+ scope: 'local',
59
+ bypassesBrain: true,
60
+ },
61
+ {
62
+ name: 'model',
63
+ description: 'Show brain model switch hint',
64
+ surfaces: ['tui'],
65
+ scope: 'local',
66
+ bypassesBrain: true,
67
+ },
68
+ {
69
+ name: 'jobs',
70
+ description: 'List active marketplace jobs',
71
+ surfaces: ['tui'],
72
+ scope: 'local',
73
+ bypassesBrain: true,
74
+ },
75
+ {
76
+ name: 'help',
77
+ description: 'Show all commands',
78
+ surfaces: ['tui'],
79
+ scope: 'local',
80
+ bypassesBrain: true,
81
+ },
82
+ {
83
+ name: 'exit',
84
+ description: 'Quit lyra',
85
+ surfaces: ['tui'],
86
+ scope: 'local',
87
+ bypassesBrain: true,
88
+ },
89
+ {
90
+ name: 'quit',
91
+ description: 'Quit lyra',
92
+ surfaces: ['tui'],
93
+ scope: 'local',
94
+ bypassesBrain: true,
95
+ },
96
+ {
97
+ name: 'stop',
98
+ description: 'Cancel current turn',
99
+ surfaces: ['tg'],
100
+ scope: 'gateway',
101
+ bypassesBrain: true,
102
+ },
103
+ {
104
+ name: 'new',
105
+ description: 'Start a fresh session',
106
+ surfaces: ['tg'],
107
+ scope: 'gateway',
108
+ bypassesBrain: true,
109
+ },
110
+ {
111
+ name: 'status',
112
+ description: 'Show agent status',
113
+ surfaces: ['tg'],
114
+ scope: 'gateway',
115
+ bypassesBrain: true,
116
+ },
117
+ {
118
+ name: 'approve',
119
+ description: 'Approve the pending request',
120
+ surfaces: ['tg'],
121
+ scope: 'gateway',
122
+ bypassesBrain: true,
123
+ },
124
+ {
125
+ name: 'deny',
126
+ description: 'Deny the pending request',
127
+ surfaces: ['tg'],
128
+ scope: 'gateway',
129
+ bypassesBrain: true,
130
+ },
131
+ {
132
+ name: 'background',
133
+ description: 'Move the current turn to background',
134
+ surfaces: ['tg'],
135
+ scope: 'gateway',
136
+ bypassesBrain: true,
137
+ },
138
+ {
139
+ name: 'restart',
140
+ description: 'Restart the active session',
141
+ surfaces: ['tg'],
142
+ scope: 'gateway',
143
+ bypassesBrain: true,
144
+ },
145
+ ]
146
+
147
+ export function commandsForSurface(surface: CommandSurface): SlashCommand[] {
148
+ return COMMAND_REGISTRY.filter(c => c.surfaces.includes(surface))
149
+ }
150
+
151
+ export function findCommand(name: string): SlashCommand | undefined {
152
+ const needle = name.replace(/^\/+/, '').toLowerCase()
153
+ return COMMAND_REGISTRY.find(c => c.name === needle)
154
+ }
155
+
156
+ export interface ParsedSlash {
157
+ /** Raw command name lowercased, no leading slash. */
158
+ name: string
159
+ /** Whitespace-split argv after the command. */
160
+ args: string[]
161
+ /** Resolved registry entry when name matches a known command, otherwise undefined. */
162
+ command?: SlashCommand
163
+ }
164
+
165
+ /**
166
+ * Parse a leading-slash message into name + args. Returns null when the input
167
+ * doesn't start with a slash. Unknown command names still return a parsed
168
+ * shape (with `command` undefined) so callers can distinguish "not a slash"
169
+ * from "slash but unknown".
170
+ */
171
+ export function parseSlash(text: string): ParsedSlash | null {
172
+ const trimmed = text.trimStart()
173
+ if (!trimmed.startsWith('/')) return null
174
+ const stripped = trimmed.slice(1).trimEnd()
175
+ if (stripped.length === 0) return null
176
+ const parts = stripped.split(/\s+/)
177
+ const name = (parts[0] ?? '').toLowerCase()
178
+ if (name.length === 0) return null
179
+ const args = parts.slice(1)
180
+ return { name, args, command: findCommand(name) }
181
+ }
182
+
183
+ /**
184
+ * Filter commands by surface and prefix for autocomplete suggestions.
185
+ * Empty or single-`/` query returns all commands for the surface.
186
+ */
187
+ export function suggestForPrefix(surface: CommandSurface, query: string): SlashCommand[] {
188
+ const stripped = query.replace(/^\/+/, '').toLowerCase()
189
+ const all = commandsForSurface(surface)
190
+ if (stripped.length === 0) return all
191
+ return all.filter(c => c.name.startsWith(stripped))
192
+ }
193
+
194
+ /**
195
+ * Permission modes operators can flip via /yolo and /perms. Matches
196
+ * `PermissionMode` from `../permission` but redeclared here to avoid the
197
+ * commands module taking a transitive dependency on the permission service.
198
+ */
199
+ export type PermissionToggleMode = 'off' | 'prompt' | 'strict'
200
+
201
+ export interface PermissionApi {
202
+ getMode(): PermissionToggleMode
203
+ setMode(mode: PermissionToggleMode): void
204
+ }
205
+
206
+ export interface ApplyResult {
207
+ /** Operator-facing message describing the new state. */
208
+ message: string
209
+ /** Mode after the operation, for callers that mirror UI state. */
210
+ mode: PermissionToggleMode
211
+ }
212
+
213
+ /**
214
+ * Toggle approval prompts between `off` (YOLO) and `prompt` (re-enable).
215
+ * `strict` flips to `prompt` on toggle to give an off-ramp from lockdown.
216
+ * Single source of truth; consumed by TUI `/yolo`, TG-local `/yolo`, and
217
+ * gateway TG `/yolo` so wording stays in lockstep.
218
+ */
219
+ export function applyYolo(permission: PermissionApi): ApplyResult {
220
+ const cur = permission.getMode()
221
+ const next: PermissionToggleMode = cur === 'off' ? 'prompt' : 'off'
222
+ permission.setMode(next)
223
+ return {
224
+ mode: next,
225
+ message:
226
+ next === 'off'
227
+ ? 'YOLO ON. Approval prompts disabled for this session.'
228
+ : 'YOLO OFF. Approvals back on.',
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Set the permission mode explicitly. `arg === undefined` returns the current
234
+ * mode without mutation. Returns the same `{ message, mode }` shape so callers
235
+ * have one branch instead of two for "show vs set".
236
+ */
237
+ export function applyPerms(
238
+ permission: PermissionApi,
239
+ arg: string | undefined,
240
+ ): ApplyResult | { message: string; mode: PermissionToggleMode; error: true } {
241
+ if (!arg) {
242
+ const mode = permission.getMode()
243
+ return { mode, message: `perms: ${mode}` }
244
+ }
245
+ const lower = arg.toLowerCase()
246
+ if (lower !== 'off' && lower !== 'prompt' && lower !== 'strict') {
247
+ return {
248
+ mode: permission.getMode(),
249
+ message: `unknown perms mode '${arg}'. try: off | prompt | strict`,
250
+ error: true,
251
+ }
252
+ }
253
+ permission.setMode(lower as PermissionToggleMode)
254
+ return { mode: lower as PermissionToggleMode, message: `perms set to ${lower}` }
255
+ }
package/src/config.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * User-facing configuration shape for `lyra.config.ts`.
3
+ *
4
+ * Example:
5
+ *
6
+ * import { defineConfig } from 'lyra-core'
7
+ *
8
+ * export default defineConfig({
9
+ * network: 'mainnet', // or 'testnet'
10
+ * storage: { network: 'mainnet' },
11
+ * brain: { model: 'gpt-4o-mini' }, // chosen at `lyra init`
12
+ * plugins: ['onchain', 'system'],
13
+ * tools: { 'defi.*': false, 'shell.run': false },
14
+ * imports: { claudeCode: true },
15
+ * })
16
+ */
17
+
18
+ export type LyraNetwork = 'testnet' | 'mainnet'
19
+
20
+ export type LyraPlugin = 'onchain' | 'comms' | 'system' | 'telegram'
21
+
22
+ export type OperatorSourceKind = 'raw-privkey' | 'keystore-file'
23
+
24
+ /**
25
+ * Persisted hint about which operator source to use when commands like
26
+ * `lyra` (chat) and `lyra drain` need to talk to the operator wallet
27
+ * again. Stores enough metadata to reconstruct the signer without re-prompting
28
+ * the user from scratch (passphrases / QR scans still happen per-session).
29
+ */
30
+ export interface OperatorSourceHint {
31
+ source: OperatorSourceKind
32
+ /** Only for `keystore-file`: absolute or `~`-prefixed path to the JSON keystore. */
33
+ keystorePath?: string
34
+ }
35
+
36
+ export interface LyraConfig {
37
+ identity: {
38
+ /** Operator wallet address that encrypts (and can recover) the agent keystore. */
39
+ operator: string | null
40
+ /** Agent EOA address (separate key, signs + pays gas). */
41
+ agent: string | null
42
+ }
43
+ network: LyraNetwork
44
+ storage: {
45
+ network: LyraNetwork
46
+ }
47
+ brain: {
48
+ provider: string | null
49
+ model: string | null
50
+ /** Max assistant output tokens per turn. Default 4096. */
51
+ maxOutputTokens?: number
52
+ /**
53
+ * Model context window. Used for auto-compaction trigger. Default
54
+ * 1_000_000. Override for smaller models.
55
+ */
56
+ contextWindow?: number
57
+ /**
58
+ * Pre-flight summarize-fold of older history when the running estimate
59
+ * breaches `threshold * contextWindow`. Set to `null` to disable.
60
+ * Default: { threshold: 0.5, keepRecent: 8 }.
61
+ */
62
+ compaction?: {
63
+ threshold?: number
64
+ keepRecent?: number
65
+ } | null
66
+ /**
67
+ * Persist channel histories to JSONL under
68
+ * `~/.lyra/agents/<id>/conversations/`. Loaded on boot, appended per
69
+ * turn, atomically rewritten on compaction. Default true.
70
+ */
71
+ persistConversations?: boolean
72
+ }
73
+ plugins: LyraPlugin[]
74
+ /** Glob-level tool allow/deny. Right-most match wins. */
75
+ tools: Record<string, boolean>
76
+ imports: {
77
+ claudeCode: boolean
78
+ }
79
+ /**
80
+ * Which operator source to use when reconnecting. Optional so legacy configs
81
+ * still parse; commands fall back to the interactive picker when missing.
82
+ */
83
+ operator?: OperatorSourceHint | null
84
+ /**
85
+ * Permission system. `prompt` (default) prompts on dangerous commands;
86
+ * `strict` always denies them; `off` is YOLO (no prompts). The `--yolo` CLI
87
+ * flag and `/yolo` TUI slash both flip the active service to 'off' for the
88
+ * current session without rewriting the file.
89
+ */
90
+ approvals?: {
91
+ mode: 'strict' | 'prompt' | 'off'
92
+ /** Always-approved patterns (regex against `kind|command|path` signature). */
93
+ allowlist?: string[]
94
+ }
95
+ /**
96
+ * Skills system. `disabled` is the persistent list of skill ids that should
97
+ * never auto-load or appear in the index.
98
+ */
99
+ skills?: {
100
+ disabled?: string[]
101
+ }
102
+ /**
103
+ * Operator-supplied additions to the system prompt. `append` is concatenated
104
+ * under a `# Operator instructions` header AFTER lyra's built-in safety +
105
+ * tool-use scaffolding. Can NOT replace the base prompt; use it for personal
106
+ * rules ("always reply in Indonesian", "prefer Bun over npm").
107
+ */
108
+ prompt?: {
109
+ append?: string | null
110
+ }
111
+ /**
112
+ * Multimodal vision routing. Vision limbs (vision.analyze, browser.vision)
113
+ * call this OpenAI-compatible provider; the brain stays on `brain.provider`.
114
+ * Set `null` to disable; tools then return a clear "not configured" error.
115
+ */
116
+ vision?: {
117
+ provider?: string | null
118
+ }
119
+ /**
120
+ * Structural sandbox for limb spawns. Defense-in-depth BENEATH the permission
121
+ * floor — even when `s` (allow session) or yolo grants a destructive command,
122
+ * the sandbox profile prevents writes outside an allowlist (agentDir +
123
+ * workspaceRoot + /tmp/lyra-* + /var/folders).
124
+ *
125
+ * - `none` (default): passthrough. Permission floor only.
126
+ * - `os`: native OS sandbox. macOS = sandbox-exec wrapper. Linux = bubblewrap.
127
+ * - `docker`: long-lived container per session, every spawn through `docker exec`.
128
+ */
129
+ sandbox?: {
130
+ mode?: 'none' | 'os' | 'docker'
131
+ /**
132
+ * docker mode only: container image. Default `oven/bun:1`. Compatible with
133
+ * Docker Desktop AND Podman. Override for custom tooling.
134
+ */
135
+ dockerImage?: string
136
+ /**
137
+ * docker mode only: bind-mount the host's workspaceRoot into the container
138
+ * at /workspace. Default `false` for max isolation.
139
+ */
140
+ dockerMountWorkspace?: boolean
141
+ /**
142
+ * docker mode only: force a specific container runtime binary. Auto-detect
143
+ * by default (tries docker, then podman).
144
+ */
145
+ dockerRuntimePath?: string
146
+ /** docker mode only: CPU cores cap (`--cpus`). Default unlimited. */
147
+ dockerCpu?: number
148
+ /** docker mode only: memory cap in MB (`--memory <N>m`). Default unlimited. */
149
+ dockerMemoryMb?: number
150
+ /**
151
+ * docker mode only: per-container writable-layer disk cap in MB. Linux +
152
+ * overlay2 with pquota only — silently dropped on macOS / podman.
153
+ */
154
+ dockerDiskMb?: number
155
+ /**
156
+ * docker mode only: block all network access from inside the container
157
+ * (`--network=none`). Default false.
158
+ */
159
+ dockerNoNetwork?: boolean
160
+ }
161
+ }
162
+
163
+ export type LyraConfigInput = Partial<LyraConfig> & Pick<LyraConfig, 'network'>
164
+
165
+ const DEFAULT_CONFIG: Omit<LyraConfig, 'network' | 'storage'> = {
166
+ identity: { operator: null, agent: null },
167
+ brain: { provider: null, model: null },
168
+ plugins: ['onchain', 'system'],
169
+ tools: {},
170
+ imports: { claudeCode: true },
171
+ operator: null,
172
+ approvals: { mode: 'prompt', allowlist: [] },
173
+ skills: { disabled: [] },
174
+ prompt: { append: null },
175
+ vision: { provider: undefined },
176
+ sandbox: { mode: 'none' },
177
+ }
178
+
179
+ export function defineConfig(input: LyraConfigInput): LyraConfig {
180
+ return {
181
+ ...DEFAULT_CONFIG,
182
+ identity: input.identity ?? DEFAULT_CONFIG.identity,
183
+ network: input.network,
184
+ storage: input.storage ?? { network: input.network },
185
+ brain: input.brain ?? DEFAULT_CONFIG.brain,
186
+ plugins: input.plugins ?? DEFAULT_CONFIG.plugins,
187
+ tools: input.tools ?? DEFAULT_CONFIG.tools,
188
+ imports: input.imports ?? DEFAULT_CONFIG.imports,
189
+ operator: input.operator ?? DEFAULT_CONFIG.operator,
190
+ approvals: input.approvals ?? DEFAULT_CONFIG.approvals,
191
+ skills: input.skills ?? DEFAULT_CONFIG.skills,
192
+ prompt: input.prompt ?? DEFAULT_CONFIG.prompt,
193
+ vision: input.vision ?? DEFAULT_CONFIG.vision,
194
+ sandbox: input.sandbox ?? DEFAULT_CONFIG.sandbox,
195
+ }
196
+ }
197
+
198
+ export const NETWORK_RPC: Record<LyraNetwork, string> = {
199
+ mainnet: 'https://fullnode.mainnet.sui.io:443',
200
+ testnet: 'https://fullnode.testnet.sui.io:443',
201
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Economy module. The legacy decentralized-compute self-funding manager was
3
+ * removed with the 0G compute backend (Lyra uses an API-key LLM). Reserved
4
+ * for Sui-native economy primitives (treasury limits, spend caps, etc.).
5
+ */
6
+ export {}
@@ -0,0 +1,4 @@
1
+ export type { LyraEvent, EventPayload, EventSource } from './types'
2
+ export { EventQueue, newEventId } from './queue'
3
+ export { type Listener, listeners } from './listeners'
4
+ export { routeLoop, type RouterDeps } from './router'
@@ -0,0 +1,37 @@
1
+ import type { EventQueue } from './queue'
2
+
3
+ /**
4
+ * A Listener watches some source (stdin, chain, a2a, ...) and pushes events
5
+ * onto the queue. Plugins contribute listeners via `registerListener()`.
6
+ */
7
+ export interface Listener {
8
+ name: string
9
+ source: string
10
+ start(queue: EventQueue): Promise<void>
11
+ stop(): Promise<void>
12
+ }
13
+
14
+ class ListenerRegistry {
15
+ private registered: Listener[] = []
16
+
17
+ register(l: Listener): void {
18
+ if (this.registered.some(x => x.name === l.name)) {
19
+ throw new Error(`Listener already registered: ${l.name}`)
20
+ }
21
+ this.registered.push(l)
22
+ }
23
+
24
+ list(): readonly Listener[] {
25
+ return this.registered
26
+ }
27
+
28
+ async startAll(queue: EventQueue): Promise<void> {
29
+ await Promise.all(this.registered.map(l => l.start(queue)))
30
+ }
31
+
32
+ async stopAll(): Promise<void> {
33
+ await Promise.all(this.registered.map(l => l.stop()))
34
+ }
35
+ }
36
+
37
+ export const listeners = new ListenerRegistry()
@@ -0,0 +1,43 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { EventQueue, newEventId } from './queue'
3
+
4
+ test('FIFO ordering', async () => {
5
+ const q = new EventQueue()
6
+ for (let i = 0; i < 3; i++) {
7
+ q.enqueue({
8
+ id: newEventId(),
9
+ source: 'stdin',
10
+ payload: { label: `e${i}`, data: i },
11
+ ts: Date.now(),
12
+ })
13
+ }
14
+ const a = await q.dequeue()
15
+ const b = await q.dequeue()
16
+ const c = await q.dequeue()
17
+ expect(a.payload.label).toBe('e0')
18
+ expect(b.payload.label).toBe('e1')
19
+ expect(c.payload.label).toBe('e2')
20
+ })
21
+
22
+ test('dequeue awaits when empty', async () => {
23
+ const q = new EventQueue()
24
+ const pending = q.dequeue()
25
+ setTimeout(() => {
26
+ q.enqueue({
27
+ id: 'late',
28
+ source: 'stdin',
29
+ payload: { label: 'late', data: null },
30
+ ts: Date.now(),
31
+ })
32
+ }, 10)
33
+ const ev = await pending
34
+ expect(ev.payload.label).toBe('late')
35
+ })
36
+
37
+ test('closed queue throws on enqueue', () => {
38
+ const q = new EventQueue()
39
+ q.close()
40
+ expect(() =>
41
+ q.enqueue({ id: 'x', source: 'stdin', payload: { label: 'x', data: null }, ts: 0 }),
42
+ ).toThrow()
43
+ })
@@ -0,0 +1,63 @@
1
+ import type { LyraEvent } from './types'
2
+
3
+ /**
4
+ * Minimal in-memory FIFO queue. Async-iterable so consumers `for await` over
5
+ * incoming events. Enqueue resolves immediately; dequeue awaits the next event.
6
+ */
7
+ export class EventQueue {
8
+ private buffer: LyraEvent[] = []
9
+ private waiters: Array<(ev: LyraEvent) => void> = []
10
+ private closed = false
11
+
12
+ enqueue(ev: LyraEvent): void {
13
+ if (this.closed) throw new Error('EventQueue closed')
14
+ const w = this.waiters.shift()
15
+ if (w) {
16
+ w(ev)
17
+ return
18
+ }
19
+ this.buffer.push(ev)
20
+ }
21
+
22
+ async dequeue(): Promise<LyraEvent> {
23
+ const head = this.buffer.shift()
24
+ if (head) return head
25
+ if (this.closed) throw new Error('EventQueue closed')
26
+ return new Promise<LyraEvent>(resolve => {
27
+ this.waiters.push(resolve)
28
+ })
29
+ }
30
+
31
+ /** Close and wake all waiters with error. */
32
+ close(): void {
33
+ this.closed = true
34
+ for (const w of this.waiters) {
35
+ Promise.resolve().then(() => w({} as LyraEvent))
36
+ }
37
+ this.waiters = []
38
+ }
39
+
40
+ get length(): number {
41
+ return this.buffer.length
42
+ }
43
+
44
+ get isClosed(): boolean {
45
+ return this.closed
46
+ }
47
+
48
+ async *[Symbol.asyncIterator](): AsyncGenerator<LyraEvent> {
49
+ while (!this.closed) {
50
+ try {
51
+ yield await this.dequeue()
52
+ } catch {
53
+ return
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ let counter = 0
60
+ export function newEventId(): string {
61
+ counter += 1
62
+ return `${Date.now().toString(36)}-${counter.toString(36)}`
63
+ }
@@ -0,0 +1,42 @@
1
+ import type { Brain, BrainTurn } from '../brain/types'
2
+ import type { ToolRegistry } from '../tools/registry'
3
+ import type { EventQueue } from './queue'
4
+ import type { LyraEvent } from './types'
5
+
6
+ export interface RouterDeps {
7
+ brain: Brain
8
+ tools: ToolRegistry
9
+ onTurn?: (ev: LyraEvent, turn: BrainTurn) => void | Promise<void>
10
+ }
11
+
12
+ /**
13
+ * Pulls events from the queue, assembles a prompt via the brain, executes any
14
+ * returned tool_calls until the brain produces a final message, and yields
15
+ * the turn back via `onTurn`.
16
+ */
17
+ export async function routeLoop(queue: EventQueue, deps: RouterDeps): Promise<void> {
18
+ for await (const ev of queue) {
19
+ if (!ev.source) continue // closed sentinel
20
+ await handleOne(ev, deps)
21
+ }
22
+ }
23
+
24
+ async function handleOne(ev: LyraEvent, deps: RouterDeps): Promise<void> {
25
+ // Seed conversation with the triggering event (stub does echo; real brain
26
+ // in phase 3 will load memory, assemble frozen prefix, etc.)
27
+ const turn = await deps.brain.infer({ event: ev })
28
+
29
+ // Resolve any tool calls in a single iteration for MVP — phase 3 extends
30
+ // this to a proper multi-turn loop.
31
+ for (const call of turn.toolCalls ?? []) {
32
+ const tool = deps.tools.find(call.name)
33
+ if (!tool) continue
34
+ try {
35
+ await tool.handler(call.args)
36
+ } catch {
37
+ // Tool errors are surfaced in activity log by the runtime, not here.
38
+ }
39
+ }
40
+
41
+ await deps.onTurn?.(ev, turn)
42
+ }
@@ -0,0 +1,28 @@
1
+ /** Sources that can enqueue events into the runtime. */
2
+ export type EventSource =
3
+ | 'stdin'
4
+ | 'cron'
5
+ | 'webhook'
6
+ | 'a2a'
7
+ | 'marketplace'
8
+ | 'chain'
9
+ | 'internal'
10
+ | 'telegram'
11
+
12
+ export interface EventPayload {
13
+ /** Short human-readable label for logs/status. */
14
+ label: string
15
+ /** Arbitrary structured data. Listener-specific shape. */
16
+ data: unknown
17
+ /** Any peer address (ECIES pubkey or .0g name) that originated this event. */
18
+ peer?: string
19
+ /** Per-listener hint about which memory topics are relevant for this event. */
20
+ memoryHint?: string[]
21
+ }
22
+
23
+ export interface LyraEvent {
24
+ id: string
25
+ source: EventSource
26
+ payload: EventPayload
27
+ ts: number
28
+ }
package/src/format.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Render a MIST amount as a 6-decimal SUI string. Matches the statusline,
3
+ * `lyra balance`, and ledger output styles. Always emits exactly 6 decimal
4
+ * places (zero-padded) so columns align. 1 SUI = 1e9 MIST.
5
+ */
6
+ export function formatSui(mist: bigint | string | number): string {
7
+ const m = typeof mist === 'bigint' ? mist : BigInt(mist)
8
+ const neg = m < 0n
9
+ const abs = neg ? -m : m
10
+ const whole = abs / 1_000_000_000n
11
+ const frac = (abs % 1_000_000_000n).toString().padStart(9, '0').slice(0, 6)
12
+ return `${neg ? '-' : ''}${whole}.${frac}`
13
+ }