@soederpop/luca 0.0.5 → 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
@@ -4,11 +4,15 @@ import { Feature } from '../feature.js'
4
4
  import { Feature as UniversalFeature } from '../../feature.js'
5
5
  import { Client, clients } from '../../client.js'
6
6
  import { Server, servers } from '../../server.js'
7
- import { commands } from '../../command.js'
7
+ import { Command, commands } from '../../command.js'
8
+ import { graftModule, isNativeHelperClass } from '../../graft.js'
8
9
  import { endpoints } from '../../endpoint.js'
10
+ import { Selector, selectors } from '../../selector.js'
9
11
  import type { Registry } from '../../registry.js'
10
12
  import type { FileManager } from './file-manager.js'
13
+ import type { VM } from './vm.js'
11
14
  import { resolve, parse } from 'path'
15
+ import { existsSync } from 'fs'
12
16
 
13
17
  export const HelpersStateSchema = FeatureStateSchema.extend({
14
18
  discovered: z.record(z.string(), z.boolean()).default({}).describe('Which registry types have been discovered'),
@@ -35,7 +39,7 @@ export const HelpersEventsSchema = FeatureEventsSchema.extend({
35
39
  ]).describe('Emitted when a single helper is registered'),
36
40
  })
37
41
 
38
- type RegistryType = 'features' | 'clients' | 'servers' | 'commands' | 'endpoints'
42
+ type RegistryType = 'features' | 'clients' | 'servers' | 'commands' | 'endpoints' | 'selectors'
39
43
 
40
44
  const CLASS_BASED: RegistryType[] = ['features', 'clients', 'servers']
41
45
 
@@ -43,11 +47,11 @@ const CLASS_BASED: RegistryType[] = ['features', 'clients', 'servers']
43
47
  * The Helpers feature is a unified gateway for discovering and registering
44
48
  * project-level helpers from conventional folder locations.
45
49
  *
46
- * It scans known folder names (features/, clients/, servers/, commands/, endpoints/)
50
+ * It scans known folder names (features/, clients/, servers/, commands/, endpoints/, selectors/)
47
51
  * and handles registration differently based on the helper type:
48
52
  *
49
53
  * - Class-based (features, clients, servers): Dynamic import, validate subclass, register
50
- * - Config-based (commands, endpoints): Delegate to existing discovery mechanisms
54
+ * - Config-based (commands, endpoints, selectors): Delegate to existing discovery mechanisms
51
55
  *
52
56
  * @example
