@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,71 +0,0 @@
1
- ---
2
- repeatable: false
3
- ---
4
-
5
- # Implement Helper Discovery for WebContainer
6
-
7
- The NodeContainer has a `helpers` feature (`src/node/features/helpers.ts`) that provides unified discovery across all registries. The WebContainer has no equivalent. Implement helper discovery for the web container.
8
-
9
- ## Context
10
-
11
- The node `Helpers` feature provides:
12
- - `container.helpers.discover('features')` — scan conventional folders and register what it finds
13
- - `container.helpers.discoverAll()` — discover across all registry types
14
- - `container.helpers.available` — unified view of all registries
15
- - `container.helpers.lookup(type, name)` and `container.helpers.describe(type, name)`
16
-
17
- The web container has `features` and `clients` registries (Client is attached via `extension.ts`, which calls `container.use(Client)` and triggers `registerHelperType('clients', 'client')`). It has `RestClient` and `SocketClient` available. No servers, commands, or endpoints registries.
18
-
19
- ## What to Build
20
-
21
- ### 1. Create `src/web/features/helpers.ts`
22
-
23
- Port the node `Helpers` feature to work in the browser environment. Key differences from the node version:
24
-
25
- - **No filesystem scanning** — the browser can't scan directories. Instead, discovery should work via explicit registration or a manifest/config object that lists available helpers and their import paths.
26
- - **Registry scope** — cover `features` and `clients` (both already attached). The `registryMap` should reflect what the web container actually has.
27
- - **No dynamic `import()` from disk** — helper modules need to be bundled or loaded via URL. Consider accepting a map of `{ name: () => import('./my-feature.js') }` lazy loaders.
28
- - **Keep the same public API surface** — `discover()`, `discoverAll()`, `available`, `lookup()`, `describe()` should all work identically from the consumer's perspective.
29
-
30
- ### 2. Register it in `src/web/extension.ts`
31
-
32
- Add the helpers feature to the web extension so it's available as `container.feature('helpers')` / `container.helpers`.
33
-
34
- ### 3. Approach for Browser Discovery
35
-
36
- Since there's no filesystem to scan, discovery needs a different mechanism. Recommended approach:
37
-
38
- ```typescript
39
- // Option A: Manifest-based discovery
40
- const helpers = container.feature('helpers', {
41
- enable: true,
42
- manifest: {
43
- features: {
44
- myFeature: () => import('./features/my-feature.js'),
45
- }
46
- }
47
- })
48
- await helpers.discoverAll()
49
- ```
50
-
51
- This keeps the same `discover()` / `discoverAll()` API but replaces folder scanning with a lazy-import manifest. The manifest can be generated at build time by a bundler plugin or written by hand.
52
-
53
- ### 4. Shared Base
54
-
55
- Look at whether a base `Helpers` class can be extracted to `src/features/helpers.ts` (universal, not node or web specific) with the shared API surface (`available`, `lookup`, `describe`, state/events schemas). Then `src/node/features/helpers.ts` and `src/web/features/helpers.ts` extend it with their environment-specific discovery strategies (filesystem vs manifest).
56
-
57
- ## Files to Touch
58
-
59
- - `src/features/helpers.ts` — new, shared base class with common API
60
- - `src/web/features/helpers.ts` — new, web-specific discovery via manifest
61
- - `src/node/features/helpers.ts` — refactor to extend shared base
62
- - `src/web/extension.ts` — register the web helpers feature
63
- - `src/schemas/base.ts` — only if new shared schemas are needed
64
-
65
- ## Acceptance Criteria
66
-
67
- - `container.helpers.available` works in both node and web containers
68
- - `container.helpers.discover('features')` works in web via manifest config
69
- - `container.helpers.lookup()` and `container.helpers.describe()` work in web
70
- - Node behavior is unchanged (existing tests still pass)
71
- - The shared base class eliminates duplicated logic between node and web
package/docs/todos.md DELETED
@@ -1 +0,0 @@
1
- - make container.start() meaningful. i like the idea of container.use() accepting in addition to the current shape, an async function and container.start() basically running all of those functions.
@@ -1,123 +0,0 @@
1
- import { NodeContainer } from '../src/node/container'
2
-
3
- const container = new NodeContainer({ cwd: process.cwd() })
4
- const listener = container.feature('launcherAppCommandListener', {
5
- autoListen: true,
6
- })
7
-
8
- const windowManager = container.feature('windowManager')
9
-
10
- console.log('Listening on:', listener.state.get('socketPath'))
11
- console.log('Waiting for native app to connect...\n')
12
-
13
- listener.enable()
14
-
15
- listener.on('clientConnected', () => {
16
- console.log('[connected] Native app connected')
17
- })
18
-
19
- listener.on('clientDisconnected', () => {
20
- console.log('[disconnected] Native app disconnected')
21
- })
22
-
23
- listener.on('command', async (cmd) => {
24
- console.log(`[command] "${cmd.text}" (source: ${cmd.source}, id: ${cmd.id})`)
25
-
26
- const normalizedText = String(cmd.text).toLowerCase()
27
-
28
- if (normalizedText.includes('terminal')) {
29
- await container.sleep(1000)
30
- cmd.ack('Sheeeeeeeit. I got you fam!')
31
- await container.sleep(1000)
32
- console.log('Spawning terminal')
33
- const result = await windowManager.spawnTTY({
34
- command: '/Users/jon/.bun/bin/bun',
35
- args: ['run', '/Users/jon/@luca/src/cli/cli.ts', 'console'],
36
- cwd: '/Users/jon/@soederpop',
37
- title: 'The Console',
38
- cols: 120,
39
- rows: 40,
40
- width: 1000,
41
- height: 700,
42
- })
43
-
44
- await container.sleep(4000)
45
-
46
- cmd.finish({ result: { action: 'completed', text: cmd.text }, speech: 'Check that shit out playboy. Fuckin terminal output.' })
47
-
48
- return
49
- } else if (normalizedText.includes('code')) {
50
- await container.sleep(1000)
51
- cmd.ack('Real talk, I feel for the homies we told to learn to code. Now that claude is on this shit?? I mean.')
52
- await container.sleep(1000)
53
- console.log('Spawning terminal')
54
- const result = await windowManager.spawnTTY({
55
- command: '/Users/jon/.bun/bin/claude',
56
- cwd: '/Users/jon/@soederpop',
57
- title: 'Claude',
58
- cols: 120,
59
- rows: 80,
60
- width: 1000,
61
- height: 700,
62
- })
63
-
64
- await container.sleep(4000)
65
-
66
- cmd.finish({ result: { action: 'completed', text: cmd.text }, speech: 'Good luck with claude bro.' })
67
-
68
- return
69
- } else if (normalizedText.includes('web') || normalizedText.includes('browser')) {
70
- cmd.ack('Yo.... Fuckin check this out, twin.')
71
- await container.sleep(1000)
72
- const result = await windowManager.spawn({
73
- url: 'https://google.com',
74
- width: 1000,
75
- height: 700,
76
- })
77
-
78
- console.log('Web browser spawned', result)
79
-
80
- await container.sleep(3000)
81
- cmd.finish({ result: { action: 'completed', text: cmd.text }, speech: 'Motherfucker I can even launch web browsers' })
82
- return
83
- } else if (normalizedText.includes('write')) {
84
- await container.sleep(1000)
85
- cmd.ack('Aight. Sheeeit. We got a real fuckin earnest hemmingway up in here.')
86
- const result = await windowManager.spawn({
87
- url: 'http://localhost:3080',
88
- width: 1200,
89
- height: 900,
90
- })
91
-
92
- await container.sleep(4000)
93
- cmd.finish({ result: { action: 'completed', text: cmd.text }, speech: 'Let the boy COOK' })
94
- return
95
- } else if (normalizedText.includes('track')) {
96
- await container.sleep(1000)
97
- cmd.ack('Better believe it. Aint nobody hiding from your boy.')
98
-
99
- container.proc.spawnAndCapture('luca', ['serve', '--force', '--port', '3969', '--no-open'], {
100
- cwd: '/Users/jon/@soederpop/playground/enemy-tracker'
101
- })
102
-
103
- await container.sleep(4000)
104
-
105
- const result = await windowManager.spawn({
106
- url: 'http://localhost:3969',
107
- width: 1400,
108
- height: 1000,
109
- })
110
-
111
- cmd.finish({ result: { action: 'completed', text: cmd.text }, speech: 'Get em dawg. Me and the homies are ready.' })
112
-
113
- return
114
- }
115
-
116
- await container.sleep(4000)
117
- cmd.ack('Look unc. I dont know the fuck you talmbout.')
118
- cmd.finish({ result: { action: 'unknown' }})
119
- })
120
-
121
- listener.on('message', (msg) => {
122
- console.log('[message]', JSON.stringify(msg))
123
- })
@@ -1,389 +0,0 @@
1
- import { z } from 'zod'
2
- import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
- import { Feature } from '../feature.js'
4
- import { Server as NetServer, Socket } from 'net'
5
- import { homedir } from 'os'
6
- import { join, dirname } from 'path'
7
- import { existsSync, unlinkSync, mkdirSync } from 'fs'
8
-
9
- const DEFAULT_SOCKET_PATH = join(
10
- homedir(),
11
- 'Library',
12
- 'Application Support',
13
- 'LucaVoiceLauncher',
14
- 'ipc-command.sock'
15
- )
16
-
17
- // --- CommandHandle ---
18
-
19
- /**
20
- * A handle to a single incoming command from the native app.
21
- * Provides methods to acknowledge, report progress, and finish the command.
22
- * All responses are automatically correlated by the command's `id`.
23
- */
24
- export class CommandHandle {
25
- /** The correlation UUID from the app. */
26
- readonly id: string
27
- /** The command text (e.g. "open notes"). */
28
- readonly text: string
29
- /** The input source (e.g. "voice", "hotkey"). */
30
- readonly source: string
31
- /** The full payload object from the app. */
32
- readonly payload: any
33
- /** The entire raw message from the app. */
34
- readonly raw: any
35
-
36
- private _send: (msg: Record<string, any>) => boolean
37
- private _finished = false
38
-
39
- constructor(msg: any, send: (msg: Record<string, any>) => boolean) {
40
- this.id = msg.id
41
- this.text = msg.payload?.text ?? ''
42
- this.source = msg.payload?.source ?? ''
43
- this.payload = msg.payload ?? {}
44
- this.raw = msg
45
- this._send = send
46
- }
47
-
48
- /** Whether `finish()` or `fail()` has been called. */
49
- get isFinished(): boolean {
50
- return this._finished
51
- }
52
-
53
- /**
54
- * Send a processing acknowledgement to the app.
55
- * Optionally include a speech phrase for TTS or an audio file path for playback.
56
- *
57
- * @param speechOrOpts - Text the app will speak, or an options object with speech and/or audioFile
58
- */
59
- ack(speechOrOpts?: string | { speech?: string; audioFile?: string }): boolean {
60
- const opts = typeof speechOrOpts === 'string' ? { speech: speechOrOpts } : speechOrOpts
61
- return this._send({
62
- id: this.id,
63
- status: 'processing',
64
- ...(opts?.speech ? { speech: opts.speech } : {}),
65
- ...(opts?.audioFile ? { audioFile: opts.audioFile } : {}),
66
- timestamp: new Date().toISOString(),
67
- })
68
- }
69
-
70
- /**
71
- * Send a progress update to the app.
72
- *
73
- * @param progress - A number between 0 and 1
74
- * @param message - Optional human-readable progress message
75
- */
76
- progress(progress: number, message?: string): boolean {
77
- return this._send({
78
- id: this.id,
79
- status: 'progress',
80
- progress,
81
- ...(message ? { message } : {}),
82
- timestamp: new Date().toISOString(),
83
- })
84
- }
85
-
86
- /**
87
- * Mark the command as successfully finished.
88
- * Can only be called once per command. All arguments are optional.
89
- *
90
- * @param opts - Optional result payload, speech phrase, and/or audio file path
91
- */
92
- finish(opts?: { result?: Record<string, any>; speech?: string; audioFile?: string }): boolean {
93
- if (this._finished) return false
94
- this._finished = true
95
- return this._send({
96
- id: this.id,
97
- status: 'finished',
98
- success: true,
99
- ...(opts?.result ? { result: opts.result } : {}),
100
- ...(opts?.speech ? { speech: opts.speech } : {}),
101
- ...(opts?.audioFile ? { audioFile: opts.audioFile } : {}),
102
- timestamp: new Date().toISOString(),
103
- })
104
- }
105
-
106
- /**
107
- * Mark the command as failed.
108
- * Can only be called once per command.
109
- *
110
- * @param opts - Optional error description, speech phrase, and/or audio file path
111
- */
112
- fail(opts?: { error?: string; speech?: string; audioFile?: string }): boolean {
113
- if (this._finished) return false
114
- this._finished = true
115
- return this._send({
116
- id: this.id,
117
- status: 'finished',
118
- success: false,
119
- ...(opts?.error ? { error: opts.error } : {}),
120
- ...(opts?.speech ? { speech: opts.speech } : {}),
121
- ...(opts?.audioFile ? { audioFile: opts.audioFile } : {}),
122
- timestamp: new Date().toISOString(),
123
- })
124
- }
125
- }
126
-
127
- // --- Schemas ---
128
-
129
- export const LauncherAppCommandListenerOptionsSchema = FeatureOptionsSchema.extend({
130
- socketPath: z.string().default(DEFAULT_SOCKET_PATH)
131
- .describe('Path to the Unix domain socket to listen on'),
132
- autoListen: z.boolean().optional()
133
- .describe('Automatically start listening when the feature is enabled'),
134
- })
135
- export type LauncherAppCommandListenerOptions = z.infer<typeof LauncherAppCommandListenerOptionsSchema>
136
-
137
- export const LauncherAppCommandListenerStateSchema = FeatureStateSchema.extend({
138
- listening: z.boolean().default(false)
139
- .describe('Whether the IPC server is listening'),
140
- clientConnected: z.boolean().default(false)
141
- .describe('Whether the native launcher app is connected'),
142
- socketPath: z.string().optional()
143
- .describe('The socket path in use'),
144
- commandsReceived: z.number().default(0)
145
- .describe('Total number of commands received'),
146
- lastCommandText: z.string().optional()
147
- .describe('The text of the last received command'),
148
- lastError: z.string().optional()
149
- .describe('Last error message'),
150
- })
151
- export type LauncherAppCommandListenerState = z.infer<typeof LauncherAppCommandListenerStateSchema>
152
-
153
- export const LauncherAppCommandListenerEventsSchema = FeatureEventsSchema.extend({
154
- listening: z.tuple([]).describe('Emitted when the IPC server starts listening'),
155
- clientConnected: z.tuple([z.any().describe('The client socket')]).describe('Emitted when the native app connects'),
156
- clientDisconnected: z.tuple([]).describe('Emitted when the native app disconnects'),
157
- command: z.tuple([z.any().describe('A CommandHandle for the incoming command')]).describe('Emitted when a command is received. The listener is responsible for calling ack(), finish(), or fail() on the handle.'),
158
- message: z.tuple([z.any().describe('The parsed message')]).describe('Emitted for any non-command message from the app'),
159
- })
160
-
161
- // --- Private types ---
162
-
163
- interface ClientConnection {
164
- socket: Socket
165
- buffer: string
166
- }
167
-
168
- // --- Feature ---
169
-
170
- /**
171
- * LauncherAppCommandListener — IPC transport for commands from the LucaVoiceLauncher app
172
- *
173
- * Listens on a Unix domain socket for the native macOS launcher app to connect.
174
- * When a command event arrives (voice, hotkey, text input), it wraps it in a
175
- * `CommandHandle` and emits a `command` event. The consumer is responsible for
176
- * acknowledging, processing, and finishing the command via the handle.
177
- *
178
- * Uses NDJSON (newline-delimited JSON) over the socket per the CLIENT_SPEC protocol.
179
- *
180
- * @example
181
- * ```typescript
182
- * const listener = container.feature('launcherAppCommandListener', {
183
- * enable: true,
184
- * autoListen: true,
185
- * })
186
- *
187
- * listener.on('command', async (cmd) => {
188
- * cmd.ack('Working on it!') // or just cmd.ack() for silent
189
- *
190
- * // ... do your actual work ...
191
- * cmd.progress(0.5, 'Halfway there')
192
- *
193
- * cmd.finish() // silent finish
194
- * cmd.finish({ result: { action: 'completed' }, speech: 'All done!' })
195
- * // or: cmd.fail({ error: 'not found', speech: 'Sorry, that failed.' })
196
- * })
197
- * ```
198
- */
199
- export class LauncherAppCommandListener extends Feature<LauncherAppCommandListenerState, LauncherAppCommandListenerOptions> {
200
- static override shortcut = 'features.launcherAppCommandListener' as const
201
- static override stateSchema = LauncherAppCommandListenerStateSchema
202
- static override optionsSchema = LauncherAppCommandListenerOptionsSchema
203
- static override eventsSchema = LauncherAppCommandListenerEventsSchema
204
- static { Feature.register(this, 'launcherAppCommandListener') }
205
-
206
- private _server?: NetServer
207
- private _client?: ClientConnection
208
-
209
- override get initialState(): LauncherAppCommandListenerState {
210
- return {
211
- ...super.initialState,
212
- listening: false,
213
- clientConnected: false,
214
- commandsReceived: 0,
215
- }
216
- }
217
-
218
- /** Whether the IPC server is currently listening. */
219
- get isListening(): boolean {
220
- return this.state.get('listening') || false
221
- }
222
-
223
- /** Whether the native app client is currently connected. */
224
- get isClientConnected(): boolean {
225
- return this.state.get('clientConnected') || false
226
- }
227
-
228
- override async enable(options: any = {}): Promise<this> {
229
- await super.enable(options)
230
-
231
- if (this.options.autoListen) {
232
- this.listen()
233
- }
234
-
235
- return this
236
- }
237
-
238
- /**
239
- * Start listening on the Unix domain socket for the native app to connect.
240
- * Fire-and-forget — binds the socket and returns immediately. Sits quietly
241
- * until the native app connects; does nothing visible if it never does.
242
- *
243
- * @param socketPath - Override the configured socket path
244
- * @returns This feature instance for chaining
245
- */
246
- listen(socketPath?: string): this {
247
- if (this._server) return this
248
-
249
- socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
250
-
251
- const dir = dirname(socketPath)
252
- if (!existsSync(dir)) {
253
- try {
254
- mkdirSync(dir, { recursive: true })
255
- } catch (error: any) {
256
- this.setState({ lastError: `Failed to create socket directory ${dir}: ${error?.message || String(error)}` })
257
- return this
258
- }
259
- }
260
-
261
- if (existsSync(socketPath)) {
262
- try {
263
- unlinkSync(socketPath)
264
- } catch (error: any) {
265
- this.setState({ lastError: `Failed to remove stale socket at ${socketPath}: ${error?.message || String(error)}` })
266
- return this
267
- }
268
- }
269
-
270
- const server = new NetServer((socket) => {
271
- this.handleClientConnect(socket)
272
- })
273
-
274
- server.on('error', (err) => {
275
- this.setState({ lastError: err.message })
276
- })
277
-
278
- const finalPath = socketPath
279
- server.listen(finalPath, () => {
280
- this._server = server
281
- this.setState({ listening: true, socketPath: finalPath })
282
- this.emit('listening')
283
- })
284
-
285
- return this
286
- }
287
-
288
- /**
289
- * Stop the IPC server and clean up all connections.
290
- *
291
- * @returns This feature instance for chaining
292
- */
293
- async stop(): Promise<this> {
294
- if (this._client) {
295
- this._client.socket.destroy()
296
- this._client = undefined
297
- }
298
-
299
- const socketPath = this.state.get('socketPath')
300
-
301
- if (this._server) {
302
- await new Promise<void>((resolve) => {
303
- this._server!.close(() => resolve())
304
- })
305
- this._server = undefined
306
- }
307
-
308
- if (socketPath && existsSync(socketPath)) {
309
- try { unlinkSync(socketPath) } catch { /* ignore */ }
310
- }
311
-
312
- this.setState({ listening: false, clientConnected: false, socketPath: undefined })
313
- return this
314
- }
315
-
316
- /**
317
- * Write an NDJSON message to the connected app client.
318
- *
319
- * @param msg - The message object to send (will be JSON-serialized + newline)
320
- * @returns True if the message was written, false if no client is connected
321
- */
322
- send(msg: Record<string, any>): boolean {
323
- if (!this._client) return false
324
- this._client.socket.write(JSON.stringify(msg) + '\n')
325
- return true
326
- }
327
-
328
- // --- Private ---
329
-
330
- /** Handle a new client connection from the native app. */
331
- private handleClientConnect(socket: Socket): void {
332
- const client: ClientConnection = { socket, buffer: '' }
333
-
334
- if (this._client) {
335
- this._client.socket.destroy()
336
- }
337
- this._client = client
338
-
339
- this.setState({ clientConnected: true })
340
- this.emit('clientConnected', socket)
341
-
342
- socket.on('data', (chunk) => {
343
- client.buffer += chunk.toString()
344
- const lines = client.buffer.split('\n')
345
- client.buffer = lines.pop() || ''
346
- for (const line of lines) {
347
- if (line.trim()) this.processLine(line)
348
- }
349
- })
350
-
351
- socket.on('close', () => {
352
- if (this._client === client) {
353
- this._client = undefined
354
- this.setState({ clientConnected: false })
355
- this.emit('clientDisconnected')
356
- }
357
- })
358
-
359
- socket.on('error', (err) => {
360
- this.setState({ lastError: err.message })
361
- })
362
- }
363
-
364
- /** Process a single NDJSON line. Wraps commands in a CommandHandle; emits `message` for everything else. */
365
- private processLine(line: string): void {
366
- let msg: any
367
- try {
368
- msg = JSON.parse(line)
369
- } catch {
370
- return
371
- }
372
-
373
- if (msg.type === 'command') {
374
- const handle = new CommandHandle(msg, (m) => this.send(m))
375
-
376
- this.setState({
377
- commandsReceived: (this.state.get('commandsReceived') ?? 0) + 1,
378
- lastCommandText: handle.text,
379
- })
380
-
381
- this.emit('command', handle)
382
- return
383
- }
384
-
385
- this.emit('message', msg)
386
- }
387
- }
388
-
389
- export default LauncherAppCommandListener