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