@soederpop/luca 0.0.30 → 0.0.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soederpop/luca",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
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>",
@@ -29,6 +29,7 @@ export const AssistantEventsSchema = FeatureEventsSchema.extend({
29
29
  toolResult: z.tuple([z.string().describe('Tool name'), z.any().describe('Result value')]).describe('Emitted when a tool returns a result'),
30
30
  toolError: z.tuple([z.string().describe('Tool name'), z.any().describe('Error')]).describe('Emitted when a tool call fails'),
31
31
  hookFired: z.tuple([z.string().describe('Hook/event name')]).describe('Emitted when a hook function is called'),
32
+ reloaded: z.tuple([]).describe('Emitted after tools, hooks, and system prompt are reloaded from disk'),
32
33
  systemPromptExtensionsChanged: z.tuple([]).describe('Emitted when system prompt extensions are added or removed'),
33
34
  })
34
35
 
@@ -461,6 +462,8 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
461
462
  */
462
463
  addTool(name: string, handler: (...args: any[]) => any, schema?: z.ZodType): this {
463
464
  if (!name) throw new Error('addTool handler must be a named function')
465
+ if (!this._runtimeToolNames) this._runtimeToolNames = new Set()
466
+ this._runtimeToolNames.add(name)
464
467
 
465
468
  const current = { ...this.tools }
466
469
 
@@ -504,10 +507,12 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
504
507
 
505
508
  if (typeof nameOrHandler === 'string') {
506
509
  delete current[nameOrHandler]
510
+ this._runtimeToolNames?.delete(nameOrHandler)
507
511
  } else {
508
512
  for (const [name, tool] of Object.entries(current)) {
509
513
  if (tool.handler === nameOrHandler) {
510
514
  delete current[name]
515
+ this._runtimeToolNames?.delete(name)
511
516
  break
512
517
  }
513
518
  }
@@ -936,17 +941,68 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
936
941
  */
937
942
  /** Hook names that are called directly during lifecycle, not bound as event listeners. */
938
943
  private static lifecycleHooks = new Set(['formatSystemPrompt'])
944
+ /** Stored references to bound hook listeners so they can be unbound on reload. Lazily initialized because afterInitialize runs before field initializers. */
945
+ private _boundHookListeners!: Array<{ event: string; listener: (...args: any[]) => void }>
946
+ /** Tool names added at runtime via addTool()/use(), so reload() can preserve them. */
947
+ private _runtimeToolNames!: Set<string>
939
948
 
940
949
  private bindHooksToEvents() {
950
+ if (!this._boundHookListeners) this._boundHookListeners = []
941
951
  const assistant = this
942
952
  const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
943
953
  for (const [eventName, hookFn] of Object.entries(hooks)) {
944
954
  if (Assistant.lifecycleHooks.has(eventName)) continue
945
- this.on(eventName as any, (...args: any[]) => {
955
+ const listener = (...args: any[]) => {
946
956
  this.emit('hookFired', eventName)
947
957
  hookFn(assistant, ...args)
948
- })
958
+ }
959
+ this._boundHookListeners.push({ event: eventName, listener })
960
+ this.on(eventName as any, listener)
961
+ }
962
+ }
963
+
964
+ private unbindHooksFromEvents() {
965
+ for (const { event, listener } of this._boundHookListeners) {
966
+ this.off(event as any, listener)
949
967
  }
968
+ this._boundHookListeners = []
969
+ }
970
+
971
+ /**
972
+ * Reload tools, hooks, and system prompt from disk. Useful during development
973
+ * or when tool/hook files have been modified and you want the assistant to
974
+ * pick up changes without restarting.
975
+ *
976
+ * @returns this, for chaining
977
+ */
978
+ reload(): this {
979
+ // Unbind existing hook listeners
980
+ this.unbindHooksFromEvents()
981
+
982
+ // Snapshot runtime-added tools before reloading from disk
983
+ const runtimeTools: Record<string, ConversationTool> = {}
984
+ if (this._runtimeToolNames?.size) {
985
+ const current = this.tools
986
+ for (const name of this._runtimeToolNames) {
987
+ if (current[name]) runtimeTools[name] = current[name]
988
+ }
989
+ }
990
+
991
+ // Reload system prompt from disk
992
+ this.state.set('systemPrompt', this.loadSystemPrompt())
993
+
994
+ // Reload tools from disk (merges with option tools), then restore runtime tools
995
+ const diskTools = this.loadTools()
996
+ this.state.set('tools', { ...diskTools, ...runtimeTools })
997
+ this.emit('toolsChanged')
998
+
999
+ // Reload hooks from disk and rebind
1000
+ this.state.set('hooks', this.loadHooks())
1001
+ this.bindHooksToEvents()
1002
+
1003
+ this.emit('reloaded')
1004
+
1005
+ return this
950
1006
  }
951
1007
 
952
1008
  /**
@@ -1,5 +1,5 @@
1
1
  // Auto-generated bootstrap content
2
- // Generated at: 2026-03-24T06:38:37.146Z
2
+ // Generated at: 2026-03-24T09:08:07.564Z
3
3
  // Source: docs/bootstrap/*.md, docs/bootstrap/templates/*, docs/examples/*.md, docs/tutorials/*.md
4
4
  //
5
5
  // Do not edit manually. Run: luca build-bootstrap
@@ -1,4 +1,4 @@
1
1
  // Generated at compile time — do not edit manually
2
- export const BUILD_SHA = 'd0653ac'
2
+ export const BUILD_SHA = 'ff23ed7'
3
3
  export const BUILD_BRANCH = 'main'
4
- export const BUILD_DATE = '2026-03-24T06:38:37Z'
4
+ export const BUILD_DATE = '2026-03-24T09:08:07Z'
package/src/command.ts CHANGED
@@ -109,7 +109,26 @@ export class Command<
109
109
  const named = this._normalizeInput(raw, dispatchSource)
110
110
 
111
111
  // Validate against argsSchema
112
- const parsed = Cls.argsSchema.parse(named)
112
+ let parsed: any
113
+ try {
114
+ parsed = Cls.argsSchema.parse(named)
115
+ } catch (err: any) {
116
+ if (err?.name === 'ZodError' && dispatchSource === 'cli') {
117
+ const ui = (this.container as any).feature('ui')
118
+ const cmdName = Cls.shortcut?.replace('commands.', '') || 'unknown'
119
+ const issues = err.issues || []
120
+
121
+ ui.print.red(`\n Error: Invalid options for "${cmdName}"\n`)
122
+ for (const issue of issues) {
123
+ const path = issue.path?.length ? issue.path.join('.') : 'input'
124
+ ui.print(` ${ui.colors.yellow('→')} ${ui.colors.bold(path)}: ${issue.message}`)
125
+ }
126
+ ui.print('')
127
+ ui.print.dim(` Run ${ui.colors.cyan(`luca ${cmdName} --help`)} for usage info.\n`)
128
+ process.exit(1)
129
+ }
130
+ throw err
131
+ }
113
132
 
114
133
  // For headless dispatch, capture stdout/stderr
115
134
  if (dispatchSource !== 'cli') {
@@ -19,6 +19,7 @@ export const argsSchema = CommandOptionsSchema.extend({
19
19
  force: z.boolean().default(false).describe('Kill any process currently using the target port'),
20
20
  anyPort: z.boolean().default(false).describe('Find an available port starting above 3000'),
21
21
  open: z.boolean().default(true).describe('Open the server URL in Google Chrome'),
22
+ watch: z.boolean().default(false).describe('Watch endpoint files and hot-reload handlers on change'),
22
23
  })
23
24
 
24
25
  export default async function serve(options: z.infer<typeof argsSchema>, context: ContainerContext) {
@@ -157,6 +158,32 @@ export default async function serve(options: z.infer<typeof argsSchema>, context
157
158
  }
158
159
  }
159
160
 
161
+ if (options.watch && endpointsDir) {
162
+ const fm = container.feature('fileManager')
163
+ await fm.watch({ paths: [endpointsDir] })
164
+
165
+ fm.on('file:change', async ({ type, path: filePath }: { type: string; path: string }) => {
166
+ if (!filePath.endsWith('.ts')) return
167
+
168
+ if (type === 'change') {
169
+ try {
170
+ const ep = await expressServer.reloadEndpoint(filePath)
171
+ if (ep) {
172
+ console.log(`[watch] Reloaded ${ep.methods.map((m: string) => m.toUpperCase()).join(',')} ${ep.path}`)
173
+ }
174
+ } catch (err: any) {
175
+ console.error(`[watch] Failed to reload ${filePath}: ${err.message}`)
176
+ }
177
+ } else if (type === 'add') {
178
+ console.log(`[watch] New file detected: ${filePath} (restart to mount)`)
179
+ } else if (type === 'delete') {
180
+ console.log(`[watch] File deleted: ${filePath} (restart to unmount)`)
181
+ }
182
+ })
183
+
184
+ console.log(`\n[watch] Watching ${endpointsDir} for changes`)
185
+ }
186
+
160
187
  console.log()
161
188
  }
162
189
 
package/src/endpoint.ts CHANGED
@@ -197,6 +197,12 @@ export class Endpoint<
197
197
 
198
198
  async reload(): Promise<this> {
199
199
  this._module = null
200
+ if (this.options.filePath) {
201
+ const helpers = this.container.feature('helpers') as any
202
+ const mod = await helpers.loadModuleExports(this.options.filePath, { cacheBust: true })
203
+ const endpointModule: EndpointModule = mod.default || mod
204
+ return this.load(endpointModule)
205
+ }
200
206
  return this.load()
201
207
  }
202
208
 
package/src/helper.ts CHANGED
@@ -704,16 +704,42 @@ function presentIntrospectionAsTypeScript(introspection: HelperIntrospection, se
704
704
  }
705
705
  }
706
706
 
707
- // State
707
+ // State — render as the observable State<T> object, not just currentState
708
708
  if (shouldRender('state') && introspection.state && Object.keys(introspection.state).length > 0) {
709
709
  if (members.length > 0) members.push('')
710
710
  const stateMembers = Object.entries(introspection.state)
711
711
  .map(([name, info]) => {
712
- const comment = info.description ? ` /** ${info.description} */\n` : ''
713
- return `${comment} ${name}: ${normalizeTypeString(info.type || 'any')};`
712
+ const comment = info.description ? ` /** ${info.description} */\n` : ''
713
+ return `${comment} ${name}: ${normalizeTypeString(info.type || 'any')};`
714
714
  })
715
715
  .join('\n')
716
- members.push(` state: {\n${stateMembers}\n };`)
716
+ const stateShape = `{\n${stateMembers}\n }`
717
+ members.push(` state: {`)
718
+ members.push(` /** The current version number, incremented on each change */`)
719
+ members.push(` readonly version: number;`)
720
+ members.push(` /** Get the value of a state key */`)
721
+ members.push(` get<K extends keyof T>(key: K): T[K] | undefined;`)
722
+ members.push(` /** Set a state key to a new value, notifying observers */`)
723
+ members.push(` set<K extends keyof T>(key: K, value: T[K]): this;`)
724
+ members.push(` /** Delete a state key, notifying observers */`)
725
+ members.push(` delete<K extends keyof T>(key: K): this;`)
726
+ members.push(` /** Check if a state key exists */`)
727
+ members.push(` has<K extends keyof T>(key: K): boolean;`)
728
+ members.push(` /** Get all state keys */`)
729
+ members.push(` keys(): string[];`)
730
+ members.push(` /** Get the current state snapshot */`)
731
+ members.push(` readonly current: ${stateShape};`)
732
+ members.push(` /** Get all entries as [key, value] pairs */`)
733
+ members.push(` entries(): [string, any][];`)
734
+ members.push(` /** Get all state values */`)
735
+ members.push(` values(): any[];`)
736
+ members.push(` /** Register an observer callback for state changes. Returns an unsubscribe function. */`)
737
+ members.push(` observe(callback: (changeType: 'add' | 'update' | 'delete', key: string, value?: any) => void): () => void;`)
738
+ members.push(` /** Merge partial state, notifying observers for each changed key */`)
739
+ members.push(` setState(value: Partial<${stateShape}> | ((current: ${stateShape}, state: this) => Partial<${stateShape}>)): void;`)
740
+ members.push(` /** Clear all state, notifying observers */`)
741
+ members.push(` clear(): void;`)
742
+ members.push(` };`)
717
743
  }
718
744
 
719
745
  // Options
@@ -804,5 +830,16 @@ function isGenericObjectType(type: string): boolean {
804
830
  function normalizeTypeString(type: string): string {
805
831
  if (!type) return 'any'
806
832
  // The AST scanner sometimes wraps types in quotes
807
- return type.replace(/^["']|["']$/g, '')
833
+ type = type.replace(/^["']|["']$/g, '')
834
+ // Convert internal ReturnType<typeof this.container.feature<'name'>> to a clean import reference
835
+ // e.g. ReturnType<typeof this.container.feature<'proc'>> → import('luca').Proc
836
+ type = type.replace(
837
+ /ReturnType<typeof this\.container\.(feature|client|server)<'([^']+)'>>/g,
838
+ (_match, _kind, name) => {
839
+ // Convert shortcut name to PascalCase class name
840
+ const className = name.replace(/(^|[-_])(\w)/g, (_: string, _sep: string, ch: string) => ch.toUpperCase())
841
+ return `import('@soederpop/luca').${className}`
842
+ }
843
+ )
844
+ return type
808
845
  }