53
57
  * ```typescript
@@ -71,6 +75,13 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
71
75
  static override eventsSchema = HelpersEventsSchema
72
76
  static { Feature.register(this, 'helpers') }
73
77
 
78
+ /** In-flight or completed discovery promises, keyed by registry type */
79
+ private _discoveryPromises: Map<string, Promise<string[]>> = new Map()
80
+ /** Cached results from completed discoveries */
81
+ private _discoveryResults: Map<string, string[]> = new Map()
82
+ /** In-flight or completed discoverAll promise */
83
+ private _discoverAllPromise: Promise<Record<string, string[]>> | null = null
84
+
74
85
  /**
75
86
  * Returns a mapping from registry type name to its registry singleton, base class, and conventional folder candidates.
76
87
  */
@@ -81,6 +92,7 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
81
92
  servers: { registry: servers, baseClass: Server, folders: ['servers'] },
82
93
  commands: { registry: commands, baseClass: null, folders: ['commands'] },
83
94
  endpoints: { registry: endpoints, baseClass: null, folders: ['endpoints'] },
95
+ selectors: { registry: selectors, baseClass: null, folders: ['selectors'] },
84
96
  }
85
97
  }
86
98
 
@@ -89,6 +101,118 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
89
101
  return this.options.rootDir || this.container.cwd
90
102
  }
91
103
 
104
+ /**
105
+ * Whether to use native `import()` for loading project helpers.
106
+ * True only if `@soederpop/luca` is actually resolvable in `node_modules`.
107
+ * Warns when `node_modules` exists but the package is missing.
108
+ */
109
+ get useNativeImport(): boolean {
110
+ const hasNodeModules = existsSync(resolve(this.rootDir, 'node_modules'))
111
+ const hasLuca = hasNodeModules && existsSync(resolve(this.rootDir, 'node_modules', '@soederpop', 'luca'))
112
+
113
+ if (hasNodeModules && !hasLuca && !this._warnedNativeImport) {
114
+ this._warnedNativeImport = true
115
+ console.warn(
116
+ `Helpers: node_modules exists but @soederpop/luca wasn't found. ` +
117
+ `Did you forget to \`bun install\` or add @soederpop/luca as a dependency? ` +
118
+ `Using the VM virtual module system instead until this is resolved.`
119
+ )
120
+ }
121
+
122
+ return hasLuca
123
+ }
124
+
125
+ /** Prevent repeated warnings about missing @soederpop/luca */
126
+ private _warnedNativeImport = false
127
+
128
+ /** Track whether we've seeded the VM with virtual modules */
129
+ private _vmSeeded = false
130
+
131
+ /**
132
+ * Seeds the VM feature with virtual modules so that project-level files
133
+ * can `import` / `require('@soederpop/luca')`, `zod`, etc. without
134
+ * needing them in `node_modules`.
135
+ *
136
+ * Called automatically when `useNativeImport` is false.
137
+ * Can also be called externally (e.g. from the CLI) to pre-seed before discovery.
138
+ */
139
+ seedVirtualModules(): void {
140
+ if (this._vmSeeded) return
141
+ this._vmSeeded = true
142
+
143
+ const vm = this.container.feature('vm') as unknown as VM
144
+
145
+ // Provide the full @soederpop/luca barrel — everything node.ts exports
146
+ // We build the exports object from the already-loaded modules in memory
147
+ const lucaExports: Record<string, any> = {
148
+ // Core classes
149
+ Feature: UniversalFeature,
150
+ Container: this.container.constructor,
151
+ Helper: Object.getPrototypeOf(UniversalFeature.prototype).constructor,
152
+ Client,
153
+ Server,
154
+ Command,
155
+ Registry: Object.getPrototypeOf(this.container.features).constructor,
156
+
157
+ // Utilities
158
+ graftModule,
159
+ isNativeHelperClass,
160
+
161
+ // Registries
162
+ features: this.container.features,
163
+ clients,
164
+ servers,
165
+ commands,
166
+ endpoints,
167
+ selectors,
168
+
169
+ // Registry classes
170
+ ClientsRegistry: clients.constructor,
171
+ CommandsRegistry: commands.constructor,
172
+ EndpointsRegistry: endpoints.constructor,
173
+ ServersRegistry: servers.constructor,
174
+ SelectorsRegistry: selectors.constructor,
175
+ FeaturesRegistry: this.container.features.constructor,
176
+
177
+ // Helper subclasses
178
+ Selector,
179
+
180
+ // The singleton container
181
+ default: this.container,
182
+
183
+ // Convenient feature instances
184
+ fs: this.container.feature('fs'),
185
+ ui: this.container.feature('ui'),
186
+ vm,
187
+ proc: this.container.feature('proc'),
188
+
189
+ // Zod re-export
190
+ z,
191
+ }
192
+
193
+ // Schemas
194
+ const schemasModule = { CommandOptionsSchema: commands.baseClass?.optionsSchema || z.object({}) }
195
+ try {
196
+ // Pull all base schemas from the already-loaded schemas/base module
197
+ const baseSchemas = require('../../schemas/base.js')
198
+ Object.assign(lucaExports, baseSchemas)
199
+ Object.assign(schemasModule, baseSchemas)
200
+ } catch {
201
+ // Fallback: provide the essentials
202
+ lucaExports.FeatureStateSchema = FeatureStateSchema
203
+ lucaExports.FeatureOptionsSchema = FeatureOptionsSchema
204
+ lucaExports.FeatureEventsSchema = FeatureEventsSchema
205
+ schemasModule.FeatureStateSchema = FeatureStateSchema
206
+ schemasModule.FeatureOptionsSchema = FeatureOptionsSchema
207
+ schemasModule.FeatureEventsSchema = FeatureEventsSchema
208
+ }
209
+
210
+ vm.defineModule('@soederpop/luca', lucaExports)
211
+ vm.defineModule('@soederpop/luca/schemas', schemasModule)
212
+ vm.defineModule('@soederpop/luca/node', lucaExports)
213
+ vm.defineModule('zod', { z, default: { z } })
214
+ }
215
+
92
216
  /**
93
217
  * Returns a unified view of all available helpers across all registries.
94
218
  * Each key is a registry type, each value is the list of helper names in that registry.
@@ -144,11 +268,9 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
144
268
  /**
145
269
  * Discover and register project-level helpers of the given type.
146
270
  *
147
- * For class-based types (features, clients, servers), scans the matching
148
- * directory for .ts files, dynamically imports each, validates the default
149
- * export is a subclass of the registry's base class, and registers it.
150
- *
151
- * For config-based types (commands, endpoints), delegates to existing discovery mechanisms.
271
+ * Idempotent: the first caller triggers the actual scan. Subsequent callers
272
+ * receive the cached results. If discovery is in-flight, callers await the
273
+ * same promise no duplicate work.
152
274
  *
153
275
  * @param type - Which type of helpers to discover
154
276
  * @param options - Optional overrides
@@ -162,16 +284,39 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
162
284
  * ```
163
285
  */
