@soederpop/luca 0.0.6 → 0.0.7

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 (211) hide show
  1. package/CLAUDE.md +10 -1
  2. package/bun.lock +1 -1
  3. package/commands/build-bootstrap.ts +78 -0
  4. package/commands/build-scaffolds.ts +24 -2
  5. package/commands/try-all-challenges.ts +543 -0
  6. package/commands/try-challenge.ts +100 -0
  7. package/docs/README.md +52 -80
  8. package/docs/TABLE-OF-CONTENTS.md +82 -51
  9. package/docs/apis/clients/elevenlabs.md +232 -8
  10. package/docs/apis/clients/graph.md +59 -8
  11. package/docs/apis/clients/openai.md +362 -2
  12. package/docs/apis/clients/rest.md +122 -2
  13. package/docs/apis/clients/websocket.md +71 -17
  14. package/docs/apis/features/agi/assistant.md +9 -3
  15. package/docs/apis/features/agi/assistants-manager.md +2 -2
  16. package/docs/apis/features/agi/claude-code.md +153 -14
  17. package/docs/apis/features/agi/conversation-history.md +15 -3
  18. package/docs/apis/features/agi/conversation.md +133 -20
  19. package/docs/apis/features/agi/openai-codex.md +90 -12
  20. package/docs/apis/features/agi/skills-library.md +23 -5
  21. package/docs/apis/features/node/container-link.md +59 -0
  22. package/docs/apis/features/node/content-db.md +1 -1
  23. package/docs/apis/features/node/disk-cache.md +1 -1
  24. package/docs/apis/features/node/dns.md +1 -0
  25. package/docs/apis/features/node/docker.md +2 -1
  26. package/docs/apis/features/node/esbuild.md +4 -3
  27. package/docs/apis/features/node/file-manager.md +13 -4
  28. package/docs/apis/features/node/fs.md +726 -171
  29. package/docs/apis/features/node/git.md +1 -0
  30. package/docs/apis/features/node/google-auth.md +23 -4
  31. package/docs/apis/features/node/google-calendar.md +14 -2
  32. package/docs/apis/features/node/google-docs.md +15 -2
  33. package/docs/apis/features/node/google-drive.md +21 -3
  34. package/docs/apis/features/node/google-sheets.md +14 -2
  35. package/docs/apis/features/node/grep.md +2 -0
  36. package/docs/apis/features/node/helpers.md +29 -0
  37. package/docs/apis/features/node/ink.md +2 -2
  38. package/docs/apis/features/node/networking.md +39 -4
  39. package/docs/apis/features/node/os.md +28 -0
  40. package/docs/apis/features/node/postgres.md +26 -4
  41. package/docs/apis/features/node/proc.md +37 -28
  42. package/docs/apis/features/node/process-manager.md +33 -5
  43. package/docs/apis/features/node/repl.md +1 -1
  44. package/docs/apis/features/node/runpod.md +1 -0
  45. package/docs/apis/features/node/secure-shell.md +7 -0
  46. package/docs/apis/features/node/semantic-search.md +12 -5
  47. package/docs/apis/features/node/sqlite.md +26 -4
  48. package/docs/apis/features/node/telegram.md +30 -5
  49. package/docs/apis/features/node/tts.md +17 -2
  50. package/docs/apis/features/node/ui.md +1 -1
  51. package/docs/apis/features/node/vault.md +4 -9
  52. package/docs/apis/features/node/vm.md +3 -12
  53. package/docs/apis/features/node/window-manager.md +128 -20
  54. package/docs/apis/features/web/asset-loader.md +13 -1
  55. package/docs/apis/features/web/container-link.md +59 -0
  56. package/docs/apis/features/web/esbuild.md +4 -3
  57. package/docs/apis/features/web/helpers.md +29 -0
  58. package/docs/apis/features/web/network.md +16 -2
  59. package/docs/apis/features/web/speech.md +16 -2
  60. package/docs/apis/features/web/vault.md +4 -9
  61. package/docs/apis/features/web/vm.md +3 -12
  62. package/docs/apis/features/web/voice.md +18 -1
  63. package/docs/apis/servers/express.md +18 -2
  64. package/docs/apis/servers/mcp.md +29 -4
  65. package/docs/apis/servers/websocket.md +34 -6
  66. package/docs/bootstrap/CLAUDE.md +100 -0
  67. package/docs/bootstrap/SKILL.md +222 -0
  68. package/docs/bootstrap/templates/about-command.ts +41 -0
  69. package/docs/bootstrap/templates/docs-models.ts +22 -0
  70. package/docs/bootstrap/templates/docs-readme.md +43 -0
  71. package/docs/bootstrap/templates/example-feature.ts +53 -0
  72. package/docs/bootstrap/templates/health-endpoint.ts +15 -0
  73. package/docs/bootstrap/templates/luca-cli.ts +25 -0
  74. package/docs/challenges/caching-proxy.md +16 -0
  75. package/docs/challenges/content-db-round-trip.md +14 -0
  76. package/docs/challenges/custom-command.md +9 -0
  77. package/docs/challenges/file-watcher-pipeline.md +11 -0
  78. package/docs/challenges/grep-audit-report.md +15 -0
  79. package/docs/challenges/multi-feature-dashboard.md +14 -0
  80. package/docs/challenges/process-orchestrator.md +17 -0
  81. package/docs/challenges/rest-api-server-with-client.md +12 -0
  82. package/docs/challenges/script-runner-with-vm.md +11 -0
  83. package/docs/challenges/simple-rest-api.md +15 -0
  84. package/docs/challenges/websocket-serve-and-client.md +11 -0
  85. package/docs/challenges/yaml-config-system.md +14 -0
  86. package/docs/command-system-overhaul.md +94 -0
  87. package/docs/examples/assistant/CORE.md +18 -0
  88. package/docs/examples/assistant/hooks.ts +3 -0
  89. package/docs/examples/assistant/tools.ts +10 -0
  90. package/docs/examples/window-manager-layouts.md +180 -0
  91. package/docs/in-memory-fs.md +4 -0
  92. package/docs/models.ts +13 -10
  93. package/docs/philosophy.md +4 -3
  94. package/docs/reports/console-hmr-design.md +170 -0
  95. package/docs/reports/helper-semantic-search.md +72 -0
  96. package/docs/scaffolds/client.md +29 -20
  97. package/docs/scaffolds/command.md +64 -50
  98. package/docs/scaffolds/endpoint.md +31 -36
  99. package/docs/scaffolds/feature.md +28 -18
  100. package/docs/scaffolds/selector.md +91 -0
  101. package/docs/scaffolds/server.md +18 -9
  102. package/docs/selectors.md +115 -0
  103. package/docs/sessions/custom-command/attempt-log-2.md +195 -0
  104. package/docs/sessions/file-watcher-pipeline/attempt-log-1.md +728 -0
  105. package/docs/sessions/file-watcher-pipeline/attempt-log-2.md +555 -0
  106. package/docs/sessions/grep-audit-report/attempt-log-1.md +289 -0
  107. package/docs/sessions/multi-feature-dashboard/attempt-log-2.md +679 -0
  108. package/docs/sessions/rest-api-server-with-client/attempt-log-1.md +1 -0
  109. package/docs/sessions/rest-api-server-with-client/attempt-log-3.md +920 -0
  110. package/docs/sessions/simple-rest-api/attempt-log-1.md +593 -0
  111. package/docs/sessions/websocket-serve-and-client/attempt-log-2.md +995 -0
  112. package/docs/tutorials/00-bootstrap.md +148 -0
  113. package/docs/tutorials/07-endpoints.md +7 -7
  114. package/docs/tutorials/08-commands.md +153 -72
  115. package/luca.cli.ts +3 -0
  116. package/package.json +6 -5
  117. package/public/index.html +1430 -0
  118. package/scripts/examples/using-ollama.ts +2 -1
  119. package/scripts/update-introspection-data.ts +2 -2
  120. package/src/agi/endpoints/experts.ts +1 -1
  121. package/src/agi/features/assistant.ts +7 -0
  122. package/src/agi/features/assistants-manager.ts +5 -5
  123. package/src/agi/features/claude-code.ts +263 -3
  124. package/src/agi/features/conversation-history.ts +7 -1
  125. package/src/agi/features/conversation.ts +26 -3
  126. package/src/agi/features/openai-codex.ts +26 -2
  127. package/src/agi/features/openapi.ts +6 -1
  128. package/src/agi/features/skills-library.ts +9 -1
  129. package/src/bootstrap/generated.ts +540 -0
  130. package/src/cli/cli.ts +64 -21
  131. package/src/client.ts +23 -357
  132. package/src/clients/civitai/index.ts +1 -1
  133. package/src/clients/client-template.ts +1 -1
  134. package/src/clients/comfyui/index.ts +13 -2
  135. package/src/clients/elevenlabs/index.ts +2 -1
  136. package/src/clients/graph.ts +87 -0
  137. package/src/clients/openai/index.ts +10 -1
  138. package/src/clients/rest.ts +207 -0
  139. package/src/clients/websocket.ts +176 -0
  140. package/src/command.ts +281 -34
  141. package/src/commands/bootstrap.ts +181 -0
  142. package/src/commands/chat.ts +5 -4
  143. package/src/commands/describe.ts +225 -2
  144. package/src/commands/help.ts +35 -9
  145. package/src/commands/index.ts +3 -0
  146. package/src/commands/introspect.ts +92 -2
  147. package/src/commands/prompt.ts +5 -6
  148. package/src/commands/run.ts +33 -10
  149. package/src/commands/save-api-docs.ts +49 -0
  150. package/src/commands/scaffold.ts +169 -23
  151. package/src/commands/select.ts +94 -0
  152. package/src/commands/serve.ts +10 -1
  153. package/src/container.ts +15 -0
  154. package/src/endpoint.ts +19 -0
  155. package/src/graft.ts +181 -0
  156. package/src/introspection/generated.agi.ts +12458 -8968
  157. package/src/introspection/generated.node.ts +10573 -7145
  158. package/src/introspection/generated.web.ts +1 -1
  159. package/src/introspection/index.ts +26 -0
  160. package/src/node/container.ts +6 -7
  161. package/src/node/features/content-db.ts +49 -2
  162. package/src/node/features/disk-cache.ts +16 -9
  163. package/src/node/features/dns.ts +16 -3
  164. package/src/node/features/docker.ts +16 -4
  165. package/src/node/features/esbuild.ts +20 -0
  166. package/src/node/features/file-manager.ts +184 -29
  167. package/src/node/features/fs.ts +704 -248
  168. package/src/node/features/git.ts +21 -8
  169. package/src/node/features/grep.ts +23 -3
  170. package/src/node/features/helpers.ts +372 -43
  171. package/src/node/features/networking.ts +39 -4
  172. package/src/node/features/opener.ts +28 -15
  173. package/src/node/features/os.ts +76 -0
  174. package/src/node/features/port-exposer.ts +11 -1
  175. package/src/node/features/postgres.ts +17 -1
  176. package/src/node/features/proc.ts +4 -1
  177. package/src/node/features/python.ts +63 -14
  178. package/src/node/features/repl.ts +11 -7
  179. package/src/node/features/runpod.ts +16 -3
  180. package/src/node/features/secure-shell.ts +27 -2
  181. package/src/node/features/semantic-search.ts +12 -1
  182. package/src/node/features/ui.ts +5 -69
  183. package/src/node/features/vm.ts +17 -0
  184. package/src/node/features/window-manager.ts +68 -20
  185. package/src/node.ts +5 -0
  186. package/src/scaffolds/generated.ts +492 -290
  187. package/src/scaffolds/template.ts +9 -0
  188. package/src/schemas/base.ts +46 -5
  189. package/src/selector.ts +282 -0
  190. package/src/server.ts +11 -0
  191. package/src/servers/express.ts +27 -12
  192. package/src/servers/socket.ts +45 -11
  193. package/src/web/clients/socket.ts +4 -1
  194. package/src/web/container.ts +2 -1
  195. package/src/web/features/network.ts +7 -1
  196. package/src/web/features/voice-recognition.ts +16 -1
  197. package/test/clients-servers.test.ts +2 -1
  198. package/test/command.test.ts +267 -0
  199. package/test-integration/assistants-manager.test.ts +10 -20
  200. package/tmp/.cache/luca-disk-cache/content-v2/sha512/1b/b5/c75b28794f00f94c4d609a98978e9420e9b7146d204a7fbf5b0b30477292581705d207c0100dabaac27eef540aaaece3374af75104a93219d4ec8bfb44e7 +1 -0
  201. package/tmp/.cache/luca-disk-cache/content-v2/sha512/da/df/1d90ce4e042abeb035a197832c6d6893420a747a056be773eb00e4f745a037d505c8db13dde7d36b36b6b893addbb7df0f5fe9f0c13e665f20056447318b +1 -0
  202. package/tmp/.cache/luca-disk-cache/content-v2/sha512/ed/04/e1d0c2a58c2db29b3921ca2affb3ea4febe831c53b38ebc21019fb799823aba6ed5b4611873d2cd25d422d49955b852a9c326da0d678899bc1c2c2960901 +1 -0
  203. package/tmp/.cache/luca-disk-cache/index-v5/00/13/572aa4c9a94f99eda999695d050cdd0ca7fe2d23a50af03234d4c8ce0791 +2 -0
  204. package/tmp/.cache/luca-disk-cache/index-v5/75/a9/cb61dc0f0589e8ec10a9aca27b834bc73884c479941042d22a2b22324cd3 +2 -0
  205. package/tmp/.cache/luca-disk-cache/index-v5/9f/0f/8b1f915ee64cfff7667dd96acd7a5ac0a96aa91a346e19cefd45909a9c9c +2 -0
  206. package/docs/apis/features/node/launcher-app-command-listener.md +0 -145
  207. package/docs/examples/launcher-app-command-listener.md +0 -120
  208. package/docs/tasks/web-container-helper-discovery.md +0 -71
  209. package/docs/todos.md +0 -1
  210. package/scripts/test-command-listener.ts +0 -123
  211. package/src/node/features/launcher-app-command-listener.ts +0 -389
