@soederpop/luca 0.0.3 → 0.0.4

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 (113) hide show
  1. package/AGENTS.md +98 -0
  2. package/CLAUDE.md +27 -0
  3. package/SPEC.md +304 -0
  4. package/bun.lock +110 -265
  5. package/docs/CLI.md +1 -1
  6. package/docs/apis/features/node/content-db.md +16 -0
  7. package/docs/apis/features/node/fs.md +24 -0
  8. package/docs/apis/features/node/ipc-socket.md +0 -1
  9. package/docs/apis/features/node/package-finder.md +1 -11
  10. package/docs/apis/features/node/proc.md +0 -41
  11. package/docs/apis/features/node/ui.md +0 -2
  12. package/package.json +12 -8
  13. package/src/agi/container.server.ts +16 -3
  14. package/src/agi/features/assistant.ts +3 -7
  15. package/src/agi/features/assistants-manager.ts +3 -7
  16. package/src/agi/features/claude-code.ts +3 -7
  17. package/src/agi/features/conversation-history.ts +3 -7
  18. package/src/agi/features/conversation.ts +4 -8
  19. package/src/agi/features/openai-codex.ts +3 -7
  20. package/src/agi/features/openapi.ts +4 -2
  21. package/src/agi/features/skills-library.ts +4 -8
  22. package/src/cli/cli.ts +22 -0
  23. package/src/client.ts +69 -26
  24. package/src/clients/civitai/index.ts +3 -7
  25. package/src/clients/comfyui/index.ts +5 -9
  26. package/src/clients/elevenlabs/index.ts +39 -19
  27. package/src/clients/openai/index.ts +3 -7
  28. package/src/clients/supabase/index.ts +4 -13
  29. package/src/commands/console.ts +0 -3
  30. package/src/commands/eval.ts +1 -1
  31. package/src/commands/index.ts +1 -0
  32. package/src/commands/introspect.ts +128 -0
  33. package/src/commands/prompt.ts +1 -4
  34. package/src/commands/run.ts +6 -13
  35. package/src/commands/sandbox-mcp.ts +1 -13
  36. package/src/feature.ts +45 -2
  37. package/src/introspection/generated.agi.ts +175 -101
  38. package/src/introspection/generated.node.ts +175 -101
  39. package/src/introspection/generated.web.ts +113 -29
  40. package/src/introspection/index.ts +1 -1
  41. package/src/introspection/scan.ts +3 -1
  42. package/src/node/features/container-link.ts +3 -2
  43. package/src/node/features/content-db.ts +10 -2
  44. package/src/node/features/disk-cache.ts +3 -4
  45. package/src/node/features/dns.ts +3 -2
  46. package/src/node/features/docker.ts +3 -2
  47. package/src/node/features/downloader.ts +3 -16
  48. package/src/node/features/esbuild.ts +3 -12
  49. package/src/node/features/file-manager.ts +3 -2
  50. package/src/node/features/fs.ts +12 -3
  51. package/src/node/features/git.ts +3 -2
  52. package/src/node/features/google-auth.ts +3 -2
  53. package/src/node/features/google-calendar.ts +3 -2
  54. package/src/node/features/google-docs.ts +3 -2
  55. package/src/node/features/google-drive.ts +3 -2
  56. package/src/node/features/google-sheets.ts +3 -2
  57. package/src/node/features/grep.ts +3 -2
  58. package/src/node/features/helpers.ts +13 -2
  59. package/src/node/features/ink.ts +3 -3
  60. package/src/node/features/ipc-socket.ts +3 -3
  61. package/src/node/features/json-tree.ts +3 -21
  62. package/src/node/features/launcher-app-command-listener.ts +3 -2
  63. package/src/node/features/networking.ts +3 -2
  64. package/src/node/features/nlp.ts +3 -2
  65. package/src/node/features/opener.ts +8 -7
  66. package/src/node/features/os.ts +3 -2
  67. package/src/node/features/package-finder.ts +3 -2
  68. package/src/node/features/port-exposer.ts +3 -4
  69. package/src/node/features/postgres.ts +3 -3
  70. package/src/node/features/proc.ts +37 -64
  71. package/src/node/features/process-manager.ts +3 -2
  72. package/src/node/features/python.ts +3 -3
  73. package/src/node/features/repl.ts +3 -2
  74. package/src/node/features/runpod.ts +3 -3
  75. package/src/node/features/secure-shell.ts +3 -2
  76. package/src/node/features/semantic-search.ts +4 -6
  77. package/src/node/features/sqlite.ts +3 -3
  78. package/src/node/features/telegram.ts +3 -2
  79. package/src/node/features/tts.ts +3 -2
  80. package/src/node/features/ui.ts +3 -3
  81. package/src/node/features/vault.ts +3 -14
  82. package/src/node/features/vm.ts +41 -3
  83. package/src/node/features/window-manager.ts +165 -22
  84. package/src/node/features/yaml-tree.ts +3 -4
  85. package/src/node/features/yaml.ts +3 -2
  86. package/src/registry.ts +1 -1
  87. package/src/scaffolds/generated.ts +1 -1
  88. package/src/server.ts +43 -0
  89. package/src/servers/express.ts +24 -8
  90. package/src/servers/mcp.ts +2 -6
  91. package/src/servers/socket.ts +22 -7
  92. package/src/web/clients/socket.ts +3 -5
  93. package/src/web/features/asset-loader.ts +20 -12
  94. package/src/web/features/container-link.ts +3 -6
  95. package/src/web/features/esbuild.ts +21 -7
  96. package/src/web/features/helpers.ts +4 -2
  97. package/src/web/features/network.ts +24 -7
  98. package/src/web/features/speech.ts +24 -7
  99. package/src/web/features/vault.ts +21 -3
  100. package/src/web/features/vm.ts +20 -13
  101. package/src/web/features/voice-recognition.ts +26 -9
  102. package/commands/update-introspection.ts +0 -67
  103. package/docs/ideas/class-registration-refactor-possibilities.md +0 -197
  104. package/docs/ideas/container-use-api.md +0 -9
  105. package/docs/ideas/easy-auth-for-express-servers-and-luca-serve.md +0 -0
  106. package/docs/ideas/feature-stacks.md +0 -22
  107. package/docs/ideas/luca-cli-self-sufficiency-demo.md +0 -23
  108. package/docs/ideas/mcp-design.md +0 -9
  109. package/docs/ideas/web-container-debugging-feature.md +0 -13
  110. package/scripts/animations/chrome-glitch.ts +0 -55
  111. package/scripts/animations/index.ts +0 -16
  112. package/scripts/animations/neon-pulse.ts +0 -64
  113. package/scripts/animations/types.ts +0 -6
