@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
@@ -38,15 +38,26 @@ export const AssistantStateSchema = FeatureStateSchema.extend({
38
38
  docsFolder: z.string().describe('The resolved docs folder'),
39
39
  conversationId: z.string().optional().describe('The active conversation persistence ID'),
40
40
  threadId: z.string().optional().describe('The active thread ID'),
41
+ systemPrompt: z.string().describe('The loaded system prompt text'),
42
+ meta: z.record(z.string(), z.any()).describe('Parsed YAML frontmatter from CORE.md'),
43
+ tools: z.record(z.string(), z.any()).describe('Registered tool implementations'),
44
+ hooks: z.record(z.string(), z.any()).describe('Loaded event hook functions'),
45
+ resumeThreadId: z.string().optional().describe('Thread ID override for resume'),
46
+ pendingPlugins: z.array(z.any()).describe('Pending async plugin promises'),
47
+ conversation: z.any().nullable().describe('The active Conversation feature instance'),
48
+ subagents: z.record(z.string(), z.any()).describe('Cached subagent instances'),
41
49
  })
42
50
 
43
51
  export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
44
- /** The folder containing the assistant definition (CORE.md, tools.ts, hooks.ts) */
45
- folder: z.string().describe('The folder containing the assistant definition'),
52
+ /** The folder containing the assistant definition (CORE.md, tools.ts, hooks.ts). Optional for runtime-created assistants. */
53
+ folder: z.string().default('.').describe('The folder containing the assistant definition. Defaults to cwd for runtime-created assistants.'),
46
54
 
47
55
  /** If the docs folder is different from folder/docs */
48
56
  docsFolder: z.string().optional().describe('The folder containing the assistant documentation'),
49
57
 
58
+ /** Provide a complete system prompt directly, bypassing CORE.md. Useful for runtime-created assistants. */
59
+ systemPrompt: z.string().optional().describe('Provide a complete system prompt directly, bypassing CORE.md'),
60
+
50
61
  /** Text to prepend to the system prompt from CORE.md */
51
62
  prependPrompt: z.string().optional().describe('Text to prepend to the system prompt'),
52
63
 
@@ -108,6 +119,14 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
108
119
  conversationCount: 0,
109
120
  lastResponse: '',
110
121
  folder: this.resolvedFolder,
122
+ systemPrompt: '',
123
+ meta: {},
124
+ tools: {},
125
+ hooks: {},
126
+ resumeThreadId: undefined,
127
+ pendingPlugins: [],
128
+ conversation: null,
129
+ subagents: {},
111
130
  }
112
131
  }
113
132
 
@@ -177,15 +196,6 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
177
196
  return this.container.feature('contentDb', { rootPath: this.resolvedDocsFolder })
178
197
  }
179
198
 
180
- private _conversation?: Conversation
181
- private _resumeThreadId?: string
182
-
183
- // Using `declare` to prevent class field initializers from overwriting
184
- // values set during afterInitialize() (called from the base constructor).
185
- declare private _tools: Record<string, ConversationTool>
186
- declare private _hooks: Record<string, (...args: any[]) => any>
187
- declare private _systemPrompt: string
188
- declare private _pendingPlugins: Promise<void>[]
189
199
 
190
200
  /**
191
201
  * Called immediately after the assistant is constructed. Synchronously loads
@@ -193,14 +203,14 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
193
203
  * so every emitted event automatically invokes its corresponding hook.
194
204
  */
195
205
  override afterInitialize() {
196
- this._pendingPlugins = []
206
+ this.state.set('pendingPlugins', [])
197
207
 
198
208
  // Load system prompt synchronously
199
- this._systemPrompt = this.loadSystemPrompt()
209
+ this.state.set('systemPrompt', this.loadSystemPrompt())
200
210
 
201
211
  // Load tools and hooks synchronously via vm.performSync
202
- this._tools = this.loadTools()
203
- this._hooks = this.loadHooks()
212
+ this.state.set('tools', this.loadTools())
213
+ this.state.set('hooks', this.loadHooks())
204
214
 
205
215
  // Bind hooks to events BEFORE emitting created so the created hook fires
206
216
  this.bindHooksToEvents()
@@ -209,18 +219,25 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
209
219
  }
