@soederpop/luca 0.0.28 → 0.0.30

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 (51) hide show
  1. package/commands/try-all-challenges.ts +1 -1
  2. package/docs/TABLE-OF-CONTENTS.md +0 -3
  3. package/docs/examples/structured-output-with-assistants.md +144 -0
  4. package/docs/tutorials/20-browser-esm.md +234 -0
  5. package/package.json +1 -1
  6. package/src/agi/container.server.ts +4 -0
  7. package/src/agi/features/assistant.ts +132 -2
  8. package/src/agi/features/browser-use.ts +623 -0
  9. package/src/agi/features/conversation.ts +135 -45
  10. package/src/agi/lib/interceptor-chain.ts +79 -0
  11. package/src/bootstrap/generated.ts +381 -308
  12. package/src/cli/build-info.ts +2 -2
  13. package/src/clients/rest.ts +7 -7
  14. package/src/commands/chat.ts +22 -0
  15. package/src/commands/describe.ts +67 -2
  16. package/src/commands/prompt.ts +23 -3
  17. package/src/container.ts +411 -113
  18. package/src/helper.ts +189 -5
  19. package/src/introspection/generated.agi.ts +17664 -11568
  20. package/src/introspection/generated.node.ts +4891 -1860
  21. package/src/introspection/generated.web.ts +379 -291
  22. package/src/introspection/index.ts +7 -0
  23. package/src/introspection/scan.ts +224 -7
  24. package/src/node/container.ts +31 -10
  25. package/src/node/features/content-db.ts +7 -7
  26. package/src/node/features/disk-cache.ts +11 -11
  27. package/src/node/features/esbuild.ts +3 -3
  28. package/src/node/features/file-manager.ts +37 -16
  29. package/src/node/features/fs.ts +64 -25
  30. package/src/node/features/git.ts +10 -10
  31. package/src/node/features/helpers.ts +25 -18
  32. package/src/node/features/ink.ts +13 -13
  33. package/src/node/features/ipc-socket.ts +8 -8
  34. package/src/node/features/networking.ts +3 -3
  35. package/src/node/features/os.ts +7 -7
  36. package/src/node/features/package-finder.ts +15 -15
  37. package/src/node/features/proc.ts +1 -1
  38. package/src/node/features/ui.ts +13 -13
  39. package/src/node/features/vm.ts +4 -4
  40. package/src/scaffolds/generated.ts +1 -1
  41. package/src/servers/express.ts +6 -6
  42. package/src/servers/mcp.ts +4 -4
  43. package/src/servers/socket.ts +6 -6
  44. package/test/interceptor-chain.test.ts +61 -0
  45. package/docs/apis/features/node/window-manager.md +0 -445
  46. package/docs/examples/window-manager-layouts.md +0 -180
  47. package/docs/examples/window-manager.md +0 -125
  48. package/docs/window-manager-fix.md +0 -249
  49. package/scripts/test-window-manager-lifecycle.ts +0 -86
  50. package/scripts/test-window-manager.ts +0 -43
  51. package/src/node/features/window-manager.ts +0 -1603
@@ -241,7 +241,7 @@ Be specific and actionable. Reference concrete file paths, APIs, and patterns. T
241
241
  '--in-folder', sessionFolder,
242
242
  '--out-file', `${sessionFolder}/logs/synthesis-session.md`,
243
243
  '--dont-touch-file',