package/src/client.ts CHANGED
@@ -38,6 +38,9 @@ export class Client<
38
38
  static override optionsSchema = ClientOptionsSchema
39
39
  static override eventsSchema = ClientEventsSchema
40
40
 
41
+ /** Self-register a Client subclass from a static initialization block. */
42
+ static register: (SubClass: typeof Client, id?: string) => typeof Client
43
+
41
44
  static attach(container: Container & ClientsInterface): any {
42
45
  Object.assign(container, {
43
46
  get clients() {
@@ -100,6 +103,61 @@ export class Client<
100
103
  }
101
104
  }
102
105
 
106
+ // --- Registry and Client.register must be defined BEFORE subclasses ---
107
+ // because static blocks in RestClient/GraphClient/WebSocketClient run at class declaration time.
108
+
109
+ export class ClientsRegistry extends Registry<Client<any>> {
110
+ override scope = "clients"
111
+ override baseClass = Client
112
+ }
113
+
114
+ export const clients = new ClientsRegistry();
115
+
116
+ export const helperCache = new Map();
117
+
118
+ /**
119
+ * Self-register a Client subclass from a static initialization block.
120
+ * IMPORTANT: Place the static block AFTER all static override declarations
121
+ * so schemas, envVars, and other metadata are set before interceptRegistration fires.
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * export class OpenAIClient extends Client {
126
+ * static override stateSchema = OpenAIClientStateSchema
127
+ * static override optionsSchema = OpenAIClientOptionsSchema
128
+ * static { Client.register(this, 'openai') } // must come last
129
+ * }
130
+ * ```
131
+ */
132
+ Client.register = function registerClient(
133
+ SubClass: typeof Client,
134
+ id?: string,
135
+ ) {
136
+ const registryId = id ?? SubClass.name[0]!.toLowerCase() + SubClass.name.slice(1)
137
+
138
+ // Auto-set shortcut if not explicitly overridden on this class
139
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'shortcut')?.value ||
140
+ (SubClass as any).shortcut === 'unspecified' ||
141
+ (SubClass as any).shortcut === 'clients.base') {
142
+ ;(SubClass as any).shortcut = `clients.${registryId}` as const
143
+ }
144
+
145
+ // Register in the clients registry (interceptRegistration sees all statics above)
146
+ clients.register(registryId, SubClass as any)
147
+
148
+ // Generate default attach() if not explicitly overridden on this class
149
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'attach')) {
150
+ ;(SubClass as any).attach = (container: any) => {
151
+ clients.register(registryId, SubClass as any)
152
+ return container
153
+ }
154
+ }
155
+
156
+ return SubClass
157
+ }
158
+
159
+ // --- Built-in client subclasses ---
160
+
103
161
  export class RestClient<