210
220
 
211
221
  get conversation(): Conversation {
212
- if (!this._conversation) {
213
- this._conversation = this.container.feature('conversation', {
214
- model: this.options.model || 'gpt-5.2',
222
+ let conv = this.state.get('conversation') as Conversation | null
223
+ if (!conv) {
224
+ conv = this.container.feature('conversation', {
225
+ model: this.options.model || 'gpt-5.4',
215
226
  local: !!this.options.local,
216
- tools: this._tools || this.loadTools(),
227
+ tools: this.tools,
228
+ api: 'chat',
217
229
  ...(this.options.maxTokens ? { maxTokens: this.options.maxTokens } : {}),
218
230
  history: [
219
- { role: 'system', content: this._systemPrompt || this.loadSystemPrompt() },
231
+ { role: 'system', content: this.systemPrompt || this.loadSystemPrompt() },
220
232
  ],
221
233
  })
234
+ this.state.set('conversation', conv)
222
235
  }
223
- return this._conversation
236
+ return conv
237
+ }
238
+
239
+ get availableTools() {
240
+ return Object.keys(this.tools)
224
241
  }
225
242
 
226
243
  get messages() {
@@ -234,32 +251,51 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
234
251
 
235
252
  /** The current system prompt text. */
236
253
  get systemPrompt(): string {
237
- return this._systemPrompt
254
+ return this.state.get('systemPrompt') || ''
238
255
  }
239
256
 
240
257
  /** The tools registered with this assistant. */
241
258
  get tools(): Record<string, ConversationTool> {
242
- return this._tools
259
+ return (this.state.get('tools') || {}) as Record<string, ConversationTool>
243
260
  }
244
261
 
245
262
  /**
246
- * Apply a setup function to this assistant. The function receives the
247
- * assistant instance and can configure tools, hooks, event listeners, etc.
263
+ * Apply a setup function or a Helper instance to this assistant.
248
264
  *
249
- * @param fn - Setup function that receives this assistant
265
+ * When passed a function, it receives the assistant and can configure
266
+ * tools, hooks, event listeners, etc.
267
+ *
268
+ * When passed a Helper instance that exposes tools via toTools(),
269
+ * those tools are automatically added to this assistant.
270
+ *
271
+ * @param fnOrHelper - Setup function or Helper instance
250
272
  * @returns this, for chaining
251
273
  *
252
274
  * @example
253
275
  * ```typescript
254
276
  * assistant
255
277
  * .use(setupLogging)
256
- * .use(addAnalyticsTools)
278
+ * .use(container.feature('git'))
257
279
  * ```
258
280
  */
259
- use(fn: (assistant: this) => void | Promise<void>): this {
260
- const result = fn(this)
261
- if (result && typeof (result as any).then === 'function') {
262
- this._pendingPlugins.push(result as Promise<void>)
281
+ use(fnOrHelper: ((assistant: this) => void | Promise<void>) | { toTools: () => { schemas: Record<string, z.ZodType>, handlers: Record<string, Function> } }): this {
282
+ if (typeof fnOrHelper === 'function') {
283
+ const result = fnOrHelper(this)
284
+ if (result && typeof (result as any).then === 'function') {
285
+ const pending = this.state.get('pendingPlugins') as Promise<void>[]
286
+ this.state.set('pendingPlugins', [...pending, result as Promise<void>])
287
+ }
288
+ } else if (fnOrHelper && typeof fnOrHelper.toTools === 'function') {
289
+ const { schemas, handlers } = fnOrHelper.toTools()
290
+ for (const name of Object.keys(schemas)) {
291
+ if(typeof handlers[name] === 'function') {
292
+ this.addTool(
293
+ name,
294
+ handlers[name] as any,
295
+ schemas[name],
296
+ )
297
+ }
298
+ }
263
299
  }
264
300
  return this
265
301
  }
@@ -279,29 +315,37 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
279
315
  * }, z.object({ city: z.string() }).describe('Get weather for a city'))
280
316
  * ```
281
317
  */
282
- addTool(handler: (...args: any[]) => any, schema?: z.ZodType): this {
283
- const name = handler.name
318
+ addTool(name: string, handler: (...args: any[]) => any, schema?: z.ZodType): this {
284
319
  if (!name) throw new Error('addTool handler must be a named function')
285
320
 
321
+ const current = { ...this.tools }
322
+
286
323
  if (schema) {
287
324
  const jsonSchema = (schema as any).toJSONSchema() as Record<string, any>
288
- this._tools[name] = {
325
+ // OpenAI requires `required` to list ALL property keys — optional params
326
+ // must still appear in `required` but use a default value in the schema.
327
+ const properties = jsonSchema.properties || {}
328
+ const required = Object.keys(properties)
329
+ current[name] = {
289
330
  handler: handler as ConversationTool['handler'],
290
331
  description: jsonSchema.description || name,
291
332
  parameters: {
292
333
  type: jsonSchema.type || 'object',
293
- properties: jsonSchema.properties || {},
294
- ...(jsonSchema.required ? { required: jsonSchema.required } : {}),
334
+ properties,
335
+ required,
295
336
  },
296
337
  }
297
338
  } else {
298
- this._tools[name] = {
339
+ current[name] = {
299
340
  handler: handler as ConversationTool['handler'],
300
341
  description: name,
301
342
  parameters: { type: 'object', properties: {} },
302
343
  }
303
344
  }
304
345
 
346
+ this.state.set('tools', current)
347
+ this.emit('toolsChanged')
348
+
305
349
  return this
306
350
  }
307
351
 
@@ -312,17 +356,22 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
312
356
  * @returns this, for chaining
313
357
  */
314
358
  removeTool(nameOrHandler: string | ((...args: any[]) => any)): this {
359
+ const current = { ...this.tools }
360
+
315
361
  if (typeof nameOrHandler === 'string') {
316
- delete this._tools[nameOrHandler]
362
+ delete current[nameOrHandler]
317
363
  } else {
318
- for (const [name, tool] of Object.entries(this._tools)) {
364
+ for (const [name, tool] of Object.entries(current)) {
319
365
  if (tool.handler === nameOrHandler) {
320
- delete this._tools[name]
366
+ delete current[name]
321
367
  break
322
368
  }
323
369
  }
324
370
  }
325
371
 
372
+ this.state.set('tools', current)
373
+ this.emit('toolsChanged')
374
+
326
375
  return this
327
376
  }
328
377
 
@@ -384,17 +433,38 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
384
433
  return this
385
434
  }
386
435
 
436
+ /**
437
+ * Parsed YAML frontmatter from CORE.md, or empty object if none.
438
+ */
439
+ get meta(): Record<string, any> {
440
+ return (this.state.get('meta') || {}) as Record<string, any>
441
+ }
442
+
387
443
  /**
388
444
  * Load the system prompt from CORE.md, applying any prepend/append options.
445
+ * YAML frontmatter (between --- fences) is stripped from the prompt and
446
+ * stored in `_meta`.
389
447
  *
390
448
  * @returns {string} The assembled system prompt
391
449
  */
392
450
  loadSystemPrompt(): string {
393
451
  const { fs } = this.container
394
452
  let prompt = ''
395
-
396
- if (fs.exists(this.corePromptPath)) {
397
- prompt = fs.readFile(this.corePromptPath)
453
+ this.state.set('meta', {})
454
+
455
+ if (this.options.systemPrompt) {
456
+ prompt = this.options.systemPrompt
457
+ } else if (fs.exists(this.corePromptPath)) {
458
+ const raw = fs.readFile(this.corePromptPath).toString()
459
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
460
+
461
+ if (fmMatch) {
462
+ const yaml = this.container.feature('yaml')
463
+ this.state.set('meta', yaml.parse(fmMatch[1]!) ?? {})
464
+ prompt = raw.slice(fmMatch[0].length)
465
+ } else {
466
+ prompt = raw
467
+ }
398
468
  }
399
469
 
400
470
  if (this.options.prependPrompt) {
@@ -426,6 +496,19 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
426
496
  */
427
497
  loadTools(): Record<string, ConversationTool> {
428
498
  const tools: Record<string, ConversationTool> = {}
499
+
500
+ // Skip loading if no tools file exists (runtime-created assistants)
501
+ if (!this.container.fs.exists(this.toolsModulePath)) {
502
+ return this.mergeOptionTools(tools)
503
+ }
504
+
505
+ // Ensure virtual modules (zod, @soederpop/luca, etc.) are seeded so tools
506
+ // files outside the project tree can resolve them through the VM
507
+ if (this.container.features.has('helpers')) {
508
+ const helpers = this.container.feature('helpers') as any
509
+ helpers.seedVirtualModules()
510
+ }
511
+
429
512
  const vm = this.container.feature('vm')
430
513
 
431
514
  let moduleExports: Record<string, any>
@@ -441,7 +524,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
441
524
  console.error(`Failed to load tools from ${this.toolsModulePath}`)
442
525
  console.error(`There may be a syntax error in this file. Please check it.`)
443
526
  console.error(err.message || err)
444
- return tools
527
+ return this.mergeOptionTools(tools)
445
528
  }
446
529
 
447
530
  if (Object.keys(moduleExports).length) {
@@ -472,7 +555,14 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
472
555
  }
473
556
  }
474
557
 
475
- // Merge in option-provided tools and schemas
558
+ return this.mergeOptionTools(tools)
559
+ }
560
+
561
+ /**
562
+ * Merge tools provided via constructor options into the tool map.
563
+ * This allows runtime-created assistants to define tools entirely via options.
564
+ */
565
+ private mergeOptionTools(tools: Record<string, ConversationTool>): Record<string, ConversationTool> {
476
566
  if (this.options.tools) {
477
567
  const optionSchemas = this.options.schemas || {}
478
568
 
@@ -513,6 +603,12 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
513
603
  */
514
604
  loadHooks(): Record<string, (...args: any[]) => any> {
515
605
  const hooks: Record<string, (...args: any[]) => any> = {}
606
+
607
+ // Skip loading if no hooks file exists (runtime-created assistants)
608
+ if (!this.container.fs.exists(this.hooksModulePath)) {
609
+ return hooks
610
+ }
611
+
516
612
  const vm = this.container.feature('vm')
517
613
 
518
614
  let moduleExports: Record<string, any>
@@ -626,7 +722,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
626
722
  * @returns this, for chaining
627
723
  */
628
724
  resumeThread(threadId: string): this {
629
- this._resumeThreadId = threadId
725
+ this.state.set('resumeThreadId', threadId)
630
726
  return this
631
727
  }
632
728
 
@@ -659,7 +755,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
659
755
  const mode = this.options.historyMode || 'lifecycle'
660
756
  if (mode === 'lifecycle') return
661
757
 
662
- const threadId = this._resumeThreadId || this.buildThreadId(mode)
758
+ const threadId = (this.state.get('resumeThreadId') as string | undefined) || this.buildThreadId(mode)
663
759
  this.state.set('threadId', threadId)
664
760
 
665
761
  const existing = await this.conversationHistory.findByThread(threadId)
@@ -670,7 +766,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
670
766
 
671
767
  // Swap in fresh system prompt if it changed
672
768
  if (messages.length > 0 && (messages[0]!.role === 'system' || messages[0]!.role === 'developer')) {
673
- messages[0] = { role: messages[0]!.role, content: this._systemPrompt }
769
+ messages[0] = { role: messages[0]!.role, content: this.systemPrompt }
674
770
  }
675
771
 
676
772
  this.conversation.state.set('id', existing.id)
@@ -699,7 +795,8 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
699
795
 
700
796
  private bindHooksToEvents() {
701
797
  const assistant = this
702
- for (const [eventName, hookFn] of Object.entries(this._hooks)) {
798
+ const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
799
+ for (const [eventName, hookFn] of Object.entries(hooks)) {
703
800
  if (Assistant.lifecycleHooks.has(eventName)) continue
704
801
  this.on(eventName as any, (...args: any[]) => {
705
802
  this.emit('hookFired', eventName)
@@ -720,17 +817,19 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
720
817
  if (this.isStarted) return this
721
818
 
722
819
  // Wait for any async .use() plugins to finish before starting
723
- if (this._pendingPlugins.length) {
724
- await Promise.all(this._pendingPlugins)
725
- this._pendingPlugins = []
820
+ const pending = this.state.get('pendingPlugins') as Promise<void>[]
821
+ if (pending.length) {
822
+ await Promise.all(pending)
823
+ this.state.set('pendingPlugins', [])
726
824
  }
727
825
 
728
826
  // Allow hooks.ts to export a formatSystemPrompt(assistant, prompt) => string
729
827
  // that transforms the system prompt before the conversation is created.
730
- if (this._hooks.formatSystemPrompt) {
731
- const result = await this._hooks.formatSystemPrompt(this, this._systemPrompt)
828
+ const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
829
+ if (hooks.formatSystemPrompt) {
830
+ const result = await hooks.formatSystemPrompt(this, this.systemPrompt)
732
831
  if (typeof result === 'string') {
733
- this._systemPrompt = result
832
+ this.state.set('systemPrompt', result)
734
833
  }
735
834
  }
736
835
 
@@ -758,6 +857,13 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
758
857
  if (mode === 'daily' || mode === 'persistent') {
759
858
  (this.conversation.options as any).autoCompact = true
760
859
  }
860
+
861
+ this.on('toolsChanged', () => {
862
+ const conv = this.state.get('conversation') as Conversation | null
863
+ if (conv) {
864
+ conv.updateTools(this.tools)
865
+ }
866
+ })
761
867
 
762
868
  this.state.set('started', true)
763
869
  this.emit('started')
@@ -818,6 +924,53 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
818
924
 
819
925
  return this.conversation.save(opts)
820
926
  }
927
+
928
+ // -- Subagent API --
929
+
930
+ /**
931
+ * Names of assistants available as subagents, discovered via the assistantsManager.
932
+ *
933
+ * @returns {string[]} Available assistant names
934
+ */
935
+ get availableSubagents(): string[] {
936
+ try {
937
+ const manager = this.container.feature('assistantsManager')
938
+ return manager.available
939
+ } catch {
940
+ return []
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Get or create a subagent assistant. Uses the assistantsManager to discover
946
+ * and create the assistant, then caches the instance for reuse across tool calls.
947
+ *
948
+ * @param id - The assistant name (e.g. 'codingAssistant')
949
+ * @param options - Additional options to pass to the assistant constructor
950
+ * @returns {Promise<Assistant>} The subagent assistant instance, started and ready
951
+ *
952
+ * @example
953
+ * ```typescript
954
+ * const researcher = await assistant.subagent('codingAssistant')
955
+ * const answer = await researcher.ask('Find all usages of container.feature("fs")')
956
+ * ```
957
+ */
958
+ async subagent(id: string, options: Record<string, any> = {}): Promise<Assistant> {
959
+ const subagents = (this.state.get('subagents') || {}) as Record<string, Assistant>
960
+ if (subagents[id]) return subagents[id]
961
+
962
+ const manager = this.container.feature('assistantsManager')
963
+
964
+ if (!manager.state.get('discovered')) {
965
+ await manager.discover()
966
+ }
967
+
968
+ const instance = manager.create(id, options)
969
+ await instance.start()
970
+
971
+ this.state.set('subagents', { ...subagents, [id]: instance })
972
+ return instance
973
+ }
821
974
  }
822
975
 
823
976
  export default Assistant