244
- '--preserve-frontmatter',
244
+ '--include-frontmatter',
245
245
  ], {
246
246
  onOutput: (str: string) => { process.stdout.write(str) },
247
247
  onError: (str: string) => { process.stderr.write(str) },
@@ -57,8 +57,6 @@
57
57
  - [ui](./examples/ui.md)
58
58
  - [Vault](./examples/vault.md)
59
59
  - [vm](./examples/vm.md)
60
- - [Window Manager](./examples/window-manager.md)
61
- - [Window Manager Layouts](./examples/window-manager-layouts.md)
62
60
  - [YAML](./examples/yaml.md)
63
61
  - [YAML Tree](./examples/yaml-tree.md)
64
62
 
@@ -159,7 +157,6 @@
159
157
  - [UI (features.ui)](./apis/features/node/ui.md)
160
158
  - [WebVault (features.vault)](./apis/features/node/vault.md)
161
159
  - [VM (features.vm)](./apis/features/node/vm.md)
162
- - [WindowManager (features.windowManager)](./apis/features/node/window-manager.md)
163
160
  - [YAML (features.yaml)](./apis/features/node/yaml.md)
164
161
  - [YamlTree (features.yamlTree)](./apis/features/node/yaml-tree.md)
165
162
  - [AssetLoader (features.assetLoader)](./apis/features/web/asset-loader.md)
@@ -0,0 +1,144 @@
1
+ ---
2
+ title: "Structured Output with Assistants"
3
+ tags: [assistant, conversation, structured-output, zod, openai]
4
+ lastTested: null
5
+ lastTestPassed: null
6
+ ---
7
+
8
+ # Structured Output with Assistants
9
+
10
+ Get typed, schema-validated JSON responses from OpenAI instead of raw text strings.
11
+
12
+ ## Overview
13
+
14
+ OpenAI's Structured Outputs feature constrains the model to return JSON that exactly matches a schema you provide. Combined with Zod, this means `ask()` can return parsed objects instead of strings — no regex parsing, no "please respond in JSON", no malformed output.
15
+
16
+ Pass a `schema` option to `ask()` and the response comes back as a parsed object guaranteed to match your schema.
17
+
18
+ ## Basic: Extract Structured Data
19
+
20
+ The simplest use case — ask a question and get structured data back.
21
+
22
+ ```ts
23
+ const { z } = container
24
+ const conversation = container.feature('conversation', {
25
+ model: 'gpt-4.1-mini',
26
+ history: [{ role: 'system', content: 'You are a helpful data extraction assistant.' }]
27
+ })
28
+
29
+ const result = await conversation.ask('The founders of Apple are Steve Jobs, Steve Wozniak, and Ronald Wayne. They started it in 1976 in Los Altos, California.', {
30
+ schema: z.object({
31
+ company: z.string(),
32
+ foundedYear: z.number(),
33
+ location: z.string(),
34
+ founders: z.array(z.string()),
35
+ }).describe('CompanyInfo')
36
+ })
37
+
38
+ console.log('Company:', result.company)
39
+ console.log('Founded:', result.foundedYear)
40
+ console.log('Location:', result.location)
41
+ console.log('Founders:', result.founders)
42
+ ```
43
+
44
+ The `.describe()` on the schema gives OpenAI the schema name — keep it short and descriptive.
45
+
46
+ ## Enums and Categorization
47
+
48
+ Structured outputs work great for classification tasks where you want the model to pick from a fixed set of values.
49
+
50
+ ```ts
51
+ const { z } = container
52
+ const conversation = container.feature('conversation', {
53
+ model: 'gpt-4.1-mini',
54
+ history: [{ role: 'system', content: 'You are a helpful assistant.' }]
55
+ })
56
+
57
+ const sentiment = await conversation.ask('I absolutely love this product, it changed my life!', {
58
+ schema: z.object({
59
+ sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']),
60
+ confidence: z.number(),
61
+ reasoning: z.string(),
62
+ }).describe('SentimentAnalysis')
63
+ })
64
+
65
+ console.log('Sentiment:', sentiment.sentiment)
66
+ console.log('Confidence:', sentiment.confidence)
67
+ console.log('Reasoning:', sentiment.reasoning)
68
+ ```
69
+
70
+ Because the model is constrained by the schema, `sentiment` will always be one of the four allowed values.
71
+
72
+ ## Nested Objects and Arrays
73
+
74
+ Schemas can be as complex as you need. Here we extract a structured analysis with nested objects.
75
+
76
+ ```ts
77
+ const { z } = container
78
+ const conversation = container.feature('conversation', {
79
+ model: 'gpt-4.1-mini',
80
+ history: [{ role: 'system', content: 'You are a technical analyst.' }]
81
+ })
82
+
83
+ const analysis = await conversation.ask(
84
+ 'TypeScript 5.5 introduced inferred type predicates, which automatically narrow types in filter callbacks. It also added isolated declarations for faster builds in monorepos, and a new regex syntax checking feature.',
85
+ {
86
+ schema: z.object({
87
+ subject: z.string(),
88
+ version: z.string(),
89
+ features: z.array(z.object({
90
+ name: z.string(),
91
+ category: z.enum(['type-system', 'performance', 'developer-experience', 'syntax', 'other']),
92
+ summary: z.string(),
93
+ })),
94
+ featureCount: z.number(),
95
+ }).describe('ReleaseAnalysis')
96
+ }
97
+ )
98
+
99
+ console.log('Subject:', analysis.subject, analysis.version)
100
+ console.log('Features:')
101
+ for (const f of analysis.features) {
102
+ console.log(` [${f.category}] ${f.name}: ${f.summary}`)
103
+ }
104
+ console.log('Total features:', analysis.featureCount)
105
+ ```
106
+
107
+ Every level of nesting is validated — the model cannot return a feature without a category or skip required fields.
108
+
109
+ ## With an Assistant
110
+
111
+ Structured outputs work the same way through the assistant API. The schema passes straight through to the underlying conversation.
112
+
113
+ ```ts
114
+ const { z } = container
115
+ const assistant = container.feature('assistant', {
116
+ systemPrompt: 'You are a code review assistant. You analyze code snippets and provide structured feedback.',
117
+ model: 'gpt-4.1-mini',
118
+ })
119
+
120
+ const review = await assistant.ask(
121
+ 'Review this: function add(a, b) { return a + b }',
122
+ {
123
+ schema: z.object({
124
+ issues: z.array(z.object({
125
+ severity: z.enum(['info', 'warning', 'error']),
126
+ message: z.string(),
127
+ })),
128
+ suggestion: z.string(),
129
+ score: z.number(),
130
+ }).describe('CodeReview')
131
+ }
132
+ )
133
+
134
+ console.log('Score:', review.score)
135
+ console.log('Suggestion:', review.suggestion)
136
+ console.log('Issues:')
137
+ for (const issue of review.issues) {
138
+ console.log(` [${issue.severity}] ${issue.message}`)
139
+ }
140
+ ```
141
+
142
+ ## Summary
143
+
144
+ This demo covered extracting structured data, classification with enums, nested schema validation, and using structured outputs through both the conversation and assistant APIs. The key is passing a Zod schema via `{ schema }` in the options to `ask()` — OpenAI guarantees the response matches, and you get a parsed object back.
@@ -0,0 +1,234 @@
1
+ ---
2
+ title: "Browser: Import Luca from esm.sh"
3
+ tags:
4
+ - browser
5
+ - esm
6
+ - web
7
+ - quickstart
8
+ - cdn
9
+ ---
10
+ # Browser: Import Luca from esm.sh
11
+
12
+ You can use Luca in any browser environment — no bundler, no build step. Import it from [esm.sh](https://esm.sh) and you get the singleton container on `window.luca`, ready to go. All the same APIs apply.
13
+
14
+ ## Basic Setup
15
+
16
+ ```html
17
+ <script type="module">
18
+ import "https://esm.sh/@soederpop/luca/web"
19
+
20
+ const container = window.luca
21
+ console.log(container.uuid) // unique container ID
22
+ console.log(container.features.available) // ['assetLoader', 'voice', 'speech', 'network', 'vault', 'vm', 'esbuild', 'helpers', 'containerLink']
23
+ </script>
24
+ ```
25
+
26
+ The import triggers module evaluation, which creates the `WebContainer` singleton and attaches it to `window.luca`. That's it.
27
+
28
+ If you prefer a named import:
29
+
30
+ ```html
31
+ <script type="module">
32
+ import container from "https://esm.sh/@soederpop/luca/web"
33
+ // container === window.luca
34
+ </script>
35
+ ```
36
+
37
+ ## Using Features
38
+
39
+ Once you have the container, features work exactly like they do on the server — lazy-loaded via `container.feature()`.
40
+
41
+ ```html
42
+ <script type="module">
43
+ import "https://esm.sh/@soederpop/luca/web"
44
+ const { luca: container } = window
45
+
46
+ // Load a script from a CDN
47
+ const assetLoader = container.feature('assetLoader')
48
+ await assetLoader.loadScript('https://cdn.jsdelivr.net/npm/chart.js')
49
+
50
+ // Load a stylesheet
51
+ await assetLoader.loadStylesheet('https://cdn.jsdelivr.net/npm/water.css@2/out/water.css')
52
+
53
+ // Text-to-speech
54
+ const speech = container.feature('speech')
55
+ speech.speak('Hello from Luca')
56
+
57
+ // Voice recognition
58
+ const voice = container.feature('voice')
59
+ voice.on('transcript', ({ text }) => console.log('Heard:', text))
60
+ voice.start()
61
+ </script>
62
+ ```
63
+
64
+ ## State and Events
65
+
66
+ The container is a state machine and event bus. This works identically to the server.
67
+
68
+ ```html
69
+ <script type="module">
70
+ import container from "https://esm.sh/@soederpop/luca/web"
71
+
72
+ // Listen for state changes
73
+ container.on('stateChanged', ({ changes }) => {
74
+ console.log('State changed:', changes)
75
+ })
76
+
77
+ // Feature-level state and events
78
+ const voice = container.feature('voice')
79
+ voice.on('stateChanged', ({ changes }) => {
80
+ document.getElementById('status').textContent = changes.listening ? 'Listening...' : 'Idle'
81
+ })
82
+ </script>
83
+ ```
84
+
85
+ ## REST Client
86
+
87
+ Make HTTP requests with the built-in REST client. Methods return parsed JSON directly.
88
+
89
+ ```html
90
+ <script type="module">
91
+ import container from "https://esm.sh/@soederpop/luca/web"
92
+
93
+ const api = container.client('rest', { baseURL: 'https://jsonplaceholder.typicode.com' })
94
+ const posts = await api.get('/posts')
95
+ console.log(posts) // array of post objects, not a Response wrapper
96
+ </script>
97
+ ```
98
+
99
+ ## WebSocket Client
100
+
101
+ Connect to a WebSocket server:
102
+
103
+ ```html
104
+ <script type="module">
105
+ import container from "https://esm.sh/@soederpop/luca/web"
106
+
107
+ const socket = container.client('socket', { url: 'ws://localhost:3000' })
108
+ socket.on('message', (data) => console.log('Received:', data))
109
+ socket.send({ type: 'hello' })
110
+ </script>
111
+ ```
112
+
113
+ ## Extending: Custom Features
114
+
115
+ The container exposes the `Feature` class directly, so you can create your own features without any additional imports.
116
+
117
+ ```html
118
+ <script type="module">
119
+ import container from "https://esm.sh/@soederpop/luca/web"
120
+
121
+ const { Feature } = container
122
+
123
+ class Theme extends Feature {
124
+ static shortcut = 'features.theme'
125
+ static { Feature.register(this, 'theme') }
126
+
127
+ get current() {
128
+ return this.state.get('mode') || 'light'
129
+ }
130
+
131
+ toggle() {
132
+ const next = this.current === 'light' ? 'dark' : 'light'
133
+ this.state.set('mode', next)
134
+ document.documentElement.setAttribute('data-theme', next)
135
+ this.emit('themeChanged', { mode: next })
136
+ }
137
+ }
138
+
139
+ const theme = container.feature('theme')
140
+ theme.on('themeChanged', ({ mode }) => console.log('Theme:', mode))
141
+ theme.toggle() // => Theme: dark
142
+ </script>
143
+ ```
144
+
145
+ ## Utilities
146
+
147
+ The container's built-in utilities are available in the browser too.
148
+
149
+ ```html
150
+ <script type="module">
151
+ import container from "https://esm.sh/@soederpop/luca/web"
152
+
153
+ // UUID generation
154
+ const id = container.utils.uuid()
155
+
156
+ // Lodash helpers
157
+ const { groupBy, keyBy, pick } = container.utils.lodash
158
+
159
+ // String utilities
160
+ const { camelCase, kebabCase } = container.utils.stringUtils
161
+ </script>
162
+ ```
163
+
164
+ ## Full Example: A Minimal App
165
+
166
+ ```html
167
+ <!DOCTYPE html>
168
+ <html lang="en">
169
+ <head>
170
+ <meta charset="UTF-8">
171
+ <title>Luca Browser Demo</title>
172
+ </head>
173
+ <body>
174
+ <h1>Luca Browser Demo</h1>
175
+ <button id="speak">Speak</button>
176
+ <button id="theme">Toggle Theme</button>
177
+ <pre id="output"></pre>
178
+
179
+ <script type="module">
180
+ import container from "https://esm.sh/@soederpop/luca/web"
181
+
182
+ const log = (msg) => {
183
+ document.getElementById('output').textContent += msg + '\n'
184
+ }
185
+
186
+ // Load a stylesheet
187
+ const assets = container.feature('assetLoader')
188
+ await assets.loadStylesheet('https://cdn.jsdelivr.net/npm/water.css@2/out/water.css')
189
+
190
+ // Custom feature
191
+ const { Feature } = container
192
+
193
+ class Theme extends Feature {
194
+ static shortcut = 'features.theme'
195
+ static { Feature.register(this, 'theme') }
196
+
197
+ toggle() {
198
+ const next = (this.state.get('mode') || 'light') === 'light' ? 'dark' : 'light'
199
+ this.state.set('mode', next)
200
+ document.documentElement.style.colorScheme = next
201
+ this.emit('themeChanged', { mode: next })
202
+ }
203
+ }
204
+
205
+ const theme = container.feature('theme')
206
+ theme.on('themeChanged', ({ mode }) => log(`Theme: ${mode}`))
207
+
208
+ // Speech
209
+ const speech = container.feature('speech')
210
+
211
+ document.getElementById('speak').onclick = () => speech.speak('Hello from Luca')
212
+ document.getElementById('theme').onclick = () => theme.toggle()
213
+
214
+ log(`Container ID: ${container.uuid}`)
215
+ log(`Features: ${container.features.available.join(', ')}`)
216
+ </script>
217
+ </body>
218
+ </html>
219
+ ```
220
+
221
+ Save this as an HTML file, open it in a browser, and everything works — no npm, no bundler, no build step.
222
+
223
+ ## Gotchas
224
+
225
+ - **esm.sh caches aggressively.** Pin a version if you need stability: `https://esm.sh/@soederpop/luca@0.0.29/web`
226
+ - **Browser features only.** The web container doesn't include node-specific features like `fs`, `git`, `proc`, or `docker`. If you need server features, run Luca on the server and connect via the REST or WebSocket clients.
227
+ - **`window.luca` is the singleton.** Don't call `createContainer()` — it just warns and returns the same instance. If you need isolation, use `container.subcontainer()`.
228
+ - **CORS applies.** REST client requests from the browser are subject to browser CORS rules. Your API must send the right headers.
229
+
230
+ ## What's Next
231
+
232
+ - [State and Events](./05-state-and-events.md) — deep dive into the state machine and event bus (works identically in the browser)
233
+ - [Creating Features](./10-creating-features.md) — full anatomy of a feature with schemas, state, and events
234
+ - [Clients](./09-clients.md) — REST and WebSocket client APIs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soederpop/luca",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "website": "https://luca.soederpop.com",
5
5
  "description": "lightweight universal conversational architecture AKA Le Ultimate Component Architecture AKA Last Universal Common Ancestor, part AI part Human",
6
6
  "author": "jon soeder aka the people's champ <jon@soederpop.com>",
@@ -11,6 +11,7 @@ import { Assistant } from './features/assistant'
11
11
  import { AssistantsManager } from './features/assistants-manager'
12
12
  import { DocsReader } from './features/docs-reader'
13
13
  import { SkillsLibrary } from './features/skills-library'
14
+ import { BrowserUse } from './features/browser-use'
14
15
  import { SemanticSearch } from '@soederpop/luca/node/features/semantic-search'
15
16
  import { ContentDb } from '@soederpop/luca/node/features/content-db'
16
17
 
@@ -26,6 +27,7 @@ export {
26
27
  AssistantsManager,
27
28
  DocsReader,
28
29
  SkillsLibrary,
30
+ BrowserUse,
29
31
  SemanticSearch,
30
32
  ContentDb,
31
33
  NodeContainer,
@@ -49,6 +51,7 @@ export interface AGIFeatures extends NodeFeatures {
49
51
  assistantsManager: typeof AssistantsManager
50
52
  docsReader: typeof DocsReader
51
53
  skillsLibrary: typeof SkillsLibrary
54
+ browserUse: typeof BrowserUse
52
55
  }
53
56
 
54
57
  export interface ConversationFactoryOptions {
@@ -121,6 +124,7 @@ const container = new AGIContainer()
121
124
  .use(AssistantsManager)
122
125
  .use(DocsReader)
123
126
  .use(SkillsLibrary)
127
+ .use(BrowserUse)
124
128
  .use(SemanticSearch)
125
129
 
126
130
  container.docs = container.feature('contentDb', {
@@ -7,6 +7,7 @@ import type { AGIContainer } from '../container.server.js'
7
7
  import type { ContentDb } from '@soederpop/luca/node'
8
8
  import type { ConversationHistory, ConversationMeta } from './conversation-history'
9
9
  import hashObject from '../../hash-object.js'
10
+ import { InterceptorChain, type InterceptorFn, type InterceptorPoints, type InterceptorPoint } from '../lib/interceptor-chain.js'
10
11
 
11
12
  declare module '@soederpop/luca/feature' {
12
13
  interface AvailableFeatures {
@@ -85,6 +86,15 @@ export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
85
86
 
86
87
  /** When true, prepend a timestamp to each user message so the assistant can track the passage of time across sessions */
87
88
  injectTimestamps: z.boolean().default(false).describe('Prepend timestamps to user messages so the assistant can perceive time passing between sessions'),
89
+
90
+ /** Strict allowlist of tool names to include. Only these tools will be available. Supports "*" glob matching. */
91
+ allowTools: z.array(z.string()).optional().describe('Strict allowlist of tool name patterns. Only matching tools are available. Supports * glob matching.'),
92
+
93
+ /** Denylist of tool names to exclude. Matching tools will be removed. Supports "*" glob matching. */
94
+ forbidTools: z.array(z.string()).optional().describe('Denylist of tool name patterns to exclude. Supports * glob matching.'),
95
+
96
+ /** Convenience alias for allowTools — an explicit list of tool names (exact matches only). */
97
+ toolNames: z.array(z.string()).optional().describe('Explicit list of tool names to include (exact match). Shorthand for allowTools without glob patterns.'),
88
98
  })
89
99
 
90
100
  export type AssistantState = z.infer<typeof AssistantStateSchema>
@@ -113,6 +123,26 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
113
123
 
114
124
  static { Feature.register(this, 'assistant') }
115
125
 
126
+ readonly interceptors = {
127
+ beforeAsk: new InterceptorChain<InterceptorPoints['beforeAsk']>(),
128
+ beforeTurn: new InterceptorChain<InterceptorPoints['beforeTurn']>(),
129
+ beforeToolCall: new InterceptorChain<InterceptorPoints['beforeToolCall']>(),
130
+ afterToolCall: new InterceptorChain<InterceptorPoints['afterToolCall']>(),
131
+ beforeResponse: new InterceptorChain<InterceptorPoints['beforeResponse']>(),
132
+ }
133
+
134
+ /**
135
+ * Register an interceptor at a given point in the pipeline.
136
+ *
137
+ * @param point - The interception point
138
+ * @param fn - Middleware function receiving (ctx, next)
139
+ * @returns this, for chaining
140
+ */
141
+ intercept<K extends InterceptorPoint>(point: K, fn: InterceptorFn<InterceptorPoints[K]>): this {
142
+ this.interceptors[point].add(fn as any)
143
+ return this
144
+ }
145
+
116
146
  /** @returns Default state with the assistant not started, zero conversations, and the resolved folder path. */
117
147
  override get initialState(): AssistantState {
118
148
  return {
@@ -313,7 +343,59 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
313
343
 
314
344
  /** The tools registered with this assistant. */
315
345
  get tools(): Record<string, ConversationTool> {
316
- return (this.state.get('tools') || {}) as Record<string, ConversationTool>
346
+ const all = (this.state.get('tools') || {}) as Record<string, ConversationTool>
347
+ return this.applyToolFilters(all)
348
+ }
349
+
350
+ /**
351
+ * Apply allowTools, forbidTools, and toolNames filters from options.
352
+ * toolNames is treated as an exact-match allowlist. allowTools/forbidTools support "*" glob patterns.
353
+ * allowTools is applied first (strict allowlist), then forbidTools removes from whatever remains.
354
+ */
355
+ private applyToolFilters(tools: Record<string, ConversationTool>): Record<string, ConversationTool> {
356
+ const { allowTools, forbidTools, toolNames } = this.options
357
+ if (!allowTools && !forbidTools && !toolNames) return tools
358
+
359
+ let names = Object.keys(tools)
360
+
361
+ // toolNames is a strict exact-match allowlist
362
+ if (toolNames) {
363
+ const allowed = new Set(toolNames)
364
+ names = names.filter(n => allowed.has(n))
365
+ }
366
+
367
+ // allowTools: only keep names matching at least one pattern
368
+ if (allowTools) {
369
+ names = names.filter(n => allowTools.some(pattern => this.matchToolPattern(pattern, n)))
370
+ }
371
+
372
+ // forbidTools: remove names matching any pattern
373
+ if (forbidTools) {
374
+ names = names.filter(n => !forbidTools.some(pattern => this.matchToolPattern(pattern, n)))
375
+ }
376
+
377
+ const result: Record<string, ConversationTool> = {}
378
+ for (const n of names) {
379
+ result[n] = tools[n]
380
+ }
381
+ return result
382
+ }
383
+
384
+ /**
385
+ * Match a tool name against a pattern that supports "*" as a wildcard.
386
+ * - "*" matches everything
387
+ * - "prefix*" matches names starting with prefix
388
+ * - "*suffix" matches names ending with suffix
389
+ * - "pre*suf" matches names starting with pre and ending with suf
390
+ * - exact string matches exactly
391
+ */
392
+ private matchToolPattern(pattern: string, name: string): boolean {
393
+ if (pattern === '*') return true
394
+ if (!pattern.includes('*')) return pattern === name
395
+
396
+ // Convert glob pattern to regex: escape regex chars, replace * with .*
397
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
398
+ return new RegExp(`^${escaped}$`).test(name)
317
399
  }
318
400
 
319
401
  /**
@@ -911,6 +993,38 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
911
993
  this.conversation.on('toolResult', (name: string, result: any) => this.emit('toolResult', name, result))
912
994
  this.conversation.on('toolError', (name: string, error: any) => this.emit('toolError', name, error))
913
995
 
996
+ // Install interceptor-aware tool executor on the conversation
997
+ this.conversation.toolExecutor = async (name: string, args: Record<string, any>, handler: (...a: any[]) => Promise<any>) => {
998
+ const ctx = { name, args, result: undefined as string | undefined, error: undefined, skip: false }
999
+
1000
+ await this.interceptors.beforeToolCall.run(ctx, async () => {})
1001
+
1002
+ if (ctx.skip) {
1003
+ const result = ctx.result ?? JSON.stringify({ skipped: true })
1004
+ this.emit('toolResult', ctx.name, result)
1005
+ return result
1006
+ }
1007
+
1008
+ try {
1009
+ this.emit('toolCall', ctx.name, ctx.args)
1010
+ const output = await handler(ctx.args)
1011
+ ctx.result = typeof output === 'string' ? output : JSON.stringify(output)
1012
+ } catch (err: any) {
1013
+ ctx.error = err
1014
+ ctx.result = JSON.stringify({ error: err.message || String(err) })
1015
+ }
1016
+
1017
+ await this.interceptors.afterToolCall.run(ctx, async () => {})
1018
+
1019
+ if (ctx.error && !ctx.result?.includes('"error"')) {
1020
+ this.emit('toolError', ctx.name, ctx.error)
1021
+ } else {
1022
+ this.emit('toolResult', ctx.name, ctx.result!)
1023
+ }
1024
+
1025
+ return ctx.result!
1026
+ }
1027
+
914
1028
  // Load conversation history for non-lifecycle modes
915
1029
  await this.loadConversationHistory()
916
1030
 
@@ -961,7 +1075,23 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
961
1075
  question = this.prependTimestamp(question)
962
1076
  }
963
1077
 
964
- const result = await this.conversation.ask(question, options)
1078
+ // Run beforeAsk interceptors — they can rewrite the question or short-circuit
1079
+ if (this.interceptors.beforeAsk.hasInterceptors) {
1080
+ const ctx = { question, options } as InterceptorPoints['beforeAsk']
1081
+ await this.interceptors.beforeAsk.run(ctx, async () => {})
1082
+ if (ctx.result !== undefined) return ctx.result
1083
+ question = ctx.question
1084
+ options = ctx.options
1085
+ }
1086
+
1087
+ let result = await this.conversation.ask(question, options)
1088
+
1089
+ // Run beforeResponse interceptors — they can rewrite the final text
1090
+ if (this.interceptors.beforeResponse.hasInterceptors) {
1091
+ const ctx = { text: result }
1092
+ await this.interceptors.beforeResponse.run(ctx, async () => {})
1093
+ result = ctx.text
1094
+ }
965
1095
 
966
1096
  // Auto-save for non-lifecycle modes
967
1097
  if (this.options.historyMode !== 'lifecycle' && this.state.get('threadId')) {