@soederpop/luca 0.0.23 → 0.0.25
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/AGENTS.md +1 -1
- package/CLAUDE.md +6 -1
- package/assistants/codingAssistant/hooks.ts +0 -1
- package/assistants/lucaExpert/CORE.md +37 -0
- package/assistants/lucaExpert/hooks.ts +9 -0
- package/assistants/lucaExpert/tools.ts +177 -0
- package/commands/build-bootstrap.ts +41 -1
- package/docs/TABLE-OF-CONTENTS.md +0 -1
- package/docs/apis/clients/rest.md +5 -5
- package/docs/apis/features/agi/assistant.md +1 -1
- package/docs/apis/features/agi/conversation-history.md +6 -7
- package/docs/apis/features/agi/conversation.md +1 -1
- package/docs/apis/features/agi/semantic-search.md +1 -1
- package/docs/bootstrap/CLAUDE.md +1 -1
- package/docs/bootstrap/SKILL.md +7 -3
- package/docs/bootstrap/templates/luca-cli.ts +5 -0
- package/docs/mcp/readme.md +1 -1
- package/docs/tutorials/00-bootstrap.md +18 -0
- package/package.json +2 -2
- package/scripts/stamp-build.sh +12 -0
- package/scripts/test-docs-reader.ts +10 -0
- package/src/agi/container.server.ts +8 -5
- package/src/agi/features/assistant.ts +208 -55
- package/src/agi/features/assistants-manager.ts +138 -66
- package/src/agi/features/conversation.ts +46 -14
- package/src/agi/features/docs-reader.ts +142 -0
- package/src/agi/features/openapi.ts +1 -1
- package/src/agi/features/skills-library.ts +257 -313
- package/src/bootstrap/generated.ts +8163 -6
- package/src/cli/build-info.ts +4 -0
- package/src/cli/cli.ts +2 -1
- package/src/commands/bootstrap.ts +16 -1
- package/src/commands/eval.ts +6 -1
- package/src/commands/sandbox-mcp.ts +17 -7
- package/src/helper.ts +56 -2
- package/src/introspection/generated.agi.ts +2409 -1608
- package/src/introspection/generated.node.ts +902 -594
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/container.ts +1 -1
- package/src/node/features/content-db.ts +251 -13
- package/src/node/features/git.ts +90 -0
- package/src/node/features/grep.ts +1 -1
- package/src/node/features/proc.ts +1 -0
- package/src/node/features/tts.ts +1 -1
- package/src/node/features/vm.ts +48 -0
- package/src/scaffolds/generated.ts +2 -2
- package/assistants/architect/CORE.md +0 -3
- package/assistants/architect/hooks.ts +0 -3
- package/assistants/architect/tools.ts +0 -10
- package/docs/apis/features/agi/skills-library.md +0 -234
- package/docs/reports/assistant-bugs.md +0 -38
- package/docs/reports/attach-pattern-usage.md +0 -18
- package/docs/reports/code-audit-results.md +0 -391
- package/docs/reports/console-hmr-design.md +0 -170
- package/docs/reports/helper-semantic-search.md +0 -72
- package/docs/reports/introspection-audit-tasks.md +0 -378
- package/docs/reports/luca-mcp-improvements.md +0 -128
- package/test-integration/skills-library.test.ts +0 -157
|
@@ -35,27 +35,29 @@ export const AssistantsManagerEventsSchema = FeatureEventsSchema.extend({
|
|
|
35
35
|
z.string().describe('The assistant name'),
|
|
36
36
|
z.any().describe('The assistant instance'),
|
|
37
37
|
]).describe('Emitted when a new assistant instance is created'),
|
|
38
|
+
assistantRegistered: z.tuple([
|
|
39
|
+
z.string().describe('The assistant id'),
|
|
40
|
+
]).describe('Emitted when an assistant factory is registered at runtime'),
|
|
38
41
|
})
|
|
39
42
|
|
|
40
43
|
export const AssistantsManagerStateSchema = FeatureStateSchema.extend({
|
|
41
44
|
discovered: z.boolean().describe('Whether discovery has been run'),
|
|
42
45
|
assistantCount: z.number().describe('Number of discovered assistant definitions'),
|
|
43
46
|
activeCount: z.number().describe('Number of currently instantiated assistants'),
|
|
47
|
+
entries: z.record(z.string(), z.any()).describe('Discovered assistant entries keyed by name'),
|
|
48
|
+
instances: z.record(z.string(), z.any()).describe('Active assistant instances keyed by name'),
|
|
49
|
+
factories: z.record(z.string(), z.any()).describe('Registered factory functions keyed by name'),
|
|
44
50
|
})
|
|
45
51
|
|
|
46
|
-
export const AssistantsManagerOptionsSchema = FeatureOptionsSchema.extend({
|
|
47
|
-
/** Whether to automatically run discovery after initialization. */
|
|
48
|
-
autoDiscover: z.boolean().default(false).describe('Automatically discover assistants on init'),
|
|
49
|
-
})
|
|
52
|
+
export const AssistantsManagerOptionsSchema = FeatureOptionsSchema.extend({})
|
|
50
53
|
|
|
51
54
|
export type AssistantsManagerState = z.infer<typeof AssistantsManagerStateSchema>
|
|
52
55
|
export type AssistantsManagerOptions = z.infer<typeof AssistantsManagerOptionsSchema>
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
|
-
* Discovers and manages assistant definitions by
|
|
56
|
-
* in
|
|
57
|
-
* is treated as an assistant definition
|
|
58
|
-
* hooks.ts, voice.yaml, and a docs/ folder.
|
|
58
|
+
* Discovers and manages assistant definitions by looking for subdirectories
|
|
59
|
+
* in two locations: ~/.luca/assistants/ and cwd/assistants/. Each subdirectory
|
|
60
|
+
* containing a CORE.md is treated as an assistant definition.
|
|
59
61
|
*
|
|
60
62
|
* Use `discover()` to scan for available assistants, `list()` to enumerate them,
|
|
61
63
|
* and `create(name)` to instantiate one as a running Assistant feature.
|
|
@@ -66,8 +68,8 @@ export type AssistantsManagerOptions = z.infer<typeof AssistantsManagerOptionsSc
|
|
|
66
68
|
* ```typescript
|
|
67
69
|
* const manager = container.feature('assistantsManager')
|
|
68
70
|
* manager.discover()
|
|
69
|
-
* console.log(manager.list()) // [{ name: '
|
|
70
|
-
* const assistant = manager.create('
|
|
71
|
+
* console.log(manager.list()) // [{ name: 'chief-of-staff', folder: '...', ... }]
|
|
72
|
+
* const assistant = manager.create('chief-of-staff')
|
|
71
73
|
* const answer = await assistant.ask('Hello!')
|
|
72
74
|
* ```
|
|
73
75
|
*/
|
|
@@ -86,6 +88,9 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
|
|
|
86
88
|
discovered: false,
|
|
87
89
|
assistantCount: 0,
|
|
88
90
|
activeCount: 0,
|
|
91
|
+
entries: {},
|
|
92
|
+
instances: {},
|
|
93
|
+
factories: {},
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -93,57 +98,102 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
|
|
|
93
98
|
return super.container as AGIContainer
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
/** Discovered assistant entries keyed by name. */
|
|
102
|
+
get entries(): Record<string, AssistantEntry> {
|
|
103
|
+
return (this.state.get('entries') || {}) as Record<string, AssistantEntry>
|
|
104
|
+
}
|
|
98
105
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
/** Active assistant instances keyed by name. */
|
|
107
|
+
get instances(): Record<string, Assistant> {
|
|
108
|
+
return (this.state.get('instances') || {}) as Record<string, Assistant>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Registered factory functions keyed by name. */
|
|
112
|
+
get factories(): Record<string, (options: Record<string, any>) => Assistant> {
|
|
113
|
+
return (this.state.get('factories') || {}) as Record<string, (options: Record<string, any>) => Assistant>
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
/**
|
|
106
|
-
* Discovers assistants by
|
|
107
|
-
*
|
|
108
|
-
* treated as an assistant definition.
|
|
117
|
+
* Discovers assistants by listing subdirectories in ~/.luca/assistants/
|
|
118
|
+
* and cwd/assistants/. Each subdirectory containing a CORE.md is an assistant.
|
|
109
119
|
*
|
|
110
120
|
* @returns {Promise<this>} This instance, for chaining
|
|
111
121
|
*/
|
|
112
122
|
async discover(): Promise<this> {
|
|
113
|
-
const { fs, paths } = this.container
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
folder
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
const { fs, paths, os } = this.container
|
|
124
|
+
|
|
125
|
+
const discovered: Record<string, AssistantEntry> = {}
|
|
126
|
+
|
|
127
|
+
const locations = [
|
|
128
|
+
`${os.homedir}/.luca/assistants`,
|
|
129
|
+
paths.resolve('assistants'),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
for (const location of locations) {
|
|
133
|
+
if (!fs.exists(location)) continue
|
|
134
|
+
|
|
135
|
+
const dirEntries = fs.readdirSync(location)
|
|
136
|
+
|
|
137
|
+
for (const entry of dirEntries) {
|
|
138
|
+
const folder = `${location}/${entry}`
|
|
139
|
+
if (!fs.isDirectory(folder)) continue
|
|
140
|
+
|
|
141
|
+
const hasCorePrompt = fs.exists(`${folder}/CORE.md`)
|
|
142
|
+
if (!hasCorePrompt) continue
|
|
143
|
+
|
|
144
|
+
// Don't overwrite earlier entries (home takes precedence for same name)
|
|
145
|
+
if (!discovered[entry]) {
|
|
146
|
+
discovered[entry] = {
|
|
147
|
+
name: entry,
|
|
148
|
+
folder,
|
|
149
|
+
hasCorePrompt: true,
|
|
150
|
+
hasTools: fs.exists(`${folder}/tools.ts`),
|
|
151
|
+
hasHooks: fs.exists(`${folder}/hooks.ts`),
|
|
152
|
+
hasVoice: fs.exists(`${folder}/voice.yaml`),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
this.state.setState({
|
|
159
|
+
entries: discovered,
|
|
137
160
|
discovered: true,
|
|
138
|
-
assistantCount:
|
|
161
|
+
assistantCount: Object.keys(discovered).length,
|
|
139
162
|
})
|
|
140
163
|
|
|
141
164
|
this.emit('discovered')
|
|
142
165
|
return this
|
|
143
166
|
}
|
|
144
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Downloads the core assistants that ship with luca from GitHub
|
|
170
|
+
* into ~/.luca/assistants.
|
|
171
|
+
*
|
|
172
|
+
* @returns {Promise<{ files: string[] }>} The files extracted
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* const manager = container.feature('assistantsManager')
|
|
177
|
+
* await manager.downloadLucaCoreAssistants()
|
|
178
|
+
* await manager.discover()
|
|
179
|
+
* console.log(manager.available)
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
async downloadLucaCoreAssistants() {
|
|
183
|
+
const { os, paths } = this.container
|
|
184
|
+
const dest = `${os.homedir}/.luca/assistants`
|
|
185
|
+
const git = this.container.feature('git') as any
|
|
186
|
+
|
|
187
|
+
return await git.extractFolder({
|
|
188
|
+
source: 'soederpop/luca/assistants',
|
|
189
|
+
destination: dest,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
145
193
|
get available() {
|
|
146
|
-
|
|
194
|
+
const entryKeys = Object.keys(this.entries)
|
|
195
|
+
const factoryKeys = Object.keys(this.factories)
|
|
196
|
+
return [...new Set([...entryKeys, ...factoryKeys])]
|
|
147
197
|
}
|
|
148
198
|
|
|
149
199
|
/**
|
|
@@ -152,55 +202,77 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
|
|
|
152
202
|
* @returns {AssistantEntry[]} All discovered entries
|
|
153
203
|
*/
|
|
154
204
|
list(): AssistantEntry[] {
|
|
155
|
-
return
|
|
205
|
+
return Object.values(this.entries)
|
|
156
206
|
}
|
|
157
207
|
|
|
158
208
|
/**
|
|
159
209
|
* Looks up a single assistant entry by name.
|
|
160
210
|
*
|
|
161
|
-
* @param {string} name - The assistant name (e.g. '
|
|
211
|
+
* @param {string} name - The assistant name (e.g. 'chief-of-staff')
|
|
162
212
|
* @returns {AssistantEntry | undefined} The entry, or undefined if not found
|
|
163
213
|
*/
|
|
164
214
|
get(name: string): AssistantEntry | undefined {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (found) {
|
|
168
|
-
return found
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const aliases = this.available.filter(key => key === name || key.endsWith(`/${name}`))
|
|
172
|
-
|
|
173
|
-
if (aliases.length === 1) {
|
|
174
|
-
return this._entries.get(aliases[0]!)
|
|
175
|
-
} else if (aliases.length > 1) {
|
|
176
|
-
throw new Error(`Ambiguous assistant name "${name}", matches: ${aliases.join(', ')}`)
|
|
177
|
-
}
|
|
215
|
+
return this.entries[name]
|
|
216
|
+
}
|
|
178
217
|
|
|
179
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Registers a factory function that creates an assistant at runtime.
|
|
220
|
+
* Registered factories take precedence over discovered entries when
|
|
221
|
+
* calling `create()`.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} id - The assistant identifier
|
|
224
|
+
* @param {(options: Record<string, any>) => Assistant} factory - Factory function that receives create options and returns an Assistant
|
|
225
|
+
* @returns {this} This instance, for chaining
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* ```typescript
|
|
229
|
+
* manager.register('custom-bot', (options) => {
|
|
230
|
+
* return container.feature('assistant', {
|
|
231
|
+
* systemPrompt: 'You are a custom bot.',
|
|
232
|
+
* ...options,
|
|
233
|
+
* })
|
|
234
|
+
* })
|
|
235
|
+
* const bot = manager.create('custom-bot')
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
register(id: string, factory: (options: Record<string, any>) => Assistant): this {
|
|
239
|
+
this.state.set('factories', { ...this.factories, [id]: factory })
|
|
240
|
+
this.emit('assistantRegistered', id)
|
|
241
|
+
return this
|
|
180
242
|
}
|
|
181
243
|
|
|
182
244
|
/**
|
|
183
245
|
* Creates and returns a new Assistant feature instance for the given name.
|
|
246
|
+
* Checks runtime-registered factories first, then falls back to discovered entries.
|
|
184
247
|
* The assistant is configured with the discovered folder path. Any additional
|
|
185
248
|
* options are merged in.
|
|
186
249
|
*
|
|
187
|
-
* @param {string} name - The assistant name (must match a discovered entry)
|
|
250
|
+
* @param {string} name - The assistant name (must match a registered factory or discovered entry)
|
|
188
251
|
* @param {Record<string, any>} options - Additional options to pass to the Assistant constructor
|
|
189
252
|
* @returns {Assistant} The created assistant instance
|
|
190
|
-
* @throws {Error} If the name is not found among discovered assistants
|
|
253
|
+
* @throws {Error} If the name is not found among registered factories or discovered assistants
|
|
191
254
|
*
|
|
192
255
|
* @example
|
|
193
256
|
* ```typescript
|
|
194
|
-
* const assistant = manager.create('
|
|
257
|
+
* const assistant = manager.create('chief-of-staff', { model: 'gpt-4.1' })
|
|
195
258
|
* ```
|
|
196
259
|
*/
|
|
197
260
|
create(name: string, options: Record<string, any> = {}): Assistant {
|
|
261
|
+
// Check registered factories first
|
|
262
|
+
const factory = this.factories[name]
|
|
263
|
+
if (factory) {
|
|
264
|
+
const instance = factory(options)
|
|
265
|
+
const updated = { ...this.instances, [name]: instance }
|
|
266
|
+
this.state.setState({ instances: updated, activeCount: Object.keys(updated).length })
|
|
267
|
+
this.emit('assistantCreated', name, instance)
|
|
268
|
+
return instance
|
|
269
|
+
}
|
|
270
|
+
|
|
198
271
|
const entry = this.get(name)
|
|
199
272
|
|
|
200
273
|
if (!entry) {
|
|
201
|
-
const available = Array.from(this._entries.keys())
|
|
202
274
|
throw new Error(
|
|
203
|
-
`Assistant "${name}" not found. Available assistants: ${available.join(', ') || '(none — run discover() first)'}`
|
|
275
|
+
`Assistant "${name}" not found. Available assistants: ${this.available.join(', ') || '(none — run discover() first)'}`
|
|
204
276
|
)
|
|
205
277
|
}
|
|
206
278
|
|
|
@@ -209,8 +281,8 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
|
|
|
209
281
|
...options,
|
|
210
282
|
})
|
|
211
283
|
|
|
212
|
-
this.
|
|
213
|
-
this.state.
|
|
284
|
+
const updated = { ...this.instances, [name]: instance }
|
|
285
|
+
this.state.setState({ instances: updated, activeCount: Object.keys(updated).length })
|
|
214
286
|
this.emit('assistantCreated', name, instance)
|
|
215
287
|
|
|
216
288
|
return instance
|
|
@@ -223,7 +295,7 @@ export class AssistantsManager extends Feature<AssistantsManagerState, Assistant
|
|
|
223
295
|
* @returns {Assistant | undefined} The instance, or undefined if not yet created
|
|
224
296
|
*/
|
|
225
297
|
getInstance(name: string): Assistant | undefined {
|
|
226
|
-
return this.
|
|
298
|
+
return this.instances[name]
|
|
227
299
|
}
|
|
228
300
|
|
|
229
301
|
/**
|
|
@@ -94,6 +94,8 @@ export const ConversationStateSchema = FeatureStateSchema.extend({
|
|
|
94
94
|
estimatedInputTokens: z.number().describe('Estimated input token count for the current messages array'),
|
|
95
95
|
compactionCount: z.number().describe('Number of times compact() has been called'),
|
|
96
96
|
contextWindow: z.number().describe('The context window size for the current model'),
|
|
97
|
+
tools: z.record(z.string(), z.any()).describe('Active tools map including any runtime overrides'),
|
|
98
|
+
callMaxTokens: z.number().nullable().describe('Per-call max tokens override, cleared after each ask()'),
|
|
97
99
|
})
|
|
98
100
|
|
|
99
101
|
export const ConversationEventsSchema = FeatureEventsSchema.extend({
|
|
@@ -149,15 +151,9 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
149
151
|
|
|
150
152
|
static { Feature.register(this, 'conversation') }
|
|
151
153
|
|
|
152
|
-
private _callMaxTokens: number | undefined = undefined
|
|
153
|
-
|
|
154
154
|
/** Resolved max tokens: per-call override > options-level > undefined (no limit). */
|
|
155
155
|
private get maxTokens(): number | undefined {
|
|
156
|
-
return this.
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private get _tools(): Record<string, ConversationTool> {
|
|
160
|
-
return this.options.tools || {}
|
|
156
|
+
return (this.state.get('callMaxTokens') as number | null) ?? this.options.maxTokens ?? undefined
|
|
161
157
|
}
|
|
162
158
|
|
|
163
159
|
/** @returns Default state seeded from options: id, thread, model, initial history, and zero token usage. */
|
|
@@ -177,12 +173,46 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
177
173
|
estimatedInputTokens: 0,
|
|
178
174
|
compactionCount: 0,
|
|
179
175
|
contextWindow: this.options.contextWindow || getContextWindow(this.options.model || 'gpt-5'),
|
|
176
|
+
tools: (this.options.tools || {}) as Record<string, ConversationTool>,
|
|
177
|
+
callMaxTokens: null,
|
|
180
178
|
}
|
|
181
179
|
}
|
|
182
180
|
|
|
183
181
|
/** Returns the registered tools available for the model to call. */
|
|
184
|
-
get tools() : Record<string,
|
|
185
|
-
return this.
|
|
182
|
+
get tools() : Record<string, ConversationTool> {
|
|
183
|
+
return (this.state.get('tools') || {}) as Record<string, ConversationTool>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get availableTools() {
|
|
187
|
+
return Object.keys(this.tools)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add or replace a single tool by name.
|
|
192
|
+
* Uses the same format as tools passed at construction time.
|
|
193
|
+
*/
|
|
194
|
+
addTool(name: string, tool: ConversationTool): this {
|
|
195
|
+
this.state.set('tools', { ...this.tools, [name]: tool })
|
|
196
|
+
return this
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Remove a tool by name.
|
|
201
|
+
*/
|
|
202
|
+
removeTool(name: string): this {
|
|
203
|
+
const current = { ...this.tools }
|
|
204
|
+
delete current[name]
|
|
205
|
+
this.state.set('tools', current)
|
|
206
|
+
return this
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Merge new tools into the conversation, replacing any with the same name.
|
|
211
|
+
* Accepts the same Record<string, ConversationTool> format used at construction time.
|
|
212
|
+
*/
|
|
213
|
+
updateTools(tools: Record<string, ConversationTool>): this {
|
|
214
|
+
this.state.set('tools', { ...this.tools, ...tools })
|
|
215
|
+
return this
|
|
186
216
|
}
|
|
187
217
|
|
|
188
218
|
/** Returns configured remote MCP servers keyed by server label. */
|
|
@@ -388,7 +418,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
388
418
|
* ])
|
|
389
419
|
*/
|
|
390
420
|
async ask(content: string | ContentPart[], options?: AskOptions): Promise<string> {
|
|
391
|
-
this.
|
|
421
|
+
this.state.set('callMaxTokens', options?.maxTokens ?? null)
|
|
392
422
|
|
|
393
423
|
// Auto-compact before adding the new message
|
|
394
424
|
if (this.options.autoCompact) {
|
|
@@ -429,7 +459,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
429
459
|
|
|
430
460
|
return await this.runChatCompletionLoop({ turn: 1, accumulated: '' })
|
|
431
461
|
} finally {
|
|
432
|
-
this.
|
|
462
|
+
this.state.set('callMaxTokens', null)
|
|
433
463
|
}
|
|
434
464
|
}
|
|
435
465
|
|
|
@@ -584,6 +614,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
584
614
|
input: OpenAI.Responses.ResponseInput
|
|
585
615
|
previousResponseId?: string
|
|
586
616
|
}): Promise<string> {
|
|
617
|
+
|
|
587
618
|
const { turn } = context
|
|
588
619
|
let accumulated = context.accumulated
|
|
589
620
|
let turnContent = ''
|
|
@@ -660,7 +691,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
660
691
|
const functionOutputs: OpenAI.Responses.ResponseInputItem.FunctionCallOutput[] = []
|
|
661
692
|
for (const call of functionCalls) {
|
|
662
693
|
const toolName = call.name
|
|
663
|
-
const tool = this.
|
|
694
|
+
const tool = this.tools[toolName]
|
|
664
695
|
const callCount = (this.state.get('toolCalls') || 0) + 1
|
|
665
696
|
this.state.set('toolCalls', callCount)
|
|
666
697
|
|
|
@@ -741,10 +772,11 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
741
772
|
* @returns {Promise<string>} The final assistant text response (accumulated across all turns)
|
|
742
773
|
*/
|
|
743
774
|
private async runChatCompletionLoop(context: { turn: number; accumulated: string } = { turn: 1, accumulated: '' }): Promise<string> {
|
|
775
|
+
|
|
744
776
|
const { turn } = context
|
|
745
777
|
let accumulated = context.accumulated
|
|
746
778
|
|
|
747
|
-
const hasTools = Object.keys(this.
|
|
779
|
+
const hasTools = Object.keys(this.tools).length > 0
|
|
748
780
|
const toolsParam = hasTools ? this.openaiTools : undefined
|
|
749
781
|
|
|
750
782
|
this.state.set('streaming', true)
|
|
@@ -819,7 +851,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
|
|
|
819
851
|
|
|
820
852
|
for (const tc of toolCalls) {
|
|
821
853
|
const toolName = tc.function.name
|
|
822
|
-
const tool = this.
|
|
854
|
+
const tool = this.tools[toolName]
|
|
823
855
|
const callCount = (this.state.get('toolCalls') || 0) + 1
|
|
824
856
|
this.state.set('toolCalls', callCount)
|
|
825
857
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
3
|
+
import { type AvailableFeatures, Feature } from '@soederpop/luca/feature'
|
|
4
|
+
import type { ContentDb } from '@/node.js'
|
|
5
|
+
import type Assistant from './assistant.js'
|
|
6
|
+
|
|
7
|
+
declare module '@soederpop/luca/feature' {
|
|
8
|
+
interface AvailableFeatures {
|
|
9
|
+
docsReader: typeof DocsReader
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DocsReaderStateSchema = FeatureStateSchema.extend({
|
|
14
|
+
started: z.boolean().describe('Whether the docs reader has been started'),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const DocsReaderOptionsSchema = FeatureOptionsSchema.extend({
|
|
18
|
+
contentDb: z.union([
|
|
19
|
+
z.string(),
|
|
20
|
+
z.any()
|
|
21
|
+
]).describe('Either the contentDb instance or the path to the contentDb you want to load'),
|
|
22
|
+
model: z.string().describe('The model to use for the conversation').default("gpt-5.4"),
|
|
23
|
+
local: z.boolean().default(false).describe('Whether to use a local model for the conversation')
|
|
24
|
+
}).loose()
|
|
25
|
+
|
|
26
|
+
export const DocsReaderEventsSchema = FeatureEventsSchema.extend({
|
|
27
|
+
loaded: z.tuple([]).describe('Fired after the docs reader has been started'),
|
|
28
|
+
}).describe('DocsReader events')
|
|
29
|
+
|
|
30
|
+
export type DocsReaderState = z.infer<typeof DocsReaderStateSchema>
|
|
31
|
+
export type DocsReaderOptions = z.infer<typeof DocsReaderOptionsSchema>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The DocsReader feature is an AI Assisted wrapper around a ContentDB feature.
|
|
35
|
+
*
|
|
36
|
+
* You can ask it questions about the content, and it will use the ContentDB to find the answers
|
|
37
|
+
* from the documents.
|
|
38
|
+
*/
|
|
39
|
+
export class DocsReader extends Feature<DocsReaderState, DocsReaderOptions> {
|
|
40
|
+
static override stateSchema = DocsReaderStateSchema
|
|
41
|
+
static override optionsSchema = DocsReaderOptionsSchema
|
|
42
|
+
static override eventsSchema = DocsReaderEventsSchema
|
|
43
|
+
static override shortcut = 'features.docsReader' as const
|
|
44
|
+
|
|
45
|
+
static { Feature.register(this, 'docsReader') }
|
|
46
|
+
|
|
47
|
+
/** @returns Default state with started=false. */
|
|
48
|
+
override get initialState(): DocsReaderState {
|
|
49
|
+
return {
|
|
50
|
+
...super.initialState,
|
|
51
|
+
started: false,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether the docs reader has been started. */
|
|
56
|
+
get isStarted(): boolean {
|
|
57
|
+
return !!this.state.get('started')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
calculateCacheKeyForQuestion(question: string) {
|
|
61
|
+
return this.container.utils.hashObject({
|
|
62
|
+
question,
|
|
63
|
+
sha: this.container.git?.sha,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get answerCache() {
|
|
68
|
+
return this.container.feature('diskCache')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get contentDb() : ContentDb {
|
|
72
|
+
return typeof this.options.contentDb === 'object' ?
|
|
73
|
+
this.options.contentDb as ContentDb :
|
|
74
|
+
this.container.feature('contentDb', { rootPath: this.options.contentDb })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async ask(question: string) {
|
|
78
|
+
if (!this.isStarted) {
|
|
79
|
+
await this.start()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!this.assistant) {
|
|
83
|
+
throw new Error('DocsReader not started')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this.assistant.ask(question)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async askCached(question: string) {
|
|
90
|
+
if (!this.isStarted) {
|
|
91
|
+
await this.start()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!this.assistant) {
|
|
95
|
+
throw new Error('DocsReader not started')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const cacheKey = this.calculateCacheKeyForQuestion(question)
|
|
99
|
+
const cached = await this.answerCache.get(cacheKey)
|
|
100
|
+
if (cached) {
|
|
101
|
+
return cached
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const answer = await this.assistant.ask(question)
|
|
105
|
+
await this.answerCache.set(cacheKey, answer)
|
|
106
|
+
return answer
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assistant?: Assistant
|
|
110
|
+
|
|
111
|
+
/** Start the docs reader by loading the contentDb and wiring its tools into an assistant. */
|
|
112
|
+
async start(): Promise<DocsReader> {
|
|
113
|
+
if (this.isStarted) return this
|
|
114
|
+
|
|
115
|
+
const contentDb = this.contentDb
|
|
116
|
+
if (!contentDb.isLoaded) await contentDb.load()
|
|
117
|
+
|
|
118
|
+
this.assistant = this.container.feature('assistant', {
|
|
119
|
+
systemPrompt: CONTENT_DB_SYSTEM_PROMPT,
|
|
120
|
+
model: this.options.model,
|
|
121
|
+
local: this.options.local,
|
|
122
|
+
}).use(contentDb)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
this.state.set('started', true)
|
|
126
|
+
this.emit('started')
|
|
127
|
+
|
|
128
|
+
return this
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default DocsReader
|
|
133
|
+
|
|
134
|
+
export const CONTENT_DB_SYSTEM_PROMPT = `You answer questions using a collection of structured documents. Follow this workflow:
|
|
135
|
+
|
|
136
|
+
1. Start with getCollectionOverview to understand the collection structure, available models, and document counts
|
|
137
|
+
2. Use listDocuments or queryDocuments to find relevant documents by model, glob pattern, or metadata filters
|
|
138
|
+
3. Use searchContent for text/regex search or semanticSearch for natural language queries
|
|
139
|
+
4. Use readDocument to read specific documents — use include/exclude to focus on relevant sections and avoid loading unnecessary content
|
|
140
|
+
5. Synthesize your answer from the documents you've read
|
|
141
|
+
|
|
142
|
+
Be precise: read only what you need, use section filtering to stay focused, and cite document IDs in your answers.`
|