@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.
Files changed (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +6 -1
  3. package/assistants/codingAssistant/hooks.ts +0 -1
  4. package/assistants/lucaExpert/CORE.md +37 -0
  5. package/assistants/lucaExpert/hooks.ts +9 -0
  6. package/assistants/lucaExpert/tools.ts +177 -0
  7. package/commands/build-bootstrap.ts +41 -1
  8. package/docs/TABLE-OF-CONTENTS.md +0 -1
  9. package/docs/apis/clients/rest.md +5 -5
  10. package/docs/apis/features/agi/assistant.md +1 -1
  11. package/docs/apis/features/agi/conversation-history.md +6 -7
  12. package/docs/apis/features/agi/conversation.md +1 -1
  13. package/docs/apis/features/agi/semantic-search.md +1 -1
  14. package/docs/bootstrap/CLAUDE.md +1 -1
  15. package/docs/bootstrap/SKILL.md +7 -3
  16. package/docs/bootstrap/templates/luca-cli.ts +5 -0
  17. package/docs/mcp/readme.md +1 -1
  18. package/docs/tutorials/00-bootstrap.md +18 -0
  19. package/package.json +2 -2
  20. package/scripts/stamp-build.sh +12 -0
  21. package/scripts/test-docs-reader.ts +10 -0
  22. package/src/agi/container.server.ts +8 -5
  23. package/src/agi/features/assistant.ts +208 -55
  24. package/src/agi/features/assistants-manager.ts +138 -66
  25. package/src/agi/features/conversation.ts +46 -14
  26. package/src/agi/features/docs-reader.ts +142 -0
  27. package/src/agi/features/openapi.ts +1 -1
  28. package/src/agi/features/skills-library.ts +257 -313
  29. package/src/bootstrap/generated.ts +8163 -6
  30. package/src/cli/build-info.ts +4 -0
  31. package/src/cli/cli.ts +2 -1
  32. package/src/commands/bootstrap.ts +16 -1
  33. package/src/commands/eval.ts +6 -1
  34. package/src/commands/sandbox-mcp.ts +17 -7
  35. package/src/helper.ts +56 -2
  36. package/src/introspection/generated.agi.ts +2409 -1608
  37. package/src/introspection/generated.node.ts +902 -594
  38. package/src/introspection/generated.web.ts +1 -1
  39. package/src/node/container.ts +1 -1
  40. package/src/node/features/content-db.ts +251 -13
  41. package/src/node/features/git.ts +90 -0
  42. package/src/node/features/grep.ts +1 -1
  43. package/src/node/features/proc.ts +1 -0
  44. package/src/node/features/tts.ts +1 -1
  45. package/src/node/features/vm.ts +48 -0
  46. package/src/scaffolds/generated.ts +2 -2
  47. package/assistants/architect/CORE.md +0 -3
  48. package/assistants/architect/hooks.ts +0 -3
  49. package/assistants/architect/tools.ts +0 -10
  50. package/docs/apis/features/agi/skills-library.md +0 -234
  51. package/docs/reports/assistant-bugs.md +0 -38
  52. package/docs/reports/attach-pattern-usage.md +0 -18
  53. package/docs/reports/code-audit-results.md +0 -391
  54. package/docs/reports/console-hmr-design.md +0 -170
  55. package/docs/reports/helper-semantic-search.md +0 -72
  56. package/docs/reports/introspection-audit-tasks.md +0 -378
  57. package/docs/reports/luca-mcp-improvements.md +0 -128
  58. 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 finding all CORE.md files
56
- * in the project using the fileManager. Each directory containing a CORE.md
57
- * is treated as an assistant definition that can also contain tools.ts,
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: 'assistants/chief-of-staff', folder: '...', ... }]
70
- * const assistant = manager.create('assistants/chief-of-staff')
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
- private _entries: Map<string, AssistantEntry> = new Map()
97
- private _instances: Map<string, Assistant> = new Map()
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
- override async afterInitialize() {
100
- if (this.options.autoDiscover) {
101
- await this.discover()
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 finding all CORE.md files in the project
107
- * using the fileManager. Each directory containing a CORE.md is
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
- const fileManager = this.container.feature('fileManager') as any
115
-
116
- await fileManager.start()
117
-
118
- this._entries.clear()
119
-
120
- const coreFiles = fileManager.matchFiles('**/CORE.md')
121
-
122
- for (const file of coreFiles) {
123
- const dir = file.dirname
124
- const name = file.relativeDirname
125
-
126
- this._entries.set(name, {
127
- name,
128
- folder: dir,
129
- hasCorePrompt: true,
130
- hasTools: fs.exists(paths.resolve(dir, 'tools.ts')),
131
- hasHooks: fs.exists(paths.resolve(dir, 'hooks.ts')),
132
- hasVoice: fs.exists(paths.resolve(dir, 'voice.yaml')),
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: this._entries.size,
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
- return Array.from(this._entries.keys())
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 Array.from(this._entries.values())
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. 'assistants/chief-of-staff')
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
- const found = this._entries.get(name)
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
- return undefined
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('assistants/chief-of-staff', { model: 'gpt-4.1' })
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._instances.set(name, instance)
213
- this.state.set('activeCount', this._instances.size)
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._instances.get(name)
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._callMaxTokens ?? this.options.maxTokens ?? undefined
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, any> {
185
- return this.options.tools || {}
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._callMaxTokens = options?.maxTokens
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._callMaxTokens = undefined
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._tools[toolName]
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._tools || {}).length > 0
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._tools[toolName]
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.`
@@ -161,7 +161,7 @@ export class OpenAPI extends Feature<OpenAPIState, OpenAPIOptions> {
161
161
  endpointCount: this._endpoints.size,
162
162
  })
163
163
 
164
- this.emit('loaded', this._spec)
164
+ this.emit('started', this._spec)
165
165
  return this
166
166
  }
167
167