104
162
  T extends ClientState = ClientState,
105
163
  K extends ClientOptions = ClientOptions
@@ -107,18 +165,15 @@ export class RestClient<
107
165
  axios!: AxiosInstance;
108
166
 
109
167
  static override shortcut: string = "clients.rest"
110
-
111
- static override attach(container: Container & ClientsInterface): any {
112
- return container
113
- }
168
+ static { Client.register(this, 'rest') }
114
169
 
115
170
  constructor(options: K, context: ContainerContext) {
116
171
  super(options, context);
117
172
 
118
173
  this.axios = axios.create({
119
- baseURL: this.baseURL,
174
+ baseURL: this.baseURL,
120
175
  });
121
-
176
+
122
177
  if (this.useJSON) {
123
178
  this.axios.defaults.headers.common = {
124
179
  ...this.axios.defaults.headers.common,
@@ -127,16 +182,16 @@ export class RestClient<
127
182
  }
128
183
  }
129
184
  }
130
-
185
+
131
186
  async beforeRequest() {
132
187
  }
133
-
188
+
134
189
  get useJSON() {
135
190
  return !!this.options.json
136
191
  }
137
-
192
+
138
193
  override get baseURL() {
139
- return this.options.baseURL || '/'
194
+ return this.options.baseURL || '/'
140
195
  }
141
196
 
142
197
  async patch(url: string, data: any = {}, options: AxiosRequestConfig = {}) {
@@ -156,7 +211,7 @@ export class RestClient<
156
211
  }
157
212
  });
158
213
  }
159
-
214
+
160
215
  async put(url: string, data: any = {}, options: AxiosRequestConfig = {}) {
161
216
  await this.beforeRequest();
162
217
  return this.axios({
@@ -174,7 +229,7 @@ export class RestClient<
174
229
  }
175
230
  });
176
231
  }