164
286
  async discover(type: RegistryType, options: { directory?: string } = {}): Promise<string[]> {
165
- const discovered = this.state.get('discovered') || {}
287
+ // Key by type + resolved directory so that different directories
288
+ // (e.g. project commands/ vs ~/.luca/commands/) are discovered independently
289
+ // while concurrent calls to the same directory coalesce on one promise.
290
+ const dir = options.directory || this.resolveFolderPath(type)
291
+ const cacheKey = dir ? `${type}:${dir}` : type
166
292
 
167
- if (discovered[type]) {
168
- return []
293
+ // Return cached results if already completed
294
+ if (this._discoveryResults.has(cacheKey)) {
295
+ return this._discoveryResults.get(cacheKey)!
296
+ }
297
+
298
+ // If in-flight, await the same promise
299
+ if (this._discoveryPromises.has(cacheKey)) {
300
+ return this._discoveryPromises.get(cacheKey)!
169
301
  }
170
302
 
303
+ // First caller — start the work and store the promise
304
+ const promise = this._doDiscover(type, { directory: dir || undefined })
305
+ this._discoveryPromises.set(cacheKey, promise)
306
+
307
+ const names = await promise
308
+
309
+ // Cache the final results
310
+ this._discoveryResults.set(cacheKey, names)
311
+
312
+ return names
313
+ }
314
+
315
+ /** Internal: performs the actual discovery work for a single type. */
316
+ private async _doDiscover(type: RegistryType, options: { directory?: string } = {}): Promise<string[]> {
171
317
  const dir = options.directory || this.resolveFolderPath(type)
172
318
 
173
319
  if (!dir) {
174
- this.state.set('discovered', { ...discovered, [type]: true })
175
320
  return []
176
321
  }
177
322
 
@@ -183,7 +328,9 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
183
328
  names = await this.discoverConfigBased(type, dir)
184
329
  }
185
330
 
186
- this.state.set('discovered', { ...this.state.get('discovered'), [type]: true })
331
+ // Update state for observability
332
+ const discovered = this.state.get('discovered') || {}
333
+ this.state.set('discovered', { ...discovered, [type]: true })
187
334
 
188
335
  const existing = this.state.get('registered') || []
189
336
  this.state.set('registered', [...existing, ...names.map(n => `${type}.${n}`)])
@@ -196,6 +343,9 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
196
343
  /**
197
344
  * Discover all helper types from their conventional folder locations.
198
345
  *
346
+ * Idempotent: safe to call from multiple places (luca.cli.ts, commands, etc.).
347
+ * The first caller triggers discovery; all others receive the same results.
348
+ *
199
349
  * @returns Map of registry type to discovered helper names
200
350
  *
201
351
  * @example
@@ -205,9 +355,19 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
205
355
  * ```
206
356
  */
207
357
  async discoverAll(): Promise<Record<string, string[]>> {
358
+ if (this._discoverAllPromise) {
359
+ return this._discoverAllPromise
360
+ }
361
+
362
+ this._discoverAllPromise = this._doDiscoverAll()
363
+ return this._discoverAllPromise
364
+ }
365
+
366
+ /** Internal: performs the actual discoverAll work. */
367
+ private async _doDiscoverAll(): Promise<Record<string, string[]>> {
208
368
  const results: Record<string, string[]> = {}
209
369
 
210
- for (const type of ['features', 'clients', 'servers', 'commands', 'endpoints'] as RegistryType[]) {
370
+ for (const type of ['features', 'clients', 'servers', 'commands', 'endpoints', 'selectors'] as RegistryType[]) {
211
371
  results[type] = await this.discover(type)
212
372
  }
213
373
 
@@ -243,59 +403,106 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
243
403
  return registry.describe(name)
244
404
  }
245
405
 
406
+ /**
407
+ * Load a module either via native `import()` or the VM's virtual module system.
408
+ * Uses the same `useNativeImport` check as discovery to decide the loading strategy.
409
+ *
410
+ * @param absPath - Absolute path to the module file
411
+ * @returns The module's exports
412
+ */
413
+ async loadModuleExports(absPath: string): Promise<Record<string, any>> {
414
+ if (this.useNativeImport) {
415
+ const mod = await import(absPath)
416
+ return mod
417
+ }
418
+
419
+ this.seedVirtualModules()
420
+ const vm = this.container.feature('vm') as unknown as VM
421
+ return vm.loadModule(absPath)
422
+ }
423
+
246
424
  /**
247
425
  * Discovers class-based helpers (features, clients, servers) from a directory.
248
- * Uses fileManager for fast file matching.
426
+ * Uses fileManager when available (fast in git repos), falls back to Glob.
249
427
  */
250
428
  private async discoverClassBased(type: RegistryType, dir: string): Promise<string[]> {
251
429
  const { registry, baseClass } = this.registryMap[type]
252
- const fm = await this.ensureFileManager()
253
430
  const discovered: string[] = []
254
431
 
255
432
  // Load build-time introspection data before importing helpers so that
256
433
  // interceptRegistration() can merge JSDoc descriptions from the generated file.
257
434
  const introspectionFile = resolve(dir, 'introspection.generated.ts')
258
435
  try {
259
- const { existsSync } = await import('fs')
260
436
  if (existsSync(introspectionFile)) {
261
437
  await import(introspectionFile)
262
438
  }
263
439
  } catch {}
264
440
 
265
- const tests = [`${type}/*/*.ts`, `${type}/*.ts`]
266
- const files = fm.match(tests)
441
+ // Try fileManager first (faster in git repos), fall back to Glob
442
+ let files: string[] = []
443
+ try {
444
+ const fm = await this.ensureFileManager()
445
+ // fileManager may store absolute or relative keys — use absolute patterns
446
+ const absPatterns = [`${dir}/*.ts`, `${dir}/**/*.ts`]
447
+ const relPatterns = [`${type}/*.ts`, `${type}/**/*.ts`]
448
+ const matched = fm.match([...absPatterns, ...relPatterns])
449
+ files = matched.map((f: string) => f.startsWith('/') ? f : resolve(this.rootDir, f))
450
+ } catch {}
267
451
 
268
- for (const file of files) {
269
- const absPath = resolve(this.rootDir, file)
452
+ // Fall back to Glob if fileManager found nothing
453
+ if (files.length === 0) {
454
+ const { Glob } = globalThis.Bun || (await import('bun'))
455
+ const glob = new Glob('**/*.ts')
456
+ for await (const file of glob.scan({ cwd: dir })) {
457
+ files.push(resolve(dir, file))
458
+ }
459
+ }
460
+
461
+ for (const absPath of files) {
270
462
  const { name: fileName } = parse(absPath)
271
463
 
272
464
  if (fileName.includes('.test.') || fileName.includes('.spec.')) {
273
465
  continue
274
466
  }
275
-
276
467
  try {
277
- const mod = await import(absPath)
468
+ const mod = await this.loadModuleExports(absPath)
278
469
  const ExportedClass = mod.default || mod
279
470
 
280
- if (typeof ExportedClass !== 'function') {
281
- continue
282
- }
471
+ // Class-based: default export is a subclass of the base
472
+ if (typeof ExportedClass === 'function' && isNativeHelperClass(ExportedClass, baseClass)) {
473
+ const shortcut = ExportedClass.shortcut as string | undefined
474
+ const registryName = shortcut
475
+ ? shortcut.replace(`${type}.`, '')
476
+ : this.fileNameToRegistryName(fileName)
283
477
 
284
- if (!this.isSubclassOf(ExportedClass, baseClass)) {
285
- continue
286
- }
478
+ discovered.push(registryName)
287
479
 
288
- const shortcut = ExportedClass.shortcut as string | undefined
289
- const registryName = shortcut
290
- ? shortcut.replace(`${type}.`, '')
291
- : this.fileNameToRegistryName(fileName)
292
-
293
- discovered.push(registryName)
294
-
295
- if (!registry.has(registryName)) {
296
- registry.register(registryName, ExportedClass)
297
- // this is only if they didn't export it by default
298
- this.emit('registered' as any, type, registryName, ExportedClass)
480
+ if (!registry.has(registryName)) {
481
+ registry.register(registryName, ExportedClass)
482
+ this.emit('registered' as any, type, registryName, ExportedClass)
483
+ }
484
+ } else {
485
+ // Module-based: graft exports onto a generated subclass
486
+ const moduleExports = mod.default && typeof mod.default === 'object' ? mod.default : mod
487
+ const isGraftable = (
488
+ moduleExports.description !== undefined ||
489
+ moduleExports.stateSchema !== undefined ||
490
+ moduleExports.optionsSchema !== undefined ||
491
+ typeof moduleExports.run === 'function' ||
492
+ typeof moduleExports.handler === 'function'
493
+ )
494
+
495
+ if (isGraftable) {
496
+ const registryName = this.fileNameToRegistryName(fileName)
497
+ const GraftedClass = graftModule(baseClass, moduleExports, registryName, type as any)
498
+
499
+ discovered.push(registryName)
500
+
501
+ if (!registry.has(registryName)) {
502
+ registry.register(registryName, GraftedClass as any)
503
+ this.emit('registered' as any, type, registryName, GraftedClass)
504
+ }
505
+ }
299
506
  }
300
507
 
301
508
  } catch (err: any) {
@@ -318,15 +525,137 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
318
525
  const beforeNames = new Set(registry.available)
319
526
 
320
527
  if (type === 'commands') {
321
- await commands.discover({ directory: dir })
528
+ if (this.useNativeImport) {
529
+ await commands.discover({ directory: dir })
530
+ } else {
531
+ await this.discoverCommandsViaVM(dir)
532
+ }
322
533
  } else if (type === 'endpoints') {
323
534
  await this.discoverEndpoints(dir)
535
+ } else if (type === 'selectors') {
536
+ if (this.useNativeImport) {
537
+ await selectors.discover({ directory: dir })
538
+ } else {
539
+ await this.discoverSelectorsViaVM(dir)
540
+ }
324
541
  }
325
542
 
326
543
  const afterNames = new Set(registry.available)
327
544
  return [...afterNames].filter(n => !beforeNames.has(n))
328
545
  }
329
546
 
547
+ /**
548
+ * Discovers commands using the VM's virtual module system.
549
+ * Mirrors CommandsRegistry.discover() but uses vm.loadModule() instead of import().
550
+ */
551
+ private async discoverCommandsViaVM(dir: string): Promise<void> {
552
+ this.seedVirtualModules()
553
+ const { Glob } = globalThis.Bun || (await import('bun'))
554
+ const glob = new Glob('*.ts')
555
+
556
+ for await (const file of glob.scan({ cwd: dir })) {
557
+ if (file === 'index.ts') continue
558
+
559
+ const absPath = resolve(dir, file)
560
+ const name = file.replace(/\.ts$/, '')
561
+
562
+ if (commands.has(name)) continue
563
+
564
+ try {
565
+ const mod = await this.loadModuleExports(absPath)
566
+
567
+ // 1. Class-based: default export extends Command
568
+ if (isNativeHelperClass(mod.default, Command)) {
569
+ const ExportedClass = mod.default
570
+ if (!ExportedClass.shortcut || ExportedClass.shortcut === 'commands.base') {
571
+ ExportedClass.shortcut = `commands.${name}`
572
+ }
573
+ if (!ExportedClass.commandDescription && ExportedClass.description) {
574
+ ExportedClass.commandDescription = ExportedClass.description
575
+ }
576
+ commands.register(name, ExportedClass)
577
+ continue
578
+ }
579
+
580
+ const commandModule = mod.default || mod
581
+
582
+ // 2. Module-based with `run` export (new SimpleCommand pattern)
583
+ if (typeof commandModule.run === 'function') {
584
+ const Grafted = graftModule(Command as any, commandModule, name, 'commands')
585
+ commands.register(name, Grafted as any)
586
+ continue
587
+ }
588
+
589
+ // 3. Legacy: `handler` export
590
+ if (typeof commandModule.handler === 'function') {
591
+ const Grafted = graftModule(Command as any, {
592
+ description: commandModule.description,
593
+ argsSchema: commandModule.argsSchema,
594
+ handler: commandModule.handler,
595
+ }, name, 'commands')
596
+ commands.register(name, Grafted as any)
597
+ continue
598
+ }
599
+
600
+ // 4. Plain default-exported function: export default async function name(options, context)
601
+ if (typeof mod.default === 'function' && !isNativeHelperClass(mod.default, Command)) {
602
+ const Grafted = graftModule(Command as any, {
603
+ description: mod.description || '',
604
+ argsSchema: mod.argsSchema,
605
+ positionals: mod.positionals,
606
+ handler: mod.default,
607
+ }, name, 'commands')
608
+ commands.register(name, Grafted as any)
609
+ }
610
+ } catch (err: any) {
611
+ console.warn(`Helpers gateway: failed to load command from ${absPath}: ${err.message}`)
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Discovers selectors using the VM's virtual module system.
618
+ * Mirrors discoverCommandsViaVM but uses selectors registry and Selector base class.
619
+ */
620
+ private async discoverSelectorsViaVM(dir: string): Promise<void> {
621
+ this.seedVirtualModules()
622
+ const { Glob } = globalThis.Bun || (await import('bun'))
623
+ const glob = new Glob('*.ts')
624
+
625
+ for await (const file of glob.scan({ cwd: dir })) {
626
+ if (file === 'index.ts') continue
627
+
628
+ const absPath = resolve(dir, file)
629
+ const name = file.replace(/\.ts$/, '')
630
+
631
+ if (selectors.has(name)) continue
632
+
633
+ try {
634
+ const mod = await this.loadModuleExports(absPath)
635
+
636
+ // 1. Class-based: default export extends Selector
637
+ if (isNativeHelperClass(mod.default, Selector)) {
638
+ const ExportedClass = mod.default
639
+ if (!ExportedClass.shortcut || ExportedClass.shortcut === 'selectors.base') {
640
+ ExportedClass.shortcut = `selectors.${name}`
641
+ }
642
+ selectors.register(name, ExportedClass)
643
+ continue
644
+ }
645
+
646
+ const selectorModule = mod.default || mod
647
+
648
+ // 2. Module-based with `run` export
649
+ if (typeof selectorModule.run === 'function') {
650
+ const Grafted = graftModule(Selector as any, selectorModule, name, 'selectors')
651
+ selectors.register(name, Grafted as any)
652
+ }
653
+ } catch (err: any) {
654
+ console.warn(`Helpers gateway: failed to load selector from ${absPath}: ${err.message}`)
655
+ }
656
+ }
657
+ }
658
+
330
659
  /**
331
660
  * Discovers endpoints from a directory, registering them for discoverability.
332
661
  * Actual mounting to an express server is handled separately by ExpressServer.useEndpoints().
@@ -337,7 +666,7 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
337
666
 
338
667
  for await (const file of glob.scan({ cwd: dir, absolute: true })) {
339
668
  try {
340
- const mod = await import(file)
669
+ const mod = await this.loadModuleExports(file)
341
670
  const endpointModule = mod.default || mod
342
671
 
343
672
  if (endpointModule.path && typeof endpointModule.path === 'string') {
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
  import net from 'net'
3
3
  import detectPort from 'detect-port'
4
4
  import { Feature } from '../feature.js'
5
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
5
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
6
6
 
7
7
  const MAX_CIDR_HOSTS = 65536
8
8
 
@@ -119,6 +119,27 @@ export const NetworkSnapshotSchema = z.object({
119
119
  })
120
120
  export type NetworkSnapshot = z.infer<typeof NetworkSnapshotSchema>
121
121
 
122
+ export const NetworkingEventsSchema = FeatureEventsSchema.extend({
123
+ 'host:discovered': z.tuple([DiscoverHostSchema.describe('The discovered host')]).describe('When a host is found during network scanning'),
124
+ 'port:open': z.tuple([z.object({
125
+ host: z.string().describe('Host IP address'),
126
+ port: z.number().describe('Open port number'),
127
+ service: z.string().optional().describe('Best-effort service name'),
128
+ banner: z.string().optional().describe('Banner text when captured'),
129
+ }).describe('Open port details')]).describe('When an open port is detected on a host'),
130
+ 'scan:start': z.tuple([z.object({
131
+ target: z.string().describe('Scan target identifier'),
132
+ type: z.string().describe('Scan type identifier'),
133
+ }).describe('Scan start details')]).describe('When a network scan begins'),
134
+ 'scan:complete': z.tuple([z.object({
135
+ target: z.string().describe('Scan target identifier'),
136
+ type: z.string().describe('Scan type identifier'),
137
+ duration: z.number().describe('Scan duration in milliseconds'),
138
+ hostsFound: z.number().describe('Number of hosts discovered'),
139
+ portsFound: z.number().describe('Number of open ports discovered'),
140
+ }).describe('Scan completion details')]).describe('When a network scan finishes'),
141
+ }).describe('Networking events')
142
+
122
143
  export const NetworkingStateSchema = FeatureStateSchema.extend({
123
144
  lastScan: z.object({
124
145
  timestamp: z.number().describe('Unix epoch timestamp in ms'),
@@ -204,6 +225,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
204
225
  static override shortcut = 'features.networking' as const
205
226
  static override stateSchema = NetworkingStateSchema
206
227
  static override optionsSchema = NetworkingOptionsSchema
228
+ static override eventsSchema = NetworkingEventsSchema
207
229
  static { Feature.register(this, 'networking') }
208
230
 
209
231
  override get initialState(): NetworkingState {
@@ -214,6 +236,19 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
214
236
  }
215
237
  }
216
238
 
239
+ private _binCache: Record<string, string> = {}
240
+
241
+ /** Resolve a binary path via `which`, caching the result. */
242
+ private resolveBin(name: string): string {
243
+ if (this._binCache[name]) return this._binCache[name]
244
+ try {
245
+ this._binCache[name] = this.proc.exec(`which ${name}`).trim()
246
+ } catch {
247
+ this._binCache[name] = name
248
+ }
249
+ return this._binCache[name]
250
+ }
251
+
217
252
  get proc() {
218
253
  return this.container.feature('proc')
219
254
  }
@@ -359,7 +394,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
359
394
  * Reads and parses the system ARP cache.
360
395
  */
361
396
  async getArpTable(): Promise<ArpEntry[]> {
362
- const output = await this.proc.execAndCapture('arp -a')
397
+ const output = await this.proc.execAndCapture(`${this.resolveBin('arp')} -a`)
363
398
  if (output.exitCode !== 0) {
364
399
  return []
365
400
  }
@@ -605,7 +640,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
605
640
  }
606
641
 
607
642
  private async isNmapAvailable(): Promise<boolean> {
608
- const result = await this.proc.spawnAndCapture('nmap', ['--version'])
643
+ const result = await this.proc.spawnAndCapture(this.resolveBin('nmap'), ['--version'])
609
644
  return result.exitCode === 0
610
645
  }
611
646
 
@@ -619,7 +654,7 @@ export class Networking extends Feature<NetworkingState, NetworkingOptions> {
619
654
  const startTime = Date.now()
620
655
 
621
656
  const cmdArgs = [...args, '-oG', '-', target]
622
- const result = await this.proc.spawnAndCapture('nmap', cmdArgs)
657
+ const result = await this.proc.spawnAndCapture(this.resolveBin('nmap'), cmdArgs)
623
658
 
624
659
  if (result.exitCode !== 0) {
625
660
  throw new Error(result.stderr || 'nmap scan failed')