@@ -13,6 +13,14 @@ export function toCamelCase(str: string): string {
13
13
  return pascal.charAt(0).toLowerCase() + pascal.slice(1)
14
14
  }
15
15
 
16
+ /** Convert a string to kebab-case */
17
+ export function toKebabCase(str: string): string {
18
+ return str
19
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
20
+ .replace(/[_\s]+/g, '-')
21
+ .toLowerCase()
22
+ }
23
+
16
24
  /** Apply mustache-style template variables to scaffold code */
17
25
  export function applyTemplate(template: string, vars: Record<string, string>): string {
18
26
  let result = template
@@ -30,6 +38,7 @@ export function generateScaffold(type: string, name: string, description?: strin
30
38
  const vars = {
31
39
  PascalName: toPascalCase(name),
32
40
  camelName: toCamelCase(name),
41
+ kebabName: toKebabCase(name),
33
42
  description: description || `A ${type} that does something useful`,
34
43
  }
35
44
 
@@ -154,14 +154,45 @@ export const CommandStateSchema = HelperStateSchema.extend({
154
154
 
155
155
  export const CommandOptionsSchema = HelperOptionsSchema.extend({
156
156
  _: z.array(z.string()).default([]).describe('Positional arguments from minimist'),
157
+ dispatchSource: z.enum(['cli', 'headless', 'mcp', 'rpc']).default('cli').describe('How this command was invoked — controls arg normalization and output capture'),
157
158
  }).describe('Base command options parsed from argv')
158
159
 
160
+ export type DispatchSource = 'cli' | 'headless' | 'mcp' | 'rpc'
161
+
162
+ export interface CommandRunResult {
163
+ exitCode: number
164
+ stdout: string
165
+ stderr: string
166
+ }
167
+
159
168
  export const CommandEventsSchema = HelperEventsSchema.extend({
160
169
  started: z.tuple([]).describe('Emitted when command execution begins'),
161
170
  completed: z.tuple([z.number().describe('Exit code')]).describe('Emitted when command execution finishes'),
162
171
  failed: z.tuple([z.any().describe('The error')]).describe('Emitted when command execution fails'),
163
172
  }).describe('Base command events')
164
173
 
174
+ // Selector schemas
175
+ export const SelectorStateSchema = HelperStateSchema.extend({
176
+ running: z.boolean().default(false).describe('Whether the selector is currently running'),
177
+ lastRanAt: z.number().optional().describe('Unix timestamp of last successful run'),
178
+ }).describe('Base selector state')
179
+
180
+ export const SelectorOptionsSchema = HelperOptionsSchema.extend({
181
+ dispatchSource: z.enum(['cli', 'headless', 'mcp', 'rpc']).default('headless').describe('How this selector was invoked'),
182
+ }).describe('Base selector options')
183
+
184
+ export interface SelectorRunResult<T = any> {
185
+ data: T
186
+ cached: boolean
187
+ cacheKey: string
188
+ }
189
+
190
+ export const SelectorEventsSchema = HelperEventsSchema.extend({
191
+ started: z.tuple([]).describe('Emitted when selector execution begins'),
192
+ completed: z.tuple([z.any().describe('The result data')]).describe('Emitted when selector execution finishes'),
193
+ failed: z.tuple([z.any().describe('The error')]).describe('Emitted when selector execution fails'),
194
+ }).describe('Base selector events')
195
+
165
196
  // Endpoint schemas
166
197
  export const EndpointStateSchema = HelperStateSchema.extend({
167
198
  mounted: z.boolean().default(false).describe('Whether the endpoint is mounted on a server'),
@@ -230,12 +261,22 @@ export function describeEventsSchema(
230
261
  // The event value is a tuple schema — its items describe positional args
231
262
  const items = eventProp?.prefixItems || eventProp?.items
232
263
  if (Array.isArray(items)) {
233
- items.forEach((item: any, index: number) => {
234
- args[`arg${index}`] = {
235
- type: item.type || 'any',
236
- description: item.description || ''
264
+ if (items.length === 1 && items[0].type === 'object' && items[0].properties) {
265
+ // Single object payload — expand its properties as named arguments
266
+ for (const [propName, propSchema] of Object.entries(items[0].properties) as [string, any][]) {
267
+ args[propName] = {
268
+ type: propSchema.enum ? propSchema.enum.map((v: any) => `'${v}'`).join(' | ') : (propSchema.type || 'any'),
269
+ description: propSchema.description || ''
270
+ }
237
271
  }
238
- })
272
+ } else {
273
+ items.forEach((item: any, index: number) => {
274
+ args[`arg${index}`] = {
275
+ type: item.type || 'any',
276
+ description: item.description || ''
277
+ }
278
+ })
279
+ }
239
280
  }
240
281
 
241
282
  result[eventName] = {
@@ -0,0 +1,282 @@
1
+ import { Helper } from './helper.js'
2
+ import type { Container, ContainerContext } from './container.js'
3
+ import { Registry } from './registry.js'
4
+ import { SelectorStateSchema, SelectorOptionsSchema, SelectorEventsSchema, type SelectorRunResult } from './schemas/base.js'
5
+ import { z } from 'zod'
6
+ import { join } from 'path'
7
+ import { graftModule, isNativeHelperClass } from './graft.js'
8
+
9
+ export type { SelectorRunResult }
10
+
11
+ export type SelectorState = z.infer<typeof SelectorStateSchema>
12
+ export type SelectorOptions = z.infer<typeof SelectorOptionsSchema>
13
+
14
+ export interface AvailableSelectors {}
15
+
16
+ export type SelectorFactory = <T extends keyof AvailableSelectors>(
17
+ key: T,
18
+ options?: ConstructorParameters<AvailableSelectors[T]>[0]
19
+ ) => NonNullable<InstanceType<AvailableSelectors[T]>>
20
+
21
+ export interface SelectorsInterface {
22
+ selectors: SelectorsRegistry
23
+ select: SelectorFactory
24
+ }
25
+
26
+ /**
27
+ * Type helper for module-augmentation of AvailableSelectors when using the
28
+ * module-based pattern.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * declare module '@soederpop/luca' {
33
+ * interface AvailableSelectors {
34
+ * packageInfo: SimpleSelector<typeof argsSchema>
35
+ * }
36
+ * }
37
+ * ```
38
+ */
39
+ export type SimpleSelector<Schema extends z.ZodType = z.ZodType> = typeof Selector & {
40
+ argsSchema: Schema
41
+ }
42
+
43
+ /**
44
+ * A Selector is a helper that returns data. Where Commands perform actions,
45
+ * Selectors query and return structured results with built-in caching.
46
+ *
47
+ * Module authors export a `run(args, context)` function that returns data.
48
+ * The `select()` dispatch method wraps `run()` with cache check/store and
49
+ * returns `{ data, cached, cacheKey }`.
50
+ *
51
+ * Caching is on by default and keyed by `hashObject({ selectorName, args, gitSha })`.
52
+ * Export a `cacheKey(args, context)` function to customize, or set `cacheable = false`
53
+ * to disable.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * // selectors/package-info.ts
58
+ * export const description = 'Returns parsed package.json data'
59
+ * export const argsSchema = z.object({ field: z.string().optional() })
60
+ *
61
+ * export function cacheKey(args, context) {
62
+ * return context.container.git.sha
63
+ * }
64
+ *
65
+ * export async function run(args, context) {
66
+ * const manifest = context.container.manifest
67
+ * return args.field ? manifest[args.field] : manifest
68
+ * }
69
+ * ```
70
+ */
71
+ export class Selector<
72
+ T extends SelectorState = SelectorState,
73
+ K extends SelectorOptions = SelectorOptions
74
+ > extends Helper<T, K> {
75
+ static override shortcut = 'selectors.base'
76
+ static override description = 'Base selector'
77
+ static override stateSchema = SelectorStateSchema
78
+ static override optionsSchema = SelectorOptionsSchema
79
+ static override eventsSchema = SelectorEventsSchema
80
+
81
+ static selectorDescription: string = ''
82
+ static argsSchema: z.ZodType = SelectorOptionsSchema
83
+ static cacheable: boolean = true
84
+
85
+ /** Self-register a Selector subclass from a static initialization block. */
86
+ static register: (SubClass: typeof Selector, id?: string) => typeof Selector
87
+
88
+ override get initialState(): T {
89
+ return ({ running: false } as unknown) as T
90
+ }
91
+
92
+ /**
93
+ * The user-defined selector payload. Override this in module-based selectors
94
+ * by exporting a `run` function.
95
+ *
96
+ * Receives validated args and the container context. Must return data.
97
+ */
98
+ async run(_args: any, _context: ContainerContext): Promise<any> {
99
+ // override via grafted module export
100
+ }
101
+
102
+ /**
103
+ * Compute the cache key for a given set of args.
104
+ * Override by exporting a `cacheKey(args, context)` function in the module.
105
+ *
106
+ * Default: hashObject({ selectorName, args, gitSha })
107
+ */
108
+ resolveCacheKey(args: any, _context: ContainerContext): string {
109
+ const name = (this.constructor as typeof Selector).shortcut || 'selector'
110
+ const gitSha = (this.container as any).git?.currentCommitSha ?? 'unknown'
111
+ return (this.container as any).utils.hashObject({ selectorName: name, args, gitSha })
112
+ }
113
+
114
+ /**
115
+ * The public dispatch method. Checks cache, calls run(), stores result.
116
+ *
117
+ * @returns `{ data, cached, cacheKey }` — the result and cache metadata
118
+ */
119
+ async select(args?: Record<string, any>): Promise<SelectorRunResult> {
120
+ const Cls = this.constructor as typeof Selector
121
+ const parsed = Cls.argsSchema.parse(args ?? {})
122
+ const resolvedCacheKey = this.resolveCacheKey(parsed, this.context)
123
+
124
+ // Cache check
125
+ if (Cls.cacheable) {
126
+ try {
127
+ const cache = this._getCache()
128
+ if (await cache.has(resolvedCacheKey)) {
129
+ const data = await cache.get(resolvedCacheKey, true)
130
+ return { data, cached: true, cacheKey: resolvedCacheKey }
131
+ }
132
+ } catch {
133
+ // Cache miss or unavailable — proceed to run
134
+ }
135
+ }
136
+
137
+ // Run the selector
138
+ this.state.set('running' as any, true)
139
+ this.emit('started' as any)
140
+
141
+ let data: any
142
+ try {
143
+ data = await this.run(parsed, this.context)
144
+ this.state.set('running' as any, false)
145
+ this.state.set('lastRanAt' as any, Date.now())
146
+ this.emit('completed' as any, data)
147
+ } catch (err: any) {
148
+ this.state.set('running' as any, false)
149
+ this.emit('failed' as any, err)
150
+ throw err
151
+ }
152
+
153
+ // Cache store
154
+ if (Cls.cacheable) {
155
+ try {
156
+ await this._getCache().set(resolvedCacheKey, data)
157
+ } catch {
158
+ // Cache write failure is non-fatal
159
+ }
160
+ }
161
+
162
+ return { data, cached: false, cacheKey: resolvedCacheKey }
163
+ }
164
+
165
+ /** Lazily access diskCache. */
166
+ private _getCache(): any {
167
+ return (this.container as any).feature('diskCache', { enable: true })
168
+ }
169
+
170
+ static attach(container: Container<any> & SelectorsInterface) {
171
+ container.selectors = selectors
172
+
173
+ Object.assign(container, {
174
+ select<T extends keyof AvailableSelectors>(
175
+ id: T,
176
+ options?: ConstructorParameters<AvailableSelectors[T]>[0]
177
+ ): NonNullable<InstanceType<AvailableSelectors[T]>> {
178
+ const BaseClass = selectors.lookup(id as string) as any
179
+
180
+ return container.createHelperInstance({
181
+ cache: selectorHelperCache,
182
+ type: 'selector',
183
+ id: String(id),
184
+ BaseClass,
185
+ options,
186
+ fallbackName: String(id),
187
+ }) as NonNullable<InstanceType<AvailableSelectors[T]>>
188
+ },
189
+ })
190
+
191
+ container.registerHelperType('selectors', 'select')
192
+ return container
193
+ }
194
+ }
195
+
196
+ export class SelectorsRegistry extends Registry<Selector<any>> {
197
+ override scope = 'selectors'
198
+ override baseClass = Selector as any
199
+
200
+ /**
201
+ * Discover and register selectors from a directory.
202
+ * Detection order:
203
+ * 1. Default export is a class extending Selector -> register directly
204
+ * 2. Module exports a `run` function -> graft as SimpleSelector
205
+ */
206
+ async discover(options: { directory: string }) {
207
+ const { Glob } = globalThis.Bun || (await import('bun'))
208
+ const glob = new Glob('*.ts')
209
+
210
+ for await (const file of glob.scan({ cwd: options.directory })) {
211
+ if (file === 'index.ts') continue
212
+
213
+ const name = file.replace(/\.ts$/, '')
214
+ if (this.has(name)) continue
215
+
216
+ const mod = await import(join(options.directory, file))
217
+
218
+ // 1. Class-based: default export extends Selector
219
+ if (isNativeHelperClass(mod.default, Selector)) {
220
+ const ExportedClass = mod.default
221
+ if (!ExportedClass.shortcut || ExportedClass.shortcut === 'selectors.base') {
222
+ ExportedClass.shortcut = `selectors.${name}`
223
+ }
224
+ this.register(name, ExportedClass)
225
+ continue
226
+ }
227
+
228
+ const selectorModule = mod.default || mod
229
+
230
+ // 2. Module-based with `run` export
231
+ if (typeof selectorModule.run === 'function') {
232
+ const Grafted = graftModule(Selector as any, selectorModule, name, 'selectors')
233
+ this.register(name, Grafted as any)
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ export const selectors = new SelectorsRegistry()
240
+ export const selectorHelperCache = new Map()
241
+
242
+ /**
243
+ * Self-register a Selector subclass from a static initialization block.
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * export class PackageInfoSelector extends Selector {
248
+ * static override description = 'Returns parsed package.json data'
249
+ * static { Selector.register(this, 'packageInfo') }
250
+ *
251
+ * override async run(args, context) { return context.container.manifest }
252
+ * }
253
+ * ```
254
+ */
255
+ Selector.register = function registerSelector(
256
+ SubClass: typeof Selector,
257
+ id?: string,
258
+ ) {
259
+ const registryId = id ?? (SubClass.name
260
+ ? SubClass.name[0]!.toLowerCase() + SubClass.name.slice(1).replace(/Selector$/, '')
261
+ : `selector_${Date.now()}`)
262
+
263
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'shortcut')?.value ||
264
+ (SubClass as any).shortcut === 'selectors.base') {
265
+ ;(SubClass as any).shortcut = `selectors.${registryId}`
266
+ }
267
+
268
+ selectors.register(registryId, SubClass as any)
269
+
270
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'attach')) {
271
+ ;(SubClass as any).attach = (container: any) => {
272
+ selectors.register(registryId, SubClass as any)
273
+ return container
274
+ }
275
+ }
276
+
277
+ return SubClass
278
+ }
279
+
280
+ export { graftModule, isNativeHelperClass } from './graft.js'
281
+
282
+ export default Selector
package/src/server.ts CHANGED
@@ -91,6 +91,7 @@ export class Server<T extends ServerState = ServerState, K extends ServerOptions
91
91
  return !!this.state.get('stopped')
92
92
  }
93
93
 
94
+ /** The port this server will bind to. Reads from state first (set by start() or configure()), then constructor options, then defaults to 3000. */
94
95
  get port() {
95
96
  return this.state.get('port') || this.options.port || 3000
96
97
  }
@@ -105,11 +106,21 @@ export class Server<T extends ServerState = ServerState, K extends ServerOptions
105
106
  return this
106
107
  }
107
108
 
109
+ /**
110
+ * Start the server. Runtime options override constructor options and update state
111
+ * so that `server.port` always reflects the actual listening port.
112
+ *
113
+ * @param options - Optional runtime overrides for port and host
114
+ */
108
115
  async start(options?: StartOptions) {
109
116
  if(this.isListening) {
110
117
  return this
111
118
  }
112
119
 
120
+ if (options?.port) {
121
+ this.state.set('port', options.port)
122
+ }
123
+
113
124
  this.state.set('listening', true)
114
125
 
115
126
  return this
@@ -4,7 +4,7 @@ import cors from 'cors'
4
4
  import { z } from 'zod'
5
5
  import { ServerStateSchema, ServerOptionsSchema } from '../schemas/base.js'
6
6
  import { type StartOptions, Server, type ServerState } from '../server.js'
7
- import { Endpoint, type EndpointModule } from '../endpoint.js'
7
+ import { Endpoint, type EndpointModule, warnUnknownExports } from '../endpoint.js'
8
8
 
9
9
  declare module '../server' {
10
10
  interface AvailableServers {
@@ -88,26 +88,34 @@ export class ExpressServer<T extends ServerState = ServerState, K extends Expres
88
88
 
89
89
  // @ts-ignore-next-line
90
90
  const server : Server = this
91
- this.hooks.create(app, server)
92
-
93
91
  this._app = this.hooks.create(app, server) || app
94
92
 
95
93
  return app
96
94
  }
97
95
 
96
+ /**
97
+ * Start the Express HTTP server. A runtime `port` overrides the constructor
98
+ * option and is written to state so `server.port` always reflects reality.
99
+ *
100
+ * @param options - Optional runtime overrides for port and host
101
+ */
98
102
  override async start(options?: StartOptions) {
99
103
  if (this.isListening) {
100
104
  return this
101
105
  }
102
106
 
103
- options = {
104
- port: this.options.port || 3000,
105
- host: this.options.host || '0.0.0.0',
106
- ...options || {}
107
+ // Apply runtime port to state so this.port reflects the override
108
+ if (options?.port) {
109
+ this.state.set('port', options.port)
110
+ }
111
+
112
+ const startOptions = {
113
+ port: this.port,
114
+ host: options?.host || this.options.host || '0.0.0.0',
107
115
  }
108
116
 
109
117
  // @ts-ignore-next-line
110
- await this.hooks.beforeStart(options, this)
118
+ await this.hooks.beforeStart(startOptions, this)
111
119
 
112
120
  // SPA history fallback: serve index.html for unmatched GET routes
113
121
  if (this.options.historyFallback && this.options.static) {
@@ -118,12 +126,12 @@ export class ExpressServer<T extends ServerState = ServerState, K extends Expres
118
126
  }
119
127
 
120
128
  await new Promise((res) => {
121
- this._listener = this.app.listen(options?.port!, options?.host!, () => {
129
+ this._listener = this.app.listen(startOptions.port, startOptions.host, () => {
122
130
  this.state.set('listening', true)
123
131
  res(null)
124
132
  })
125
133
  })
126
-
134
+
127
135
  return this
128
136
  }
129
137
 
@@ -169,17 +177,24 @@ export class ExpressServer<T extends ServerState = ServerState, K extends Expres
169
177
  }
170
178
 
171
179
  async useEndpoints(dir: string): Promise<this> {
172
- const glob = new Bun.Glob('**/*.ts')
180
+ const { Glob } = globalThis.Bun || (await import('bun'))
181
+ const glob = new Glob('**/*.ts')
182
+
183
+ // Use the helpers feature's VM-aware loader so endpoints can resolve
184
+ // packages like zod and @soederpop/luca even from the compiled binary
185
+ const helpers = this.container.feature('helpers') as any
173
186
 
174
187
  for await (const file of glob.scan({ cwd: dir, absolute: true })) {
175
188
  try {
176
- const mod = await import(file)
189
+ const mod = await helpers.loadModuleExports(file)
177
190
  const endpointModule: EndpointModule = mod.default || mod
178
191
 
179
192
  if (!endpointModule.path) {
180
193
  continue
181
194
  }
182
195
 
196
+ warnUnknownExports(mod, file)
197
+
183
198
  const endpoint = new Endpoint(
184
199
  { path: endpointModule.path, filePath: file },
185
200
  this.container.context
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { ServerStateSchema, ServerOptionsSchema } from '../schemas/base.js'
2
+ import { ServerStateSchema, ServerOptionsSchema, ServerEventsSchema } from '../schemas/base.js'
3
3
  import { type StartOptions, Server, type ServerState } from '../server.js';
4
4
  import { WebSocketServer as BaseServer } from 'ws'
5
5
 
@@ -10,16 +10,24 @@ declare module '../server' {
10
10
  }
11
11
 
12
12
  export const SocketServerOptionsSchema = ServerOptionsSchema.extend({
13
- json: z.boolean().optional().describe('Whether to automatically JSON parse/stringify messages'),
13
+ json: z.boolean().optional().describe('When enabled, incoming messages are automatically JSON-parsed before emitting the message event, and outgoing send/broadcast calls JSON-stringify the payload'),
14
14
  })
15
15
  export type SocketServerOptions = z.infer<typeof SocketServerOptionsSchema>
16
16
 
17
+ export const SocketServerEventsSchema = ServerEventsSchema.extend({
18
+ connection: z.tuple([z.any().describe('The raw WebSocket client instance from the ws library')]).describe('Fires when a new client connects'),
19
+ message: z.tuple([z.any().describe('The message data (JSON-parsed object when json option is enabled, raw Buffer/string otherwise)'), z.any().describe('The WebSocket client that sent the message — use with server.send(ws, data) to reply')]).describe('Fires when a message is received from a client. Handler signature: (data, ws)'),
20
+ }).describe('WebSocket server events')
21
+
17
22
  /**
18
23
  * WebSocket server built on the `ws` library with optional JSON message framing.
19
24
  *
20
25
  * Manages WebSocket connections, tracks connected clients, and bridges
21
- * messages to Luca's event bus. When `json` mode is enabled, messages
22
- * are automatically parsed and stringified.
26
+ * messages to Luca's event bus. When `json` mode is enabled, incoming
27
+ * messages are automatically JSON-parsed (with `.toString()` for Buffer data)
28
+ * and outgoing messages via `send()` / `broadcast()` are JSON-stringified.
29
+ * When `json` mode is disabled, raw message data is emitted as-is and
30
+ * `send()` / `broadcast()` still JSON-stringify for safety.
23
31
  *
24
32
  * @extends Server
25
33
  *
@@ -28,7 +36,7 @@ export type SocketServerOptions = z.infer<typeof SocketServerOptionsSchema>
28
36
  * const ws = container.server('websocket', { json: true })
29
37
  * await ws.start({ port: 8080 })
30
38
  *
31
- * ws.on('message', (client, data) => {
39
+ * ws.on('message', (data, client) => {
32
40
  * console.log('Received:', data)
33
41
  * ws.broadcast({ echo: data })
34
42
  * })
@@ -38,6 +46,7 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
38
46
  static override shortcut = 'servers.websocket' as const
39
47
  static override stateSchema = ServerStateSchema
40
48
  static override optionsSchema = SocketServerOptionsSchema
49
+ static override eventsSchema = SocketServerEventsSchema
41
50
 
42
51
  static { Server.register(this, 'websocket') }
43
52
 
@@ -68,19 +77,43 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
68
77
  return this
69
78
  }
70
79
 
80
+ /**
81
+ * Start the WebSocket server. A runtime `port` overrides the constructor
82
+ * option and is written to state before the underlying `ws.Server` is created,
83
+ * so the server binds to the correct port.
84
+ *
85
+ * @param options - Optional runtime overrides for port and host
86
+ */
71
87
  override async start(options?: StartOptions) {
72
- if(!this.isConfigured) {
88
+ if (this.isListening) {
89
+ return this
90
+ }
91
+
92
+ // Apply runtime port to state before configure/wss touches it
93
+ if (options?.port) {
94
+ this.state.set('port', options.port)
95
+ // Reset cached wss so it rebinds to the new port
96
+ this._wss = undefined
97
+ }
98
+
99
+ if(!this.isConfigured || options?.port) {
73
100
  await this.configure()
74
101
  }
75
-
102
+
76
103
  const { wss } = this
77
104
 
78
105
  wss.on('connection', (ws) => {
79
106
  this.connections.add(ws)
80
107
  this.emit('connection', ws)
81
-
82
- ws.on('message', (data) => {
83
- this.emit('message', data, ws)
108
+
109
+ ws.on('message', (raw) => {
110
+ let data: any = raw
111
+ if (this.options.json) {
112
+ try {
113
+ data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
114
+ } catch {}
115
+ }
116
+ this.emit('message', data, ws)
84
117
  })
85
118
  })
86
119
 
@@ -127,8 +160,9 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
127
160
  return this
128
161
  }
129
162
 
163
+ /** The port this server will bind to. Defaults to 8081 if not set via constructor options or start(). */
130
164
  override get port() {
131
- return this.state.get('port') || this.options.port || 8081
165
+ return this.state.get('port') || this.options.port || 8081
132
166
  }
133
167
  }
134
168
 
@@ -1,5 +1,7 @@
1
1
  import Websocket from 'isomorphic-ws'
2
- import { Client, WebSocketClient, type WebSocketClientState, type WebSocketClientOptions } from '../../client'
2
+ import { Client } from '../../client'
3
+ import { WebSocketClient, type WebSocketClientState, type WebSocketClientOptions } from '../../clients/websocket'
4
+ import { WebSocketClientEventsSchema } from '../../schemas/base.js'
3
5
 
4
6
  /**
5
7
  * Web-specific WebSocket client implementation using isomorphic-ws.
@@ -11,6 +13,7 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
11
13
  declare ws: Websocket | WebSocket
12
14
 
13
15
  static override shortcut = 'clients.websocket' as const
16
+ static override eventsSchema = WebSocketClientEventsSchema
14
17
 
15
18
  static { Client.register(this, 'websocket') }
16
19
 
@@ -1,6 +1,7 @@
1
1
  export * from '../container.js'
2
2
  import { Container } from '../container.js'
3
- import { Client, RestClient } from '../client.js'
3
+ import { Client } from '../client.js'
4
+ import { RestClient } from '../clients/rest.js'
4
5
  import { SocketClient } from './clients/socket.js'
5
6
  import type { AvailableFeatures } from '../feature.js'
6
7
  import type { ContainerState, ContainerArgv } from '../container.js'
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
3
  import { Feature } from "../feature.js";
4
4
  import type { ContainerContext } from "../container.js";
5
5
 
@@ -9,6 +9,11 @@ export const NetworkStateSchema = FeatureStateSchema.extend({
9
9
 
10
10
  export const NetworkOptionsSchema = FeatureOptionsSchema.extend({})
11
11
 
12
+ export const NetworkEventsSchema = FeatureEventsSchema.extend({
13
+ online: z.tuple([]).describe('Fires when the browser regains network connectivity'),
14
+ offline: z.tuple([]).describe('Fires when the browser loses network connectivity'),
15
+ }).describe('Network events')
16
+
12
17
  export type NetworkState = z.infer<typeof NetworkStateSchema>
13
18
  export type NetworkOptions = z.infer<typeof NetworkOptionsSchema>
14
19
 
@@ -37,6 +42,7 @@ export class Network<
37
42
  > extends Feature<T, K> {
38
43
  static override stateSchema = NetworkStateSchema
39
44
  static override optionsSchema = NetworkOptionsSchema
45
+ static override eventsSchema = NetworkEventsSchema
40
46
  static override shortcut = "features.network" as const
41
47
 
42
48
  static { Feature.register(this as any, 'network') }