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,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,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
|
+
}
|