@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.
- package/commands/try-all-challenges.ts +1 -1
- package/docs/TABLE-OF-CONTENTS.md +0 -3
- package/docs/examples/structured-output-with-assistants.md +144 -0
- package/docs/tutorials/20-browser-esm.md +234 -0
- package/package.json +1 -1
- package/src/agi/container.server.ts +4 -0
- package/src/agi/features/assistant.ts +132 -2
- package/src/agi/features/browser-use.ts +623 -0
- package/src/agi/features/conversation.ts +135 -45
- package/src/agi/lib/interceptor-chain.ts +79 -0
- package/src/bootstrap/generated.ts +381 -308
- package/src/cli/build-info.ts +2 -2
- package/src/clients/rest.ts +7 -7
- package/src/commands/chat.ts +22 -0
- package/src/commands/describe.ts +67 -2
- package/src/commands/prompt.ts +23 -3
- package/src/container.ts +411 -113
- package/src/helper.ts +189 -5
- package/src/introspection/generated.agi.ts +17664 -11568
- package/src/introspection/generated.node.ts +4891 -1860
- package/src/introspection/generated.web.ts +379 -291
- package/src/introspection/index.ts +7 -0
- package/src/introspection/scan.ts +224 -7
- package/src/node/container.ts +31 -10
- package/src/node/features/content-db.ts +7 -7
- package/src/node/features/disk-cache.ts +11 -11
- package/src/node/features/esbuild.ts +3 -3
- package/src/node/features/file-manager.ts +37 -16
- package/src/node/features/fs.ts +64 -25
- package/src/node/features/git.ts +10 -10
- package/src/node/features/helpers.ts +25 -18
- package/src/node/features/ink.ts +13 -13
- package/src/node/features/ipc-socket.ts +8 -8
- package/src/node/features/networking.ts +3 -3
- package/src/node/features/os.ts +7 -7
- package/src/node/features/package-finder.ts +15 -15
- package/src/node/features/proc.ts +1 -1
- package/src/node/features/ui.ts +13 -13
- package/src/node/features/vm.ts +4 -4
- package/src/scaffolds/generated.ts +1 -1
- package/src/servers/express.ts +6 -6
- package/src/servers/mcp.ts +4 -4
- package/src/servers/socket.ts +6 -6
- package/test/interceptor-chain.test.ts +61 -0
- package/docs/apis/features/node/window-manager.md +0 -445
- package/docs/examples/window-manager-layouts.md +0 -180
- package/docs/examples/window-manager.md +0 -125
- package/docs/window-manager-fix.md +0 -249
- package/scripts/test-window-manager-lifecycle.ts +0 -86
- package/scripts/test-window-manager.ts +0 -43
- 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
|
-
'--
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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')) {
|