@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 +1 -1
- package/src/agi/features/assistant.ts +58 -2
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/command.ts +20 -1
- package/src/commands/serve.ts +27 -0
- package/src/endpoint.ts +6 -0
- package/src/helper.ts +42 -5
- package/src/introspection/generated.agi.ts +844 -776
- package/src/introspection/generated.node.ts +47 -1
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/features/helpers.ts +5 -2
- package/src/scaffolds/generated.ts +1 -1
- package/src/servers/express.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soederpop/luca",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
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-
|
|
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
|
package/src/cli/build-info.ts
CHANGED
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
|
-
|
|
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') {
|
package/src/commands/serve.ts
CHANGED
|
@@ -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 ? `
|
|
713
|
-
return `${comment}
|
|
712
|
+
const comment = info.description ? ` /** ${info.description} */\n` : ''
|
|
713
|
+
return `${comment} ${name}: ${normalizeTypeString(info.type || 'any')};`
|
|
714
714
|
})
|
|
715
715
|
.join('\n')
|
|
716
|
-
|
|
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
|
-
|
|
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
|
}
|