177
-
232
+
178
233
  async post(url: string, data: any = {}, options: AxiosRequestConfig = {}) {
179
234
  await this.beforeRequest();
180
235
  return this.axios({
@@ -249,6 +304,7 @@ export class GraphClient<
249
304
  static override shortcut = "clients.graph" as const
250
305
  static override optionsSchema = GraphClientOptionsSchema
251
306
  static override eventsSchema = GraphClientEventsSchema
307
+ static { Client.register(this, 'graph') }
252
308
 
253
309
  /** The GraphQL endpoint path. Defaults to '/graphql'. */
254
310
  get endpoint() {
@@ -321,6 +377,7 @@ export class WebSocketClient<
321
377
  static override stateSchema = WebSocketClientStateSchema
322
378
  static override optionsSchema = WebSocketClientOptionsSchema
323
379
  static override eventsSchema = WebSocketClientEventsSchema
380
+ static { Client.register(this, 'websocket') }
324
381
 
325
382
  constructor(options?: K, context?: ContainerContext) {
326
383
  super(options, context)
@@ -444,18 +501,4 @@ export class WebSocketClient<
444
501
  }
445
502
  }
446
503
 
447
-
448
- export class ClientsRegistry extends Registry<Client<any>> {
449
- override scope = "clients"
450
- override baseClass = Client
451
- }
452
-
453
- export const clients = new ClientsRegistry();
454
-
455
- clients.register("rest", RestClient);
456
- clients.register("graph", GraphClient);
457
- clients.register("websocket", WebSocketClient);
458
-
459
- export const helperCache = new Map();
460
-
461
504
  export default Client;
@@ -1,10 +1,9 @@
1
1
  import {
2
2
  Client,
3
3
  type ClientOptions,
4
- type ClientsInterface,
5
4
  RestClient,
6
5
  } from "@soederpop/luca/client";
7
- import { Container, type ContainerContext } from "@soederpop/luca/container";
6
+ import { type ContainerContext } from "@soederpop/luca/container";
8
7
  import { isEmpty, maxBy, omitBy } from "lodash-es";
9
8
  import { NodeContainer } from "@soederpop/luca/node/container";
10
9
  import { z } from 'zod'
@@ -38,11 +37,8 @@ export type CivitaiClientState = z.infer<typeof CivitaiClientStateSchema>
38
37
  */
39
38
  export class CivitaiClient<T extends CivitaiClientState> extends RestClient<T> {
40
39
  static override stateSchema = CivitaiClientStateSchema;
41
- // @ts-ignore
42
- static attach(container: Container & ClientsInterface, options?: any) {
43
- container.clients.register("civitai", CivitaiClient);
44
- return container
45
- }
40
+
41
+ static { Client.register(this, 'civitai') }
46
42
 
47
43
  constructor(options: ClientOptions, context: ContainerContext) {
48
44
  options = {
@@ -1,9 +1,8 @@
1
1
  import {
2
- type ClientsInterface,
3
- clients,
2
+ Client,
4
3
  RestClient,
5
4
  } from "@soederpop/luca/client";
6
- import type { Container, ContainerContext } from "@soederpop/luca/container";
5
+ import type { ContainerContext } from "@soederpop/luca/container";
7
6
  import { z } from 'zod'
8
7
  import { ClientStateSchema, ClientOptionsSchema } from '@soederpop/luca/schemas/base.js'
9
8
 
@@ -68,12 +67,9 @@ export class ComfyUIClient extends RestClient<ComfyUIClientState, ComfyUIClientO
68
67
  static override stateSchema = ComfyUIClientStateSchema;
69
68
  static override optionsSchema = ComfyUIClientOptionsSchema;
70
69
 
71
- private ws: WebSocket | null = null;
70
+ static { Client.register(this, 'comfyui') }
72
71
 
73
- static override attach(container: Container & ClientsInterface, options?: any) {
74
- container.clients.register("comfyui", ComfyUIClient);
75
- return container;
76
- }
72
+ private ws: WebSocket | null = null;
77
73
 
78
74
  constructor(options: ComfyUIClientOptions, context: ContainerContext) {
79
75
  super(
@@ -594,4 +590,4 @@ export class ComfyUIClient extends RestClient<ComfyUIClientState, ComfyUIClientO
594
590
  }
595
591
  }
596
592
 
597
- export default clients.register("comfyui", ComfyUIClient);
593
+ export default ComfyUIClient;
@@ -1,8 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { ClientStateSchema, ClientOptionsSchema, ClientEventsSchema } from '@soederpop/luca/schemas/base.js'
3
- import { clients, RestClient } from "@soederpop/luca/client";
4
- import type { Container, ContainerContext } from "@soederpop/luca/container";
5
- import type { ClientsInterface, ClientOptions } from "@soederpop/luca/client";
3
+ import { Client, RestClient } from "@soederpop/luca/client";
4
+ import type { ContainerContext } from "@soederpop/luca/container";
6
5
  import type { AxiosRequestConfig } from 'axios'
7
6
 
8
7
  declare module "@soederpop/luca/client" {
@@ -48,6 +47,7 @@ export type SynthesizeOptions = {
48
47
  modelId?: string
49
48
  outputFormat?: string
50
49
  voiceSettings?: ElevenLabsVoiceSettings
50
+ disableCache?: boolean
51
51
  }
52
52
 
53
53
  /**
@@ -72,11 +72,7 @@ export class ElevenLabsClient extends RestClient<ElevenLabsClientState, ElevenLa
72
72
  static override optionsSchema = ElevenLabsClientOptionsSchema
73
73
  static override eventsSchema = ElevenLabsClientEventsSchema
74
74
 
75
- // @ts-ignore
76
- static override attach(container: Container & ClientsInterface, options?: any) {
77
- container.clients.register("elevenlabs", ElevenLabsClient);
78
- return container
79
- }
75
+ static { Client.register(this, 'elevenlabs') }
80
76
 
81
77
  override get initialState(): ElevenLabsClientState {
82
78
  return {
@@ -238,8 +234,40 @@ export class ElevenLabsClient extends RestClient<ElevenLabsClientState, ElevenLa
238
234
  }
239
235
  }
240
236
 
241
- this.trackRequest(text.length)
237
+ // Check disk cache for previously synthesized audio
238
+ if (!options.disableCache) {
239
+ const { hashObject } = this.container.utils
240
+ const cacheKey = `elevenlabs:${hashObject({ text, voiceId, modelId, outputFormat, voiceSettings: options.voiceSettings })}`
241
+ const diskCache = this.container.feature('diskCache')
242
+
243
+ if (await diskCache.has(cacheKey)) {
244
+ console.log(`Cache Hit: ${cacheKey}`)
245
+ const cached = await diskCache.get(cacheKey)
246
+ const audioBuffer = Buffer.from(cached, 'base64')
247
+
248
+ this.emit('speech', {
249
+ voiceId,
250
+ text,
251
+ audioSize: audioBuffer.length,
252
+ })
253
+
254
+ return audioBuffer
255
+ }
256
+
257
+ const audioBuffer = await this.fetchSpeech(voiceId, outputFormat, body, text.length)
258
+ await diskCache.set(cacheKey, audioBuffer.toString('base64'))
259
+
260
+ this.emit('speech', { voiceId, text, audioSize: audioBuffer.length })
261
+ return audioBuffer
262
+ }
263
+
264
+ const audioBuffer = await this.fetchSpeech(voiceId, outputFormat, body, text.length)
265
+ this.emit('speech', { voiceId, text, audioSize: audioBuffer.length })
266
+ return audioBuffer
267
+ }
242
268
 
269
+ private async fetchSpeech(voiceId: string, outputFormat: string, body: Record<string, any>, charCount: number): Promise<Buffer> {
270
+ this.trackRequest(charCount)
243
271
  await this.beforeRequest()
244
272
 
245
273
  const response = await this.axios({
@@ -254,15 +282,7 @@ export class ElevenLabsClient extends RestClient<ElevenLabsClientState, ElevenLa
254
282
  },
255
283
  })
256
284
 
257
- const audioBuffer = Buffer.from(response.data)
258
-
259
- this.emit('speech', {
260
- voiceId,
261
- text,
262
- audioSize: audioBuffer.length,
263
- })
264
-
265
- return audioBuffer
285
+ return Buffer.from(response.data)
266
286
  }
267
287
 
268
288
  /**
@@ -287,5 +307,5 @@ export class ElevenLabsClient extends RestClient<ElevenLabsClientState, ElevenLa
287
307
  }
288
308
  }
289
309
 
290
- export default clients.register("elevenlabs", ElevenLabsClient)
310
+ export default ElevenLabsClient
291
311
 
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod'
2
2
  import { ClientStateSchema, ClientOptionsSchema } from '@soederpop/luca/schemas/base.js'
3
- import { clients, Client } from "@soederpop/luca/client";
3
+ import { Client } from "@soederpop/luca/client";
4
4
  import type { Container, ContainerContext } from "@soederpop/luca/container";
5
- import type { ClientsInterface } from "@soederpop/luca/client";
5
+
6
6
  import OpenAI from "openai";
7
7
 
8
8
  export const OpenAIClientStateSchema = ClientStateSchema.extend({
@@ -48,9 +48,7 @@ export class OpenAIClient extends Client<OpenAIClientState, OpenAIClientOptions>
48
48
  static override stateSchema = OpenAIClientStateSchema
49
49
  static override optionsSchema = OpenAIClientOptionsSchema
50
50
 
51
- static override attach(container: Container & ClientsInterface): any {
52
- return container;
53
- }
51
+ static { Client.register(this, 'openai') }
54
52
 
55
53
  /** Initial state with zeroed token usage counters. */
56
54
  override get initialState(): OpenAIClientState {
@@ -446,6 +444,4 @@ export class OpenAIClient extends Client<OpenAIClientState, OpenAIClientOptions>
446
444
  }
447
445
  }
448
446
 
449
- clients.register("openai", OpenAIClient)
450
-
451
447
  export default OpenAIClient;
@@ -1,10 +1,7 @@
1
1
  import {
2
2
  Client,
3
- type ClientOptions,
4
- type ClientsInterface,
5
- clients,
6
3
  } from "@soederpop/luca/client";
7
- import type { Container, ContainerContext } from "@soederpop/luca/container";
4
+ import type { ContainerContext } from "@soederpop/luca/container";
8
5
  import { z } from "zod";
9
6
  import {
10
7
  ClientStateSchema,
@@ -141,16 +138,11 @@ export class SupabaseClient extends Client<
141
138
  static override optionsSchema = SupabaseClientOptionsSchema;
142
139
  static override eventsSchema = SupabaseClientEventsSchema;
143
140
 
141
+ static { Client.register(this, 'supabase') }
142
+
144
143
  private _sdk!: SupabaseSDKClient<any, any>;
145
144
  private _channels = new Map<string, RealtimeChannel>();
146
145
 
147
- // @ts-ignore - required options (supabaseUrl, supabaseKey) widen beyond base ClientOptions
148
- static attach(container: Container & ClientsInterface, options?: any) {
149
- // @ts-ignore
150
- container.clients.register("supabase", SupabaseClient);
151
- return container;
152
- }
153
-
154
146
  constructor(options: SupabaseClientOptions, context: ContainerContext) {
155
147
  super(options, context);
156
148
 
@@ -362,5 +354,4 @@ export class SupabaseClient extends Client<
362
354
  }
363
355
  }
364
356
 
365
- // @ts-ignore
366
- clients.register("supabase", SupabaseClient);
357
+ export default SupabaseClient;
@@ -63,9 +63,6 @@ async function evalBeforeRepl(evalArg: string, container: any, featureContext: R
63
63
  code = transformed
64
64
  }
65
65
 
66
- const hasTopLevelAwait = /\bawait\b/.test(code)
67
- code = hasTopLevelAwait ? `(async function() { ${code} })()` : code
68
-
69
66
  await vm.run(code, shared)
70
67
  Object.assign(shared, container.context)
71
68
  } else {
@@ -34,7 +34,7 @@ export default async function evalCommand(options: z.infer<typeof argsSchema>, c
34
34
  const vm = container.feature('vm')
35
35
 
36
36
  // HACK
37
- Array(container.argv.enable).map((id) => {
37
+ Array(container.argv.enable).filter(Boolean).map((id) => {
38
38
  container.feature(id, { ...container.argv, enable: true }).enable()
39
39
  })
40
40
 
@@ -12,3 +12,4 @@ import './describe.js'
12
12
  import './eval.js'
13
13
  import './help.js'
14
14
  import './scaffold.js'
15
+ import './introspect.js'
@@ -0,0 +1,128 @@
1
+ import { z } from 'zod'
2
+ import { commands } from '../command.js'
3
+ import { CommandOptionsSchema } from '../schemas/base.js'
4
+ import type { ContainerContext } from '../container.js'
5
+ import '../introspection/scan.js'
6
+
7
+ declare module '../command.js' {
8
+ interface AvailableCommands {
9
+ introspect: ReturnType<typeof commands.registerHandler>
10
+ }
11
+ }
12
+
13
+ export const argsSchema = CommandOptionsSchema.extend({
14
+ src: z.array(z.string()).optional().describe('Source directories to scan (default: auto-discover features/, clients/, servers/)'),
15
+ output: z.string().optional().describe('Output file path (default: features/introspection.generated.ts)'),
16
+ 'dry-run': z.boolean().default(false).describe('Preview without writing'),
17
+ 'include-private': z.boolean().default(false).describe('Include private methods in output'),
18
+ })
19
+
20
+ async function introspect(options: z.infer<typeof argsSchema>, context: ContainerContext) {
21
+ const container = context.container as any
22
+ const { fs, paths } = container
23
+
24
+ // Detect if we're inside the luca library itself
25
+ const cwd = paths.cwd
26
+ const isLucaLibrary = fs.exists(paths.resolve('src/introspection/scan.ts'))
27
+
28
+ if (isLucaLibrary) {
29
+ // Inside luca itself — delegate to the multi-target update-introspection behavior
30
+ const { NodeContainer } = await import('../node/container.js')
31
+ const lucaContainer = new NodeContainer()
32
+
33
+ const targets = [
34
+ {
35
+ name: 'node',
36
+ src: ['src/node/features', 'src/servers', 'src/container.ts', 'src/node/container.ts'],
37
+ outputPath: 'src/introspection/generated.node.ts',
38
+ },
39
+ {
40
+ name: 'web',
41
+ src: ['src/web/features', 'src/container.ts', 'src/web/container.ts'],
42
+ outputPath: 'src/introspection/generated.web.ts',
43
+ },
44
+ {
45
+ name: 'agi',
46
+ src: ['src/node/features', 'src/servers', 'src/agi/features', 'src/container.ts', 'src/node/container.ts', 'src/agi/container.server.ts'],
47
+ outputPath: 'src/introspection/generated.agi.ts',
48
+ },
49
+ ]
50
+
51
+ for (const target of targets) {
52
+ console.log(`\nGenerating ${target.name} introspection data...`)
53
+ console.log(` Sources: ${target.src.join(', ')}`)
54
+ console.log(` Output: ${target.outputPath}`)
55
+
56
+ const scanner = lucaContainer.feature('introspectionScanner', {
57
+ src: target.src,
58
+ outputPath: options['dry-run'] ? undefined : target.outputPath,
59
+ includePrivate: options['include-private'],
60
+ })
61
+
62
+ scanner.on('scanCompleted', (data: any) => {
63
+ console.log(` Found ${data.results} helpers in ${data.files} files (${data.duration}ms)`)
64
+ })
65
+
66
+ await scanner.scan()
67
+ const script = await scanner.generateRegistryScript()
68
+
69
+ if (options['dry-run']) {
70
+ console.log(`\n--- ${target.outputPath} (dry run) ---`)
71
+ console.log(script.slice(0, 500) + (script.length > 500 ? '\n...' : ''))
72
+ } else {
73
+ console.log(` Wrote ${target.outputPath}`)
74
+ }
75
+ }
76
+
77
+ console.log('\nAll introspection data generated.')
78
+ return
79
+ }
80
+
81
+ // Project-local mode: auto-discover source directories
82
+ const defaultSrc = ['features', 'clients', 'servers']
83
+ .filter(dir => fs.exists(paths.resolve(dir)))
84
+
85
+ // Also scan container.ts if it exists at project root
86
+ const containerFile = paths.resolve('container.ts')
87
+ if (fs.exists(containerFile)) defaultSrc.push('container.ts')
88
+
89
+ const src = options.src || defaultSrc
90
+
91
+ if (src.length === 0) {
92
+ console.log('No source directories found to scan. Use --src to specify directories.')
93
+ return
94
+ }
95
+
96
+ const outputPath = options.output || 'features/introspection.generated.ts'
97
+ const importSource = '@soederpop/luca/introspection'
98
+
99
+ console.log(`Scanning: ${src.join(', ')}`)
100
+ console.log(`Output: ${outputPath}`)
101
+
102
+ const scanner = container.feature('introspectionScanner', {
103
+ src,
104
+ outputPath: options['dry-run'] ? undefined : outputPath,
105
+ includePrivate: options['include-private'],
106
+ importSource,
107
+ })
108
+
109
+ scanner.on('scanCompleted', (data: any) => {
110
+ console.log(`Found ${data.results} helpers in ${data.files} files (${data.duration}ms)`)
111
+ })
112
+
113
+ await scanner.scan()
114
+ const script = await scanner.generateRegistryScript()
115
+
116
+ if (options['dry-run']) {
117
+ console.log(`\n--- ${outputPath} (dry run) ---`)
118
+ console.log(script)
119
+ } else {
120
+ console.log(`Wrote ${outputPath}`)
121
+ }
122
+ }
123
+
124
+ commands.registerHandler('introspect', {
125
+ description: 'Generate introspection metadata from source. Works in any luca project.',
126
+ argsSchema,
127
+ handler: introspect,
128
+ })
@@ -628,10 +628,7 @@ async function executePromptFile(resolvedPath: string, container: any): Promise<
628
628
  code = transformed
629
629
  }
630
630
 
631
- const hasTopLevelAwait = /\bawait\b/.test(code)
632
- if (hasTopLevelAwait) code = `(async function() { ${code} })()`
633
-
634
- await vm.run(code, shared)
631
+ await vm.run(code, shared)
635
632
  Object.assign(shared, container.context)
636
633
 
637
634
  if (capturedLines.length) {
@@ -96,12 +96,7 @@ async function runMarkdown(scriptPath: string, options: z.infer<typeof argsSchem
96
96
  const keysBefore = new Set(Object.keys(shared))
97
97
  const { code: transformed } = esbuild.transformSync(source, { loader: 'tsx', format: 'cjs' })
98
98
 
99
- const hasTopLevelAwait = /\bawait\b/.test(transformed)
100
- const wrapped = hasTopLevelAwait
101
- ? `(async function() { ${transformed} })()`
102
- : transformed
103
-
104
- await vm.run(wrapped, shared)
99
+ await vm.run(transformed, shared)
105
100
 
106
101
  // auto-register any new functions as blocks
107
102
  for (const key of Object.keys(shared)) {
@@ -165,11 +160,6 @@ async function runMarkdown(scriptPath: string, options: z.infer<typeof argsSchem
165
160
  code = transformed
166
161
  }
167
162
 
168
- const hasTopLevelAwait = /\bawait\b/.test(code)
169
- code = hasTopLevelAwait
170
- ? `(async function() { ${code} })()`
171
- : code
172
-
173
163
  await vm.run(code, shared)
174
164
 
175
165
  // if we enabled any features, they will be in the context object
@@ -186,13 +176,16 @@ async function runMarkdown(scriptPath: string, options: z.infer<typeof argsSchem
186
176
  async function runScript(scriptPath: string, context: ContainerContext) {
187
177
  const container = context.container as any
188
178
 
189
- const { exitCode, stderr } = await container.proc.runScript(scriptPath)
179
+ const { exitCode, stderr } = await container.proc.execAndCapture(`bun run ${scriptPath}`, {
180
+ onOutput: (data: string) => process.stdout.write(data),
181
+ onError: (data: string) => process.stderr.write(data),
182
+ })
190
183
 
191
184
  if (exitCode === 0) return
192
185
 
193
186
  console.error(`\nScript failed with exit code ${exitCode}.\n`)
194
187
  if (stderr.length) {
195
- console.error(stderr.join('\n'))
188
+ console.error(stderr)
196
189
  }
197
190
  }
198
191
 
@@ -154,19 +154,7 @@ export default async function mcpSandbox(options: z.infer<typeof argsSchema>, co
154
154
  }),
155
155
  handler: async (args) => {
156
156
  try {
157
- // Wrap code containing top-level await in an async IIFE so the VM can handle it.
158
- // Try to return the last expression's value by prepending `return` to the last statement.
159
- let code = args.code
160
- if (/\bawait\b/.test(code) && !/^\s*\(?\s*async\b/.test(code)) {
161
- const lines = code.split('\n')
162
- const lastLine = lines[lines.length - 1]
163
- // If the last line doesn't start with a keyword that can't be returned, add return
164
- if (!/^\s*(var|let|const|if|for|while|switch|try|throw|class|function)\b/.test(lastLine)) {
165
- lines[lines.length - 1] = `return ${lastLine}`
166
- }
167
- code = `(async () => { ${lines.join('\n')} })()`
168
- }
169
- const result = await vmFeature.run(code, sandboxContext)
157
+ const result = await vmFeature.run(args.code, sandboxContext)
170
158
 
171
159
  let text: string
172
160
  if (result === undefined) {
package/src/feature.ts CHANGED
@@ -22,6 +22,9 @@ export abstract class Feature<T extends FeatureState = FeatureState, K extends F
22
22
  static override optionsSchema = FeatureOptionsSchema
23
23
  static override eventsSchema = FeatureEventsSchema
24
24
 
25
+ /** Self-register a Feature subclass from a static initialization block. */
26
+ static register: (SubClass: typeof Feature, id?: string) => typeof Feature
27
+
25
28
  get shortcut() {
26
29
  return (this.constructor as any).shortcut as string
27
30
  }
@@ -67,9 +70,49 @@ export abstract class Feature<T extends FeatureState = FeatureState, K extends F
67
70
  }
68
71
  }
69
72
 
70
- export class FeaturesRegistry extends Registry<Feature<any, any>> {
73
+ export class FeaturesRegistry extends Registry<Feature<any, any>> {
71
74
  override scope = "features"
72
75
  override baseClass = Feature as any
73
76
  }
74
77
 
75
- export const features = new FeaturesRegistry()
78
+ export const features = new FeaturesRegistry()
79
+
80
+ /**
81
+ * Self-register a Feature subclass from a static initialization block.
82
+ * IMPORTANT: Place the static block AFTER all static override declarations
83
+ * so schemas, envVars, and other metadata are set before interceptRegistration fires.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * export default class DNS extends Feature<DnsState, DnsOptions> {
88
+ * static override stateSchema = DnsStateSchema
89
+ * static override optionsSchema = DnsOptionsSchema
90
+ * static { Feature.register(this, 'dns') } // must come last
91
+ * }
92
+ * ```
93
+ */
94
+ Feature.register = function registerFeature(
95
+ SubClass: typeof Feature,
96
+ id?: string,
97
+ ) {
98
+ const registryId = id ?? SubClass.name[0]!.toLowerCase() + SubClass.name.slice(1)
99
+
100
+ // Auto-set shortcut if not explicitly overridden on this class
101
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'shortcut')?.value ||
102
+ (SubClass as any).shortcut === 'unspecified') {
103
+ ;(SubClass as any).shortcut = `features.${registryId}` as const
104
+ }
105
+
106
+ // Register in the features registry (interceptRegistration sees all statics above)
107
+ features.register(registryId, SubClass as any)
108
+
109
+ // Generate default attach() if not explicitly overridden on this class
110
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'attach')) {
111
+ ;(SubClass as any).attach = (container: any) => {
112
+ features.register(registryId, SubClass as any)
113
+ return container
114
+ }
115
+ }
116
+
117
+ return SubClass
118
+ }