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,226 @@
|
|
|
1
|
+
import type { ClaudeAgent } from '../claude-plugins/types'
|
|
2
|
+
import type { LyraNetwork } from '../config'
|
|
3
|
+
import type { Listener } from '../events/listeners'
|
|
4
|
+
import type { SandboxBackend } from '../sandbox/types'
|
|
5
|
+
import type { ToolRegistry } from '../tools/registry'
|
|
6
|
+
import type { ToolDef, ToolSchema } from '../tools/types'
|
|
7
|
+
import type { HookBus, HookHandler, HookName } from './hooks'
|
|
8
|
+
|
|
9
|
+
/** Vision inputs for image-capable models (provider-agnostic). */
|
|
10
|
+
export type VisionInferImage = { bytes: Uint8Array; mediaType: string }
|
|
11
|
+
export type VisionInferInput = {
|
|
12
|
+
prompt: string
|
|
13
|
+
images: VisionInferImage[]
|
|
14
|
+
maxOutputTokens?: number
|
|
15
|
+
}
|
|
16
|
+
export type VisionInferResult = {
|
|
17
|
+
content: string
|
|
18
|
+
model?: string | null
|
|
19
|
+
usage?: {
|
|
20
|
+
promptTokens?: number
|
|
21
|
+
completionTokens?: number
|
|
22
|
+
totalTokens?: number
|
|
23
|
+
cachedTokens?: number
|
|
24
|
+
}
|
|
25
|
+
finishReason?: string
|
|
26
|
+
}
|
|
27
|
+
export type VisionInferFn = (input: VisionInferInput) => Promise<VisionInferResult>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Factory chat.tsx supplies for `delegate.task` to spin up a sub-brain. The
|
|
31
|
+
* implementation typically wraps `OGComputeBrain` with a custom system prompt
|
|
32
|
+
* + restricted tool surface.
|
|
33
|
+
*/
|
|
34
|
+
export interface DelegateBrainFactoryOpts {
|
|
35
|
+
systemPrompt: string
|
|
36
|
+
tools: ToolSchema[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DelegateBrainTurn {
|
|
40
|
+
content: string | null
|
|
41
|
+
finishReason?: string
|
|
42
|
+
toolCalls?: Array<{ id: string; name: string; args: unknown }>
|
|
43
|
+
usage?: {
|
|
44
|
+
totalTokens?: number
|
|
45
|
+
cachedTokens?: number
|
|
46
|
+
promptTokens?: number
|
|
47
|
+
completionTokens?: number
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DelegateBrainHandle {
|
|
52
|
+
infer(input: {
|
|
53
|
+
event: { id: string; source: 'stdin'; payload: { label: string; data: string }; ts: number }
|
|
54
|
+
}): Promise<DelegateBrainTurn>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type DelegateBrainFactory = (opts: DelegateBrainFactoryOpts) => Promise<DelegateBrainHandle>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Context handed to a plugin's `register(ctx)` function. Plugins use this
|
|
61
|
+
* to contribute tools, listeners, and lifecycle hooks; we deliberately
|
|
62
|
+
* keep the surface tiny so future plugin features extend it without
|
|
63
|
+
* breaking existing native plugins.
|
|
64
|
+
*/
|
|
65
|
+
export interface PluginContext {
|
|
66
|
+
registerTool: (def: ToolDef) => void
|
|
67
|
+
registerListener: (l: Listener) => void
|
|
68
|
+
addHook: <TIn = unknown, TOut = void>(name: HookName, fn: HookHandler<TIn, TOut>) => void
|
|
69
|
+
/** Network the agent is configured for. */
|
|
70
|
+
network: LyraNetwork
|
|
71
|
+
/** Agent state directory (`~/.lyra/agents/<id>/`). */
|
|
72
|
+
agentDir: string
|
|
73
|
+
/** Per-agent unique id (matches `iNFTAgentId(...)` for non-stub agents). */
|
|
74
|
+
agentId: string
|
|
75
|
+
/** Absolute path to ~/.lyra/config.ts. Plugins that persist user-level state write here. */
|
|
76
|
+
configPath: string
|
|
77
|
+
/** Imports surface from config (e.g. claudeCode toggle for skills + MCP discovery). */
|
|
78
|
+
imports: { claudeCode: boolean }
|
|
79
|
+
/**
|
|
80
|
+
* Mutable cell holding the user-disabled skill ids. Plugin tools that
|
|
81
|
+
* change the list update this, and the chat rebuilds the skill index from
|
|
82
|
+
* the current value next turn.
|
|
83
|
+
*/
|
|
84
|
+
skillsDisabled: { current: string[] }
|
|
85
|
+
/** Path to the agent's activity log (~/.lyra/agents/<id>/activity.jsonl). */
|
|
86
|
+
activityLogPath: string
|
|
87
|
+
/** Workspace cwd. Used by tools that spawn subprocesses. */
|
|
88
|
+
workspaceRoot: string
|
|
89
|
+
/**
|
|
90
|
+
* Sub-brain factory (Phase 9.3 delegate.task). Chat.tsx supplies a closure
|
|
91
|
+
* that builds an OGComputeBrain with broker creds. Tools without a brain
|
|
92
|
+
* dependency ignore this.
|
|
93
|
+
*/
|
|
94
|
+
delegateFactory?: DelegateBrainFactory
|
|
95
|
+
/** Claude Code agents discovered from the local plugin cache. */
|
|
96
|
+
claudeAgents: ClaudeAgent[]
|
|
97
|
+
/** Whether the configured brain supports image inputs. */
|
|
98
|
+
brainSupportsVision: boolean
|
|
99
|
+
/** Brain model label (string). Surfaces in tool error messages. */
|
|
100
|
+
brainModelLabel: string | null
|
|
101
|
+
/**
|
|
102
|
+
* v0.11 vision routing: when set, vision.analyze + browser.vision call
|
|
103
|
+
* this function (backed by a BrokerPool entry pinned to the configured
|
|
104
|
+
* vision provider). Null when no vision provider is configured.
|
|
105
|
+
*/
|
|
106
|
+
visionInfer?: VisionInferFn | null
|
|
107
|
+
/**
|
|
108
|
+
* Phase 9.5: sandbox backend wrapping every spawn() in shell.run / code.execute /
|
|
109
|
+
* shell.process_start. Optional for back-compat: legacy callers + tests that
|
|
110
|
+
* don't supply one get a LocalBackend (passthrough) inside the plugin.
|
|
111
|
+
*/
|
|
112
|
+
sandbox?: SandboxBackend
|
|
113
|
+
/**
|
|
114
|
+
* Phase 7 side-band runtime context for plugin-comms. Opaque to core; the
|
|
115
|
+
* plugin reads its concrete shape via a typed cast. Holding the field as
|
|
116
|
+
* `unknown` keeps core free of a back-edge to the comms package.
|
|
117
|
+
*/
|
|
118
|
+
comms?: unknown
|
|
119
|
+
/**
|
|
120
|
+
* Phase 10 side-band runtime context for plugin-onchain. Same opaque
|
|
121
|
+
* pattern as `comms`.
|
|
122
|
+
*/
|
|
123
|
+
onchain?: unknown
|
|
124
|
+
/**
|
|
125
|
+
* Phase 12 side-band runtime context for plugin-telegram. Same opaque
|
|
126
|
+
* pattern: chat.tsx (local) or build-runtime.ts (sandbox) builds the typed
|
|
127
|
+
* `TelegramRuntimeContext` and passes it; the plugin casts on read.
|
|
128
|
+
*/
|
|
129
|
+
telegram?: unknown
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface NativePlugin {
|
|
133
|
+
name: string
|
|
134
|
+
register: (ctx: PluginContext) => void | Promise<void>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PluginLoadResult {
|
|
138
|
+
loaded: string[]
|
|
139
|
+
errors: { plugin: string; error: string }[]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface PluginLoaderDeps {
|
|
143
|
+
tools: ToolRegistry
|
|
144
|
+
hooks: HookBus
|
|
145
|
+
listeners: { register: (l: Listener) => void }
|
|
146
|
+
agentDir: string
|
|
147
|
+
agentId: string
|
|
148
|
+
network: LyraNetwork
|
|
149
|
+
configPath: string
|
|
150
|
+
imports: { claudeCode: boolean }
|
|
151
|
+
skillsDisabled: { current: string[] }
|
|
152
|
+
activityLogPath: string
|
|
153
|
+
workspaceRoot: string
|
|
154
|
+
delegateFactory?: DelegateBrainFactory
|
|
155
|
+
claudeAgents?: ClaudeAgent[]
|
|
156
|
+
brainSupportsVision?: boolean
|
|
157
|
+
brainModelLabel?: string | null
|
|
158
|
+
visionInfer?: VisionInferFn | null
|
|
159
|
+
/** Phase 9.5 sandbox backend, propagated to plugin context. Optional. */
|
|
160
|
+
sandbox?: SandboxBackend
|
|
161
|
+
/** Phase 7 side-band runtime context for plugin-comms. Opaque to core. */
|
|
162
|
+
comms?: unknown
|
|
163
|
+
/** Phase 10 side-band runtime context for plugin-onchain. Opaque to core. */
|
|
164
|
+
onchain?: unknown
|
|
165
|
+
/** Phase 12 side-band runtime context for plugin-telegram. Opaque to core. */
|
|
166
|
+
telegram?: unknown
|
|
167
|
+
/**
|
|
168
|
+
* Resolver for `name` → ESM module path. Defaults to dynamic import of
|
|
169
|
+
* `lyra-ai-plugin-<name>`. Tests pass a stub.
|
|
170
|
+
*/
|
|
171
|
+
resolve?: (name: string) => Promise<{ default?: NativePlugin } & Partial<NativePlugin>>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function loadPlugins(
|
|
175
|
+
names: readonly string[],
|
|
176
|
+
deps: PluginLoaderDeps,
|
|
177
|
+
): Promise<PluginLoadResult> {
|
|
178
|
+
const loaded: string[] = []
|
|
179
|
+
const errors: PluginLoadResult['errors'] = []
|
|
180
|
+
const ctx: PluginContext = {
|
|
181
|
+
registerTool: def => deps.tools.register(def),
|
|
182
|
+
registerListener: l => deps.listeners.register(l),
|
|
183
|
+
addHook: (name, fn) => deps.hooks.add(name, fn),
|
|
184
|
+
network: deps.network,
|
|
185
|
+
agentDir: deps.agentDir,
|
|
186
|
+
agentId: deps.agentId,
|
|
187
|
+
configPath: deps.configPath,
|
|
188
|
+
imports: deps.imports,
|
|
189
|
+
skillsDisabled: deps.skillsDisabled,
|
|
190
|
+
activityLogPath: deps.activityLogPath,
|
|
191
|
+
workspaceRoot: deps.workspaceRoot,
|
|
192
|
+
delegateFactory: deps.delegateFactory,
|
|
193
|
+
claudeAgents: deps.claudeAgents ?? [],
|
|
194
|
+
brainSupportsVision: deps.brainSupportsVision ?? false,
|
|
195
|
+
brainModelLabel: deps.brainModelLabel ?? null,
|
|
196
|
+
visionInfer: deps.visionInfer ?? null,
|
|
197
|
+
sandbox: deps.sandbox,
|
|
198
|
+
comms: deps.comms,
|
|
199
|
+
onchain: deps.onchain,
|
|
200
|
+
telegram: deps.telegram,
|
|
201
|
+
}
|
|
202
|
+
for (const name of names) {
|
|
203
|
+
try {
|
|
204
|
+
const mod = deps.resolve
|
|
205
|
+
? await deps.resolve(name)
|
|
206
|
+
: ((await import(`lyra-ai-plugin-${name}`)) as {
|
|
207
|
+
default?: NativePlugin
|
|
208
|
+
} & Partial<NativePlugin>)
|
|
209
|
+
const plugin: NativePlugin | undefined =
|
|
210
|
+
mod.default && 'register' in mod.default
|
|
211
|
+
? mod.default
|
|
212
|
+
: 'register' in mod && typeof mod.register === 'function'
|
|
213
|
+
? (mod as unknown as NativePlugin)
|
|
214
|
+
: undefined
|
|
215
|
+
if (!plugin) {
|
|
216
|
+
errors.push({ plugin: name, error: 'no exported register(ctx)' })
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
await plugin.register(ctx)
|
|
220
|
+
loaded.push(name)
|
|
221
|
+
} catch (e) {
|
|
222
|
+
errors.push({ plugin: name, error: (e as Error).message ?? String(e) })
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { loaded, errors }
|
|
226
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ToolCall, ToolResult } from '../tools/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle hook surface. Plugins register handlers via `ctx.addHook(name, fn)`.
|
|
5
|
+
* Hooks run in registration order; pre-* hooks may return a replacement
|
|
6
|
+
* payload to mutate the input, while post-* hooks observe only.
|
|
7
|
+
*
|
|
8
|
+
* Phase 9.0 wires `pre_tool_call` + `post_tool_call` into the chat loop.
|
|
9
|
+
* The other 8 names exist as no-op stubs so plugins can target them now and
|
|
10
|
+
* future phases (LLM call, session lifecycle) can light them up without
|
|
11
|
+
* breaking the contract.
|
|
12
|
+
*/
|
|
13
|
+
export type HookName =
|
|
14
|
+
| 'pre_tool_call'
|
|
15
|
+
| 'post_tool_call'
|
|
16
|
+
| 'pre_llm_call'
|
|
17
|
+
| 'post_llm_call'
|
|
18
|
+
| 'pre_api_request'
|
|
19
|
+
| 'post_api_request'
|
|
20
|
+
| 'on_session_start'
|
|
21
|
+
| 'on_session_end'
|
|
22
|
+
| 'on_session_finalize'
|
|
23
|
+
| 'on_session_reset'
|
|
24
|
+
|
|
25
|
+
export interface PreToolCallContext {
|
|
26
|
+
call: ToolCall
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PreToolCallResult {
|
|
30
|
+
/** Replacement call (e.g. permission injection edits args). undefined = no change. */
|
|
31
|
+
call?: ToolCall
|
|
32
|
+
/** If set, short-circuit dispatch with this result. */
|
|
33
|
+
short?: ToolResult
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PostToolCallContext {
|
|
37
|
+
call: ToolCall
|
|
38
|
+
result: ToolResult
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type HookHandler<TIn, TOut = void> = (
|
|
42
|
+
ctx: TIn,
|
|
43
|
+
) => Promise<TOut | undefined> | TOut | undefined
|
|
44
|
+
|
|
45
|
+
export class HookBus {
|
|
46
|
+
private readonly handlers = new Map<HookName, HookHandler<unknown, unknown>[]>()
|
|
47
|
+
|
|
48
|
+
add<TIn = unknown, TOut = void>(name: HookName, fn: HookHandler<TIn, TOut>): void {
|
|
49
|
+
const list = this.handlers.get(name) ?? []
|
|
50
|
+
list.push(fn as HookHandler<unknown, unknown>)
|
|
51
|
+
this.handlers.set(name, list)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run pre-tool-call hooks in order. Each handler may return a replacement
|
|
56
|
+
* call or a short-circuit result. The first short-circuit wins. Returns the
|
|
57
|
+
* effective call + optional short-circuit.
|
|
58
|
+
*/
|
|
59
|
+
async runPreToolCall(input: PreToolCallContext): Promise<PreToolCallResult> {
|
|
60
|
+
let current: ToolCall = input.call
|
|
61
|
+
const fns = this.handlers.get('pre_tool_call') ?? []
|
|
62
|
+
for (const fn of fns) {
|
|
63
|
+
const out = (await fn({ call: current })) as PreToolCallResult | undefined
|
|
64
|
+
if (!out) continue
|
|
65
|
+
if (out.short) return { short: out.short, call: current }
|
|
66
|
+
if (out.call) current = out.call
|
|
67
|
+
}
|
|
68
|
+
return { call: current }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async runPostToolCall(input: PostToolCallContext): Promise<void> {
|
|
72
|
+
const fns = this.handlers.get('post_tool_call') ?? []
|
|
73
|
+
for (const fn of fns) {
|
|
74
|
+
try {
|
|
75
|
+
await fn(input)
|
|
76
|
+
} catch {
|
|
77
|
+
// Post hooks must never break the chat loop.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export {
|
|
2
|
+
HookBus,
|
|
3
|
+
type HookName,
|
|
4
|
+
type HookHandler,
|
|
5
|
+
type PreToolCallContext,
|
|
6
|
+
type PreToolCallResult,
|
|
7
|
+
type PostToolCallContext,
|
|
8
|
+
} from './hooks'
|
|
9
|
+
export {
|
|
10
|
+
loadPlugins,
|
|
11
|
+
type PluginContext,
|
|
12
|
+
type NativePlugin,
|
|
13
|
+
type PluginLoadResult,
|
|
14
|
+
type PluginLoaderDeps,
|
|
15
|
+
type DelegateBrainFactory,
|
|
16
|
+
type DelegateBrainFactoryOpts,
|
|
17
|
+
type DelegateBrainHandle,
|
|
18
|
+
type DelegateBrainTurn,
|
|
19
|
+
type VisionInferFn,
|
|
20
|
+
type VisionInferInput,
|
|
21
|
+
type VisionInferImage,
|
|
22
|
+
type VisionInferResult,
|
|
23
|
+
} from './context'
|
|
24
|
+
export { makeToolSearchTool, type ToolSearchArgs } from './tool-search'
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { ToolRegistry } from '../tools/registry'
|
|
4
|
+
import type { ToolDef } from '../tools/types'
|
|
5
|
+
import { type NativePlugin, loadPlugins } from './context'
|
|
6
|
+
import { HookBus, type PreToolCallContext, type PreToolCallResult } from './hooks'
|
|
7
|
+
import { makeToolSearchTool } from './tool-search'
|
|
8
|
+
|
|
9
|
+
describe('ToolRegistry deferred-tool semantics', () => {
|
|
10
|
+
function makeTools() {
|
|
11
|
+
const r = new ToolRegistry()
|
|
12
|
+
r.register({
|
|
13
|
+
name: 'fs.read',
|
|
14
|
+
description: 'read a file',
|
|
15
|
+
schema: z.object({ path: z.string() }),
|
|
16
|
+
handler: () => ({ ok: true }),
|
|
17
|
+
})
|
|
18
|
+
r.register({
|
|
19
|
+
name: 'browser.navigate',
|
|
20
|
+
description: 'navigate the browser to a URL',
|
|
21
|
+
shouldDefer: true,
|
|
22
|
+
searchHint: 'browser web automation page',
|
|
23
|
+
schema: z.object({ url: z.string() }),
|
|
24
|
+
handler: () => ({ ok: true }),
|
|
25
|
+
})
|
|
26
|
+
return r
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it('eager tools appear in schemas; deferred tools do not until unlocked', () => {
|
|
30
|
+
const r = makeTools()
|
|
31
|
+
const before = r.schemas().map(s => s.function.name)
|
|
32
|
+
expect(before).toContain('fs.read')
|
|
33
|
+
expect(before).not.toContain('browser.navigate')
|
|
34
|
+
r.unlock('browser.navigate')
|
|
35
|
+
const after = r.schemas().map(s => s.function.name)
|
|
36
|
+
expect(after).toContain('browser.navigate')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('search by select:name returns exact tools', () => {
|
|
40
|
+
const r = makeTools()
|
|
41
|
+
const matches = r.search('select:fs.read,browser.navigate')
|
|
42
|
+
expect(matches.map(t => t.name).sort()).toEqual(['browser.navigate', 'fs.read'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keyword search matches deferred tools via searchHint', () => {
|
|
46
|
+
const r = makeTools()
|
|
47
|
+
const matches = r.search('browser web')
|
|
48
|
+
expect(matches.map(t => t.name)).toContain('browser.navigate')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('config glob deny propagates through schemas + dispatch', async () => {
|
|
52
|
+
const r = new ToolRegistry({ 'fs.*': false })
|
|
53
|
+
r.register({
|
|
54
|
+
name: 'fs.read',
|
|
55
|
+
description: 'x',
|
|
56
|
+
schema: z.object({}),
|
|
57
|
+
handler: () => ({ ok: true }),
|
|
58
|
+
})
|
|
59
|
+
expect(r.schemas()).toEqual([])
|
|
60
|
+
const out = await r.dispatch({ id: '1', name: 'fs.read', args: {} })
|
|
61
|
+
expect(out.ok).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('HookBus', () => {
|
|
66
|
+
it('runs pre_tool_call handlers in order, supports rewrite + short-circuit', async () => {
|
|
67
|
+
const bus = new HookBus()
|
|
68
|
+
bus.add<PreToolCallContext, PreToolCallResult>('pre_tool_call', ({ call }) => ({
|
|
69
|
+
call: { ...call, args: { mutated: true } },
|
|
70
|
+
}))
|
|
71
|
+
bus.add<PreToolCallContext, PreToolCallResult>('pre_tool_call', () => ({
|
|
72
|
+
short: { ok: false, error: 'denied' },
|
|
73
|
+
}))
|
|
74
|
+
const out = await bus.runPreToolCall({
|
|
75
|
+
call: { id: '1', name: 't', args: {} },
|
|
76
|
+
})
|
|
77
|
+
expect(out.short).toEqual({ ok: false, error: 'denied' })
|
|
78
|
+
expect(out.call?.args).toEqual({ mutated: true })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('post_tool_call observers cannot break the loop', async () => {
|
|
82
|
+
const bus = new HookBus()
|
|
83
|
+
bus.add('post_tool_call', () => {
|
|
84
|
+
throw new Error('boom')
|
|
85
|
+
})
|
|
86
|
+
let observed = false
|
|
87
|
+
bus.add('post_tool_call', () => {
|
|
88
|
+
observed = true
|
|
89
|
+
})
|
|
90
|
+
await bus.runPostToolCall({
|
|
91
|
+
call: { id: '1', name: 't', args: {} },
|
|
92
|
+
result: { ok: true },
|
|
93
|
+
})
|
|
94
|
+
expect(observed).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('loadPlugins', () => {
|
|
99
|
+
it('discovers + invokes register(ctx) per plugin', async () => {
|
|
100
|
+
const r = new ToolRegistry()
|
|
101
|
+
const bus = new HookBus()
|
|
102
|
+
let listenerCount = 0
|
|
103
|
+
const plugin: NativePlugin = {
|
|
104
|
+
name: 'fake',
|
|
105
|
+
register: ctx => {
|
|
106
|
+
ctx.registerTool({
|
|
107
|
+
name: 'fake.tool',
|
|
108
|
+
description: 'x',
|
|
109
|
+
schema: z.object({}),
|
|
110
|
+
handler: () => ({ ok: true }),
|
|
111
|
+
})
|
|
112
|
+
ctx.addHook('pre_tool_call', () => undefined)
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
const result = await loadPlugins(['fake'], {
|
|
116
|
+
tools: r,
|
|
117
|
+
hooks: bus,
|
|
118
|
+
listeners: { register: () => listenerCount++ },
|
|
119
|
+
agentDir: '/tmp/agent',
|
|
120
|
+
agentId: 'agent-x',
|
|
121
|
+
network: 'mainnet',
|
|
122
|
+
configPath: '/tmp/agent/config.ts',
|
|
123
|
+
imports: { claudeCode: false },
|
|
124
|
+
skillsDisabled: { current: [] },
|
|
125
|
+
activityLogPath: '/tmp/agent/activity.jsonl',
|
|
126
|
+
workspaceRoot: '/tmp/agent',
|
|
127
|
+
resolve: async () => ({ default: plugin }),
|
|
128
|
+
})
|
|
129
|
+
expect(result.loaded).toEqual(['fake'])
|
|
130
|
+
expect(result.errors).toEqual([])
|
|
131
|
+
expect(r.find('fake.tool')).toBeDefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('reports errors without throwing', async () => {
|
|
135
|
+
const r = new ToolRegistry()
|
|
136
|
+
const bus = new HookBus()
|
|
137
|
+
const result = await loadPlugins(['broken'], {
|
|
138
|
+
tools: r,
|
|
139
|
+
hooks: bus,
|
|
140
|
+
listeners: { register: () => {} },
|
|
141
|
+
agentDir: '/tmp/agent',
|
|
142
|
+
agentId: 'agent-x',
|
|
143
|
+
network: 'mainnet',
|
|
144
|
+
configPath: '/tmp/agent/config.ts',
|
|
145
|
+
imports: { claudeCode: false },
|
|
146
|
+
skillsDisabled: { current: [] },
|
|
147
|
+
activityLogPath: '/tmp/agent/activity.jsonl',
|
|
148
|
+
workspaceRoot: '/tmp/agent',
|
|
149
|
+
resolve: async () => {
|
|
150
|
+
throw new Error('module not found')
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
expect(result.loaded).toEqual([])
|
|
154
|
+
expect(result.errors[0]?.error).toContain('module not found')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('makeToolSearchTool', () => {
|
|
159
|
+
it('select query unlocks deferred tools', async () => {
|
|
160
|
+
const r = new ToolRegistry()
|
|
161
|
+
r.register({
|
|
162
|
+
name: 'fs.write',
|
|
163
|
+
description: 'write a file',
|
|
164
|
+
shouldDefer: true,
|
|
165
|
+
searchHint: 'filesystem write text file',
|
|
166
|
+
schema: z.object({ path: z.string(), text: z.string() }),
|
|
167
|
+
handler: () => ({ ok: true }),
|
|
168
|
+
})
|
|
169
|
+
const meta = makeToolSearchTool(r)
|
|
170
|
+
r.register(meta as ToolDef)
|
|
171
|
+
expect(r.schemas().map(s => s.function.name)).toContain('tool.search')
|
|
172
|
+
expect(r.schemas().map(s => s.function.name)).not.toContain('fs.write')
|
|
173
|
+
const result = await meta.handler({ query: 'select:fs.write' })
|
|
174
|
+
expect(result.ok).toBe(true)
|
|
175
|
+
expect(r.schemas().map(s => s.function.name)).toContain('fs.write')
|
|
176
|
+
const data = result.data as { tools: Array<{ name: string }> }
|
|
177
|
+
expect(data.tools.map(t => t.name)).toEqual(['fs.write'])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('keyword query matches by description and hint', async () => {
|
|
181
|
+
const r = new ToolRegistry()
|
|
182
|
+
r.register({
|
|
183
|
+
name: 'browser.navigate',
|
|
184
|
+
description: 'navigate the browser',
|
|
185
|
+
shouldDefer: true,
|
|
186
|
+
searchHint: 'browser web automation',
|
|
187
|
+
schema: z.object({ url: z.string() }),
|
|
188
|
+
handler: () => ({ ok: true }),
|
|
189
|
+
})
|
|
190
|
+
const meta = makeToolSearchTool(r)
|
|
191
|
+
r.register(meta as ToolDef)
|
|
192
|
+
const result = await meta.handler({ query: 'browser web' })
|
|
193
|
+
const data = result.data as { matched: number }
|
|
194
|
+
expect(data.matched).toBe(1)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { ToolRegistry } from '../tools/registry'
|
|
3
|
+
import type { ToolDef } from '../tools/types'
|
|
4
|
+
import { coerceInt } from '../tools/zod-helpers'
|
|
5
|
+
import { zodToJsonSchema } from '../tools/zod-schema'
|
|
6
|
+
|
|
7
|
+
const ToolSearchSchema = z.object({
|
|
8
|
+
query: z.string().min(1, 'query is required'),
|
|
9
|
+
max_results: coerceInt.refine(n => n > 0 && n <= 20, 'max_results must be 1..20').optional(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export type ToolSearchArgs = z.infer<typeof ToolSearchSchema>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `tool.search` meta-tool: brain calls this to hydrate deferred tool
|
|
16
|
+
* schemas. Mirrors Claude Code's ToolSearch surface, accepts either an
|
|
17
|
+
* explicit `select:foo,bar` query or a free-text keyword query.
|
|
18
|
+
*/
|
|
19
|
+
export function makeToolSearchTool(registry: ToolRegistry): ToolDef<ToolSearchArgs> {
|
|
20
|
+
return {
|
|
21
|
+
name: 'tool.search',
|
|
22
|
+
description:
|
|
23
|
+
'Look up deferred tool schemas. Use `select:name1,name2` to fetch by name, or free-text keywords (e.g., "filesystem read") to search descriptions and hints. Returns matching tools with their full parameter schema; the brain can immediately call them next turn.',
|
|
24
|
+
alwaysLoad: true,
|
|
25
|
+
searchHint: 'meta search deferred tools schemas',
|
|
26
|
+
schema: ToolSearchSchema,
|
|
27
|
+
handler: args => {
|
|
28
|
+
const max = args.max_results ?? 5
|
|
29
|
+
const matches = registry.search(args.query, max)
|
|
30
|
+
const schemas = matches.map(t => {
|
|
31
|
+
registry.unlock(t.name)
|
|
32
|
+
return {
|
|
33
|
+
name: t.name,
|
|
34
|
+
description: t.description,
|
|
35
|
+
parameters: t.parametersOverride ?? zodToJsonSchema(t.schema),
|
|
36
|
+
searchHint: t.searchHint,
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
data: {
|
|
42
|
+
query: args.query,
|
|
43
|
+
matched: schemas.length,
|
|
44
|
+
tools: schemas,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { cardToTextRecords, emptyCard, parseCard, renderCard } from './card'
|
|
3
|
+
|
|
4
|
+
describe('card', () => {
|
|
5
|
+
test('parses frontmatter + body', () => {
|
|
6
|
+
const md = `---
|
|
7
|
+
name: Alice
|
|
8
|
+
bio: research lyra
|
|
9
|
+
skills:
|
|
10
|
+
- research
|
|
11
|
+
- writing
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Free-form body here.`
|
|
15
|
+
const c = parseCard(md)
|
|
16
|
+
expect(c.frontmatter.name).toBe('Alice')
|
|
17
|
+
expect(c.frontmatter.bio).toBe('research lyra')
|
|
18
|
+
expect(c.frontmatter.skills).toEqual(['research', 'writing'])
|
|
19
|
+
expect(c.body.trim()).toBe('Free-form body here.')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('rejects missing name', () => {
|
|
23
|
+
expect(() => parseCard('---\nbio: no name\n---\nbody')).toThrow()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('round-trips via render', () => {
|
|
27
|
+
const c = {
|
|
28
|
+
frontmatter: { name: 'Bob', bio: 'hi', skills: ['code'] },
|
|
29
|
+
body: 'Body.',
|
|
30
|
+
}
|
|
31
|
+
const rendered = renderCard(c)
|
|
32
|
+
const parsed = parseCard(rendered)
|
|
33
|
+
expect(parsed.frontmatter.name).toBe('Bob')
|
|
34
|
+
expect(parsed.frontmatter.skills).toEqual(['code'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('emptyCard is parseable after setting a name', () => {
|
|
38
|
+
const c = emptyCard()
|
|
39
|
+
c.frontmatter.name = 'Temp'
|
|
40
|
+
const rendered = renderCard(c)
|
|
41
|
+
const parsed = parseCard(rendered)
|
|
42
|
+
expect(parsed.frontmatter.name).toBe('Temp')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('cardToTextRecords includes address + agent:inft when present', () => {
|
|
46
|
+
const c = {
|
|
47
|
+
frontmatter: {
|
|
48
|
+
name: 'Alice',
|
|
49
|
+
bio: 'hi',
|
|
50
|
+
skills: ['research', 'writing'],
|
|
51
|
+
inft: 'eip155:5003:0xabc:42',
|
|
52
|
+
avatar: '0xdeadbeef',
|
|
53
|
+
},
|
|
54
|
+
body: '',
|
|
55
|
+
}
|
|
56
|
+
const r = cardToTextRecords(c, '0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec')
|
|
57
|
+
expect(r.address).toBe('0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec')
|
|
58
|
+
expect(r['agent:bio']).toBe('hi')
|
|
59
|
+
expect(r['agent:skills']).toBe('research,writing')
|
|
60
|
+
expect(r['agent:inft']).toBe('eip155:5003:0xabc:42')
|
|
61
|
+
expect(r.avatar).toBe('0xdeadbeef')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('cardToTextRecords omits empty fields', () => {
|
|
65
|
+
const c = emptyCard()
|
|
66
|
+
c.frontmatter.name = 'NoExtras'
|
|
67
|
+
const r = cardToTextRecords(c)
|
|
68
|
+
expect(Object.keys(r)).toHaveLength(0)
|
|
69
|
+
})
|
|
70
|
+
})
|