@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
@@ -1,7 +1,7 @@
1
1
  import { setBuildTimeData, setContainerBuildTimeData } from './index.js';
2
2
 
3
3
  // Auto-generated introspection registry data
4
- // Generated at: 2026-03-12T02:48:58.250Z
4
+ // Generated at: 2026-03-19T00:28:05.997Z
5
5
 
6
6
  setBuildTimeData('features.containerLink', {
7
7
  "id": "features.containerLink",
@@ -161,12 +161,38 @@ export function getContainerBuildTimeData(className: string): Partial<ContainerI
161
161
  export function setBuildTimeData(key: string, data: HelperIntrospection) {
162
162
  const existing = __INTROSPECTION__.get(key)
163
163
 
164
+ // Merge events: build-time AST provides descriptions, runtime Zod schemas provide arguments.
165
+ // For each event, preserve runtime arguments if the build-time entry has none.
166
+ const mergedEvents: Record<string, any> = { ...(data.events || {}) }
167
+ if (existing?.events) {
168
+ for (const [eventName, existingEvent] of Object.entries(existing.events)) {
169
+ if (mergedEvents[eventName]) {
170
+ // Build-time entry exists — merge in runtime arguments and description if build-time has none
171
+ const buildArgs = mergedEvents[eventName].arguments || {}
172
+ const runtimeArgs = (existingEvent as any).arguments || {}
173
+ const buildDesc = mergedEvents[eventName].description || ''
174
+ const runtimeDesc = (existingEvent as any).description || ''
175
+ const isGenericDesc = buildDesc.startsWith('Event emitted by ')
176
+ if (Object.keys(buildArgs).length === 0 && Object.keys(runtimeArgs).length > 0) {
177
+ mergedEvents[eventName] = { ...mergedEvents[eventName], arguments: runtimeArgs }
178
+ }
179
+ if (isGenericDesc && runtimeDesc) {
180
+ mergedEvents[eventName] = { ...mergedEvents[eventName], description: runtimeDesc }
181
+ }
182
+ } else {
183
+ // Event only exists in runtime (from Zod schema) — preserve it
184
+ mergedEvents[eventName] = existingEvent
185
+ }
186
+ }
187
+ }
188
+
164
189
  __INTROSPECTION__.set(key, {
165
190
  ...data,
166
191
  // preserve runtime-derived className/state/options if registration already happened
167
192
  className: data.className || existing?.className,
168
193
  state: existing?.state || data.state || {},
169
194
  options: existing?.options || data.options || {},
195
+ events: mergedEvents,
170
196
  getters: data.getters || existing?.getters || {},
171
197
  envVars: existing?.envVars || data.envVars || [],
172
198
  examples: data.examples || existing?.examples,
@@ -6,12 +6,16 @@ import type { FeatureOptions } from "./feature";
6
6
  import { features, Feature } from "./feature";
7
7
  import type { AvailableFeatures } from "../feature";
8
8
  import { Client, type ClientsInterface } from "../client";
9
+ import "../clients/rest";
10
+ import "../clients/graph";
11
+ import "../clients/websocket";
9
12
  import { Server, type ServersInterface } from "../server";
10
13
  import "../servers/express";
11
14
  import "../servers/socket";
12
15
  import "../servers/mcp";
13
16
  import { Command, type CommandsInterface } from "../command";
14
17
  import { Endpoint, type EndpointsInterface } from "../endpoint";
18
+ import { Selector, type SelectorsInterface } from "../selector";
15
19
 
16
20
  import minimist from "minimist";
17
21
  import { omit, kebabCase, camelCase, mapKeys, castArray } from "lodash-es";
@@ -53,7 +57,6 @@ import "./features/google-sheets";
53
57
  import "./features/google-calendar";
54
58
  import "./features/google-docs";
55
59
  import "./features/window-manager";
56
- import "./features/launcher-app-command-listener";
57
60
  import "./features/nlp";
58
61
  import "./features/process-manager"
59
62
  import "./features/tts";
@@ -98,7 +101,6 @@ import type { GoogleSheets } from './features/google-sheets';
98
101
  import type { GoogleCalendar } from './features/google-calendar';
99
102
  import type { GoogleDocs } from './features/google-docs';
100
103
  import type { WindowManager } from './features/window-manager';
101
- import type { LauncherAppCommandListener } from './features/launcher-app-command-listener';
102
104
  import type { NLP } from './features/nlp';
103
105
  import type { ProcessManager } from './features/process-manager'
104
106
  import type { TTS } from './features/tts';
@@ -138,7 +140,6 @@ export {
138
140
  type GoogleCalendar,
139
141
  type GoogleDocs,
140
142
  type WindowManager,
141
- type LauncherAppCommandListener,
142
143
  type NLP,
143
144
  type ProcessManager,
144
145
  type TTS,
@@ -204,7 +205,6 @@ export interface NodeFeatures extends AvailableFeatures {
204
205
  googleCalendar: typeof GoogleCalendar;
205
206
  googleDocs: typeof GoogleDocs;
206
207
  windowManager: typeof WindowManager;
207
- launcherAppCommandListener: typeof LauncherAppCommandListener;
208
208
  nlp: typeof NLP;
209
209
  processManager: typeof ProcessManager;
210
210
  tts: typeof TTS;
@@ -214,7 +214,7 @@ export interface NodeFeatures extends AvailableFeatures {
214
214
  dns: typeof Dns;
215
215
  }
216
216
 
217
- export type ClientsAndServersInterface = ClientsInterface & ServersInterface & CommandsInterface & EndpointsInterface;
217
+ export type ClientsAndServersInterface = ClientsInterface & ServersInterface & CommandsInterface & EndpointsInterface & SelectorsInterface;
218
218
 
219
219
  export interface NodeContainer extends ClientsAndServersInterface {}
220
220
 
@@ -253,7 +253,6 @@ export class NodeContainer<
253
253
  googleCalendar?: GoogleCalendar;
254
254
  googleDocs?: GoogleDocs;
255
255
  windowManager?: WindowManager;
256
- launcherAppCommandListener?: LauncherAppCommandListener;
257
256
  nlp?: NLP;
258
257
  processManager?: ProcessManager;
259
258
  tts?: TTS;
@@ -286,7 +285,7 @@ export class NodeContainer<
286
285
  }
287
286
  });
288
287
 
289
- this.use(Client).use(Server).use(Command).use(Endpoint);
288
+ this.use(Client).use(Server).use(Command).use(Endpoint).use(Selector);
290
289
  }
291
290
 
292
291
  override get Feature() {
@@ -1,7 +1,8 @@
1
1
  import { Feature } from '../feature.js'
2
+ import * as contentbaseExports from 'contentbase'
2
3
  import { parse, Collection, extractSections, type ModelDefinition } from 'contentbase'
3
4
  import { z } from 'zod'
4
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
5
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
5
6
  import { join, dirname } from 'node:path'
6
7
  import { existsSync, readdirSync } from 'node:fs'
7
8
 
@@ -18,6 +19,10 @@ export const ContentDbOptionsSchema = FeatureOptionsSchema.extend({
18
19
  export type ContentDbState = z.infer<typeof ContentDbStateSchema>
19
20
  export type ContentDbOptions = z.infer<typeof ContentDbOptionsSchema>
20
21
 
22
+ export const ContentDbEventsSchema = FeatureEventsSchema.extend({
23
+ reloaded: z.tuple([]).describe('When the content collection is reloaded from disk'),
24
+ }).describe('ContentDb events')
25
+
21
26
  /**
22
27
  * Provides access to a Contentbase Collection for a folder of structured markdown files.
23
28
  *
@@ -37,6 +42,7 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
37
42
  static override shortcut = 'features.contentDb' as const
38
43
  static override stateSchema = ContentDbStateSchema
39
44
  static override optionsSchema = ContentDbOptionsSchema
45
+ static override eventsSchema = ContentDbEventsSchema
40
46
  static { Feature.register(this, 'contentDb') }
41
47
 
42
48
  override get initialState(): ContentDbState {
@@ -52,11 +58,52 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
52
58
  }
53
59
 
54
60
  _collection?: Collection
61
+ private _contentbaseSeeded = false
55
62
 
56
63
  /** Returns the lazily-initialized Collection instance for the configured rootPath. */
57
64
  get collection() {
58
65
  if (this._collection) return this._collection
59
- return this._collection = new Collection({ rootPath: this.options.rootPath })
66
+
67
+ const opts: any = { rootPath: this.options.rootPath }
68
+
69
+ // When contentbase isn't in node_modules (e.g. compiled luca binary),
70
+ // provide a VM-based module loader so models.ts can resolve its imports
71
+ if (!this._canNativeImportContentbase()) {
72
+ opts.moduleLoader = (filePath: string) => {
73
+ this._seedContentbaseVirtualModules()
74
+ const vm = this.container.feature('vm') as any
75
+ return vm.loadModule(filePath)
76
+ }
77
+ }
78
+
79
+ return this._collection = new Collection(opts)
80
+ }
81
+
82
+ /** Check if contentbase is resolvable via native import from the project root */
83
+ private _canNativeImportContentbase(): boolean {
84
+ const cwd = this.container.cwd
85
+ return existsSync(join(cwd, 'node_modules', 'contentbase'))
86
+ }
87
+
88
+ /** Seed the VM with virtual modules so models.ts can import from 'contentbase', 'zod', etc. */
89
+ private _seedContentbaseVirtualModules(): void {
90
+ if (this._contentbaseSeeded) return
91
+ this._contentbaseSeeded = true
92
+
93
+ const vm = this.container.feature('vm') as any
94
+
95
+ // Seed luca modules first (helpers does this for @soederpop/luca)
96
+ const helpers = this.container.feature('helpers') as any
97
+ if (helpers?.seedVirtualModules) {
98
+ helpers.seedVirtualModules()
99
+ }
100
+
101
+ // Register contentbase barrel — everything the library exports
102
+ vm.defineModule('contentbase', contentbaseExports)
103
+
104
+ // Common deps that models.ts files tend to use
105
+ try { vm.defineModule('js-yaml', require('js-yaml')) } catch {}
106
+ try { vm.defineModule('mdast-util-to-string', require('mdast-util-to-string')) } catch {}
60
107
  }
61
108
 
62
109
  /** Returns the absolute resolved path to the collection root directory. */
@@ -36,15 +36,17 @@ export class DiskCache extends Feature<FeatureState,DiskCacheOptions> {
36
36
  static override optionsSchema = DiskCacheOptionsSchema
37
37
  static { Feature.register(this, 'diskCache') }
38
38
 
39
- constructor(options: DiskCacheOptions, context: ContainerContext) {
40
- super(options, context)
41
- this._cache = this.create()
42
- this.hide('_cache')
43
- }
44
-
45
39
  /** Returns the underlying cacache instance configured with the cache directory path. */
46
40
  get cache() {
47
- return this._cache
41
+ if(this._cache) {
42
+ return this._cache
43
+ }
44
+
45
+ const cache = this.create()
46
+
47
+ Object.defineProperty(this, '_cache', { value: cache, enumerable: false })
48
+
49
+ return cache
48
50
  }
49
51
 
50
52
  /**
@@ -328,7 +330,7 @@ export class DiskCache extends Feature<FeatureState,DiskCacheOptions> {
328
330
 
329
331
  /**
330
332
  * Create a cacache instance with the specified path
331
- * @param path - Optional cache directory path (defaults to options.path or node_modules/.cache/luca-disk-cache)
333
+ * @param path - Optional cache directory path (defaults to options.path or ~/.cache/luca/disk-cache-{cwdHash})
332
334
  * @returns Configured cacache instance with all methods bound to the path
333
335
  * @example
334
336
  * ```typescript
@@ -336,7 +338,12 @@ export class DiskCache extends Feature<FeatureState,DiskCacheOptions> {
336
338
  * ```
337
339
  */
338
340
  create(path?: string) {
339
- path = path || this.options.path || this.container.paths.resolve('node_modules', '.cache', 'luca-disk-cache')
341
+ if (!path && !this.options.path) {
342
+ const cwdHash = this.container.utils.hashObject(this.container.cwd)
343
+ path = this.container.paths.resolve(process.env.HOME!, '.cache', 'luca', `disk-cache-${cwdHash}`)
344
+ } else {
345
+ path = path || this.options.path!
346
+ }
340
347
  this.container.fs.ensureFolder(path)
341
348
  const arg = (fn: (...args: any) => any) => partial(fn, path);
342
349
 
@@ -131,10 +131,23 @@ export class Dns extends Feature<DnsState, DnsOptions> {
131
131
  }
132
132
  }
133
133
 
134
+ private _resolvedDigPath: string | null = null
135
+
134
136
  get proc() {
135
137
  return this.container.feature('proc')
136
138
  }
137
139
 
140
+ /** Resolved path to the dig binary */
141
+ get digPath(): string {
142
+ if (this._resolvedDigPath) return this._resolvedDigPath
143
+ try {
144
+ this._resolvedDigPath = this.proc.exec('which dig').trim()
145
+ } catch {
146
+ this._resolvedDigPath = 'dig'
147
+ }
148
+ return this._resolvedDigPath
149
+ }
150
+
138
151
  /**
139
152
  * Checks whether the `dig` binary is available on the system.
140
153
  *
@@ -148,7 +161,7 @@ export class Dns extends Feature<DnsState, DnsOptions> {
148
161
  * ```
149
162
  */
150
163
  async isAvailable(): Promise<boolean> {
151
- const result = await this.proc.spawnAndCapture('dig', ['-v'])
164
+ const result = await this.proc.spawnAndCapture(this.digPath, ['-v'])
152
165
  // dig -v prints version to stderr and exits 0
153
166
  return result.exitCode === 0
154
167
  }
@@ -180,7 +193,7 @@ export class Dns extends Feature<DnsState, DnsOptions> {
180
193
  */
181
194
  async resolve(domain: string, type: DnsRecordType, options: QueryOptions = {}): Promise<DnsQueryResult> {
182
195
  const args = this.buildDigArgs(domain, type, options)
183
- const result = await this.proc.spawnAndCapture('dig', args)
196
+ const result = await this.proc.spawnAndCapture(this.digPath, args)
184
197
 
185
198
  if (result.exitCode !== 0) {
186
199
  throw new Error(`dig query failed: ${result.stderr || 'unknown error'}`)
@@ -446,7 +459,7 @@ export class Dns extends Feature<DnsState, DnsOptions> {
446
459
  }
447
460
  args.unshift('-x', ip)
448
461
 
449
- const result = await this.proc.spawnAndCapture('dig', args)
462
+ const result = await this.proc.spawnAndCapture(this.digPath, args)
450
463
 
451
464
  if (result.exitCode !== 0) {
452
465
  throw new Error(`dig reverse lookup failed: ${result.stderr || 'unknown error'}`)
@@ -101,10 +101,24 @@ export class Docker extends Feature<DockerState, DockerOptions> {
101
101
  /**
102
102
  * Get the proc feature for executing shell commands
103
103
  */
104
+ private _resolvedDockerPath: string | null = null
105
+
104
106
  get proc() {
105
107
  return this.container.feature('proc')
106
108
  }
107
109
 
110
+ /** Resolve the docker binary path via `which`, caching the result. Options take precedence. */
111
+ get dockerPath(): string {
112
+ if (this.options.dockerPath) return this.options.dockerPath
113
+ if (this._resolvedDockerPath) return this._resolvedDockerPath
114
+ try {
115
+ this._resolvedDockerPath = this.proc.exec('which docker').trim()
116
+ } catch {
117
+ this._resolvedDockerPath = 'docker'
118
+ }
119
+ return this._resolvedDockerPath
120
+ }
121
+
108
122
  /**
109
123
  * Check if Docker is available and working.
110
124
  *
@@ -117,8 +131,7 @@ export class Docker extends Feature<DockerState, DockerOptions> {
117
131
  */
118
132
  async checkDockerAvailability(): Promise<boolean> {
119
133
  try {
120
- const dockerPath = this.options.dockerPath || 'docker'
121
- const result = await this.proc.spawnAndCapture(dockerPath, ['--version'])
134
+ const result = await this.proc.spawnAndCapture(this.dockerPath, ['--version'])
122
135
 
123
136
  if (result.exitCode === 0) {
124
137
  this.setState({ isDockerAvailable: true, lastError: undefined })
@@ -152,8 +165,7 @@ export class Docker extends Feature<DockerState, DockerOptions> {
152
165
  }
153
166
 
154
167
  try {
155
- const dockerPath = this.options.dockerPath || 'docker'
156
- const result = await this.proc.spawnAndCapture(dockerPath, args)
168
+ const result = await this.proc.spawnAndCapture(this.dockerPath, args)
157
169
 
158
170
  if (result.exitCode !== 0) {
159
171
  this.setState({ lastError: result.stderr })
@@ -54,6 +54,26 @@ export class ESBuild extends Feature {
54
54
  ...options
55
55
  })
56
56
  }
57
+
58
+ /**
59
+ * Bundle one or more entry points, resolving imports and requires into a single output.
60
+ * Supports Node platform by default so require() and Node builtins are handled.
61
+ * Returns in-memory output files unless write is enabled in options.
62
+ * @param entryPoints - File paths to bundle from
63
+ * @param options - esbuild BuildOptions overrides
64
+ * @returns The build result with outputFiles when write is false
65
+ */
66
+ async bundle(entryPoints: string[], options?: esbuild.BuildOptions) {
67
+ return esbuild.build({
68
+ entryPoints,
69
+ bundle: true,
70
+ platform: 'node',
71
+ format: 'esm',
72
+ target: 'es2020',
73
+ write: false,
74
+ ...options
75
+ })
76
+ }
57
77
  }
58
78
 
59
79
  export default ESBuild
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod'
2
- import { FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
2
+ import { FeatureEventsSchema, FeatureStateSchema, FeatureOptionsSchema } from '../../schemas/base.js'
3
3
  import { State } from "../../state.js";
4
4
  import { Feature } from "../feature.js";
5
- import { parse, relative } from "path";
6
- import { statSync } from "fs";
5
+ import { parse, relative, join as pathJoin } from "path";
6
+ import { statSync, readFileSync, existsSync } from "fs";
7
7
  import micromatch from "micromatch";
8
8
  import { castArray } from "lodash-es";
9
9
  import chokidar from "chokidar";
@@ -37,6 +37,15 @@ export const FileManagerOptionsSchema = FeatureOptionsSchema.extend({
37
37
  exclude: z.union([z.string(), z.array(z.string())]).optional().describe('Glob patterns to exclude from file scanning'),
38
38
  })
39
39
 
40
+ export const FileManagerEventsSchema = FeatureEventsSchema.extend({
41
+ 'file:change': z.tuple([
42
+ z.object({
43
+ type: z.enum(['add', 'change', 'delete']).describe('The type of file change'),
44
+ path: z.string().describe('Absolute path to the changed file'),
45
+ }).describe('File change event payload'),
46
+ ]).describe('Emitted when a watched file is added, changed, or deleted'),
47
+ }).describe('FileManager events')
48
+
40
49
  export type FileManagerState = z.infer<typeof FileManagerStateSchema>
41
50
  export type FileManagerOptions = z.infer<typeof FileManagerOptionsSchema>
42
51
 
@@ -61,6 +70,7 @@ export class FileManager<
61
70
  static override shortcut = 'features.fileManager' as const
62
71
  static override stateSchema = FileManagerStateSchema
63
72
  static override optionsSchema = FileManagerOptionsSchema
73
+ static override eventsSchema = FileManagerEventsSchema
64
74
  static { Feature.register(this, 'fileManager') }
65
75
 
66
76
  files: State<Record<string, File>> = new State<Record<string, File>>({
@@ -135,6 +145,11 @@ export class FileManager<
135
145
  return !!this.state.get("watching");
136
146
  }
137
147
 
148
+ /** Returns the list of directories currently being watched. */
149
+ get watchedPaths(): string[] {
150
+ return this.state.get("watchedPaths") || [];
151
+ }
152
+
138
153
  /**
139
154
  * Starts the file manager and scans the files in the project.
140
155
  * @param {object} [options={}] - Options for the file manager
@@ -154,7 +169,11 @@ export class FileManager<
154
169
  }
155
170
 
156
171
  try {
157
- await this.scanFiles(options);
172
+ const loaded = await this.loadFromCache();
173
+ if (!loaded) {
174
+ await this.scanFiles(options);
175
+ await this.writeToCache();
176
+ }
158
177
  } catch (error) {
159
178
  console.error(error);
160
179
  this.state.set("failed", true);
@@ -166,6 +185,117 @@ export class FileManager<
166
185
  return this;
167
186
  }
168
187
 
188
+ /**
189
+ * Attempts to load the file index from disk cache.
190
+ * Only uses cache when in a clean git repo (sha hasn't changed, no dirty files).
191
+ * @returns true if cache was loaded successfully, false otherwise
192
+ */
193
+ /**
194
+ * Reads the current git SHA by reading .git/HEAD directly,
195
+ * avoiding the ~19ms cost of shelling out to `git rev-parse HEAD`.
196
+ */
197
+ private readGitShaFast(): string | null {
198
+ try {
199
+ const { git } = this.container;
200
+ if (!git.isRepo) return null;
201
+
202
+ const gitDir = pathJoin(git.repoRoot, '.git');
203
+ const head = readFileSync(pathJoin(gitDir, 'HEAD'), 'utf8').trim();
204
+
205
+ // Detached HEAD — already a sha
206
+ if (!head.startsWith('ref: ')) return head;
207
+
208
+ // Resolve the ref
209
+ const refPath = pathJoin(gitDir, head.slice(5));
210
+ if (existsSync(refPath)) {
211
+ return readFileSync(refPath, 'utf8').trim();
212
+ }
213
+
214
+ // Packed refs fallback
215
+ const packedRefsPath = pathJoin(gitDir, 'packed-refs');
216
+ if (existsSync(packedRefsPath)) {
217
+ const ref = head.slice(5);
218
+ const packed = readFileSync(packedRefsPath, 'utf8');
219
+ const match = packed.match(new RegExp(`^([0-9a-f]{40}) ${ref}`, 'm'));
220
+ if (match) return match[1];
221
+ }
222
+
223
+ return null;
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ private async loadFromCache(): Promise<boolean> {
230
+ try {
231
+ const sha = this.readGitShaFast();
232
+ if (!sha) return false;
233
+
234
+ const cache = this.container.feature('diskCache') as any;
235
+ const cacheKey = `file-index:${sha}`;
236
+
237
+ if (!(await cache.has(cacheKey))) return false;
238
+
239
+ const cached = await cache.get(cacheKey, true) as { dirs: Record<string, number>, files: Record<string, any> };
240
+ if (!cached?.files || !cached?.dirs) return false;
241
+
242
+ // Check if any directory mtime has changed — catches new/deleted/renamed files
243
+ for (const [dir, cachedMtimeMs] of Object.entries(cached.dirs)) {
244
+ try {
245
+ const current = statSync(dir).mtimeMs;
246
+ if (current !== cachedMtimeMs) return false;
247
+ } catch {
248
+ // Directory no longer exists
249
+ return false;
250
+ }
251
+ }
252
+
253
+ for (const [relativePath, file] of Object.entries(cached.files)) {
254
+ this.files.set(relativePath, {
255
+ ...file as File,
256
+ modifiedAt: new Date((file as any).modifiedAt),
257
+ createdAt: new Date((file as any).createdAt),
258
+ });
259
+ }
260
+
261
+ return true;
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Writes the current file index to disk cache, keyed by git sha.
269
+ * Stores directory mtimes alongside file data so the cache can be
270
+ * invalidated when files are added/removed without a new commit.
271
+ */
272
+ private async writeToCache(): Promise<void> {
273
+ try {
274
+ const sha = this.readGitShaFast();
275
+ if (!sha) return;
276
+
277
+ const cache = this.container.feature('diskCache') as any;
278
+ const cacheKey = `file-index:${sha}`;
279
+
280
+ // Collect unique directories and their mtimes
281
+ const dirs: Record<string, number> = {};
282
+ const files: Record<string, any> = {};
283
+
284
+ for (const [key, file] of this.files.entries()) {
285
+ files[key] = file;
286
+ if (!dirs[file.dirname]) {
287
+ try {
288
+ dirs[file.dirname] = statSync(file.dirname).mtimeMs;
289
+ } catch {}
290
+ }
291
+ }
292
+
293
+ await cache.set(cacheKey, { dirs, files });
294
+ } catch {
295
+ // Cache write failure is non-fatal
296
+ }
297
+ }
298
+
169
299
  /**
170
300
  * Scans the files in the project and updates the file manager state.
171
301
  * @param {object} [options={}] - Options for the file manager
@@ -252,14 +382,33 @@ export class FileManager<
252
382
  return this.watcher?.getWatched() || {};
253
383
  }
254
384
 
255
- /**
256
- * Watches the files in the project and updates the file manager state.
385
+ /**
386
+ * Watches directories for file changes. Can be called multiple times to add
387
+ * more directories to an existing watcher. Tracks all watched paths in state.
388
+ *
389
+ * When called without `paths`, watches the project's `directoryIds` (default behavior).
390
+ * When called with `paths`, watches only those specific directories/globs.
391
+ * Subsequent calls add to the existing watcher — they never replace what's already watched.
392
+ *
257
393
  * @param {object} [options={}] - Options for the file manager
394
+ * @param {string | string[]} [options.paths] - Specific directories or globs to watch. Defaults to project directoryIds.
258
395
  * @param {string | string[]} [options.exclude] - The patterns to exclude from the watch
259
- * @returns {Promise<void>} The file manager instance
396
+ * @returns {Promise<void>}
260
397
  */
261
- async watch(options: { exclude?: string | string[] } = {}) {
262
- if (this.isWatching) {
398
+ async watch(options: { paths?: string | string[]; exclude?: string | string[] } = {}) {
399
+ const pathsToWatch = castArray(options.paths || this.directoryIds.map(id => this.container.paths.resolve(id)))
400
+ .map(p => this.container.paths.resolve(p));
401
+
402
+ // If already watching, just add the new paths
403
+ if (this.isWatching && this.watcher) {
404
+ const currentPaths: string[] = this.state.get("watchedPaths") || [];
405
+ const newPaths = pathsToWatch.filter(p => !currentPaths.includes(p));
406
+
407
+ if (newPaths.length) {
408
+ this.watcher.add(newPaths);
409
+ this.state.set("watchedPaths", [...currentPaths, ...newPaths]);
410
+ }
411
+
263
412
  return;
264
413
  }
265
414
 
@@ -273,11 +422,7 @@ export class FileManager<
273
422
 
274
423
  exclude.push(...castArray(this.options.exclude!).filter((v) => v?.length));
275
424
 
276
- const { cwd } = this.container;
277
-
278
- const watcher = chokidar.watch(
279
- this.directoryIds.map(id => this.container.paths.resolve(id))
280
- , {
425
+ const watcher = chokidar.watch(pathsToWatch, {
281
426
  ignoreInitial: true,
282
427
  persistent: true,
283
428
  ignored: [
@@ -292,17 +437,17 @@ export class FileManager<
292
437
 
293
438
  watcher
294
439
  .on("add", (path) => {
440
+ this.updateFile(path);
295
441
  this.emit("file:change", {
296
442
  type: "add",
297
443
  path,
298
444
  });
299
- this.updateFile(path);
300
445
  })
301
446
  .on("change", (path) => {
302
447
  this.updateFile(path);
303
448
  this.emit("file:change", {
304
- path,
305
449
  type: "change",
450
+ path,
306
451
  });
307
452
  })
308
453
  .on("unlink", (path) => {
@@ -315,6 +460,7 @@ export class FileManager<
315
460
 
316
461
  watcher.on("ready", () => {
317
462
  this.state.set("watching", true);
463
+ this.state.set("watchedPaths", pathsToWatch);
318
464
  });
319
465
 
320
466
  this.watcher = watcher;
@@ -328,26 +474,35 @@ export class FileManager<
328
474
  if (this.watcher) {
329
475
  this.watcher.close();
330
476
  this.state.set("watching", false);
477
+ this.state.set("watchedPaths", []);
331
478
  this.watcher = null;
332
479
  }
333
480
  }
334
481
 
335
482
  async updateFile(path: string) {
336
- // Reuse the logic from the scanFiles method to update a single file
337
483
  const absolutePath = this.container.paths.resolve(path);
338
484
  const { name, ext, dir } = parse(absolutePath);
339
- const stats = statSync(absolutePath);
340
-
341
- this.files.set(path, {
342
- dirname: dir,
343
- absolutePath,
344
- relativePath: path,
345
- name,
346
- extension: ext,
347
- size: stats.size,
348
- modifiedAt: stats.mtime,
349
- createdAt: stats.birthtime,
350
- });
485
+
486
+ try {
487
+ const stats = statSync(absolutePath);
488
+ this.files.set(path, {
489
+ dirname: dir,
490
+ absolutePath,
491
+ relativePath: path,
492
+ name,
493
+ extension: ext,
494
+ size: stats.size,
495
+ modifiedAt: stats.mtime,
496
+ createdAt: stats.birthtime,
497
+ });
498
+ } catch (err: any) {
499
+ // File may have been moved or deleted by an event handler — remove from index gracefully
500
+ if (err.code === 'ENOENT') {
501
+ this.files.delete(path);
502
+ } else {
503
+ throw err;
504
+ }
505
+ }
351
506
  }
352
507
 
353
508
  async removeFile(path: string) {