luca 3.0.2 → 3.1.0

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.
@@ -64,6 +64,8 @@ import "./features/container-link";
64
64
  import "./features/semantic-search";
65
65
  import "./features/dns";
66
66
  import "./features/redis";
67
+ import "./features/socket-repl";
68
+ import "./features/telnyx-assistant-connector";
67
69
 
68
70
  import type { ChildProcess } from "./features/proc";
69
71
  import type { DiskCache } from "./features/disk-cache";
@@ -108,6 +110,8 @@ import type { ContainerLink } from './features/container-link';
108
110
  import type { SemanticSearch } from './features/semantic-search';
109
111
  import type { Dns } from './features/dns';
110
112
  import type { Redis } from './features/redis';
113
+ import type { SocketRepl } from './features/socket-repl';
114
+ import type { TelnyxAssistantConnector } from './features/telnyx-assistant-connector';
111
115
  export { State };
112
116
 
113
117
  export {
@@ -147,6 +151,8 @@ export {
147
151
  type SemanticSearch,
148
152
  type Dns,
149
153
  type Redis,
154
+ type SocketRepl,
155
+ type TelnyxAssistantConnector,
150
156
  type Transpiler,
151
157
  };
152
158
 
@@ -213,6 +219,8 @@ export interface NodeFeatures extends AvailableFeatures {
213
219
  semanticSearch: typeof SemanticSearch;
214
220
  dns: typeof Dns;
215
221
  redis: typeof Redis;
222
+ socketRepl: typeof SocketRepl;
223
+ telnyxAssistantConnector: typeof TelnyxAssistantConnector;
216
224
  }
217
225
 
218
226
  export type ClientsAndServersInterface = ClientsInterface & ServersInterface & CommandsInterface & EndpointsInterface & SelectorsInterface;
@@ -223,6 +223,18 @@ export class Helpers extends Feature<HelpersState, HelpersOptions> {
223
223
  vm.defineModule('luca/servers/express', { ExpressServer, default: ExpressServer })
224
224
  vm.defineModule('luca/servers/socket', { WebsocketServer, default: WebsocketServer })
225
225
 
226
+ // Legacy package name aliases for backwards compatibility
227
+ vm.defineModule('@soederpop/luca', lucaExports)
228
+ vm.defineModule('@soederpop/luca/schemas', schemasModule)
229
+ vm.defineModule('@soederpop/luca/node', lucaExports)
230
+ vm.defineModule('@soederpop/luca/client', { Client, ClientsRegistry: clients.constructor, default: Client })
231
+ vm.defineModule('@soederpop/luca/server', { Server, ServersRegistry: servers.constructor, default: Server })
232
+ vm.defineModule('@soederpop/luca/clients/rest', { RestClient, default: RestClient })
233
+ vm.defineModule('@soederpop/luca/clients/graph', { GraphClient, default: GraphClient })
234
+ vm.defineModule('@soederpop/luca/clients/websocket', { WebSocketClient, default: WebSocketClient })
235
+ vm.defineModule('@soederpop/luca/servers/express', { ExpressServer, default: ExpressServer })
236
+ vm.defineModule('@soederpop/luca/servers/socket', { WebsocketServer, default: WebsocketServer })
237
+
226
238
  vm.defineModule('zod', { z, default: { z } })
227
239
  }
228
240
 
@@ -0,0 +1,336 @@
1
+ import { z } from 'zod'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
+ import { Feature } from "../feature.js";
4
+ import vm from 'vm'
5
+ import { inspect } from 'util'
6
+
7
+ export const SocketReplStateSchema = FeatureStateSchema.extend({
8
+ started: z.boolean().optional().describe('Whether the socket REPL server is running'),
9
+ port: z.number().optional().describe('The port the WebSocket server is listening on'),
10
+ activeClients: z.number().default(0).describe('Number of connected REPL clients'),
11
+ })
12
+ export type SocketReplState = z.infer<typeof SocketReplStateSchema>
13
+
14
+ export const SocketReplOptionsSchema = FeatureOptionsSchema.extend({
15
+ port: z.number().optional().describe('Port for the WebSocket server (default: 8282)'),
16
+ prompt: z.string().optional().describe('The prompt string sent to clients (default: "> ")'),
17
+ historyPath: z.string().optional().describe('Path to the REPL history file for command persistence'),
18
+ })
19
+ export type SocketReplOptions = z.infer<typeof SocketReplOptionsSchema>
20
+
21
+ export const SocketReplEventsSchema = FeatureEventsSchema.extend({
22
+ 'client:connected': z.tuple([z.string().describe('Client ID')]).describe('A REPL client connected'),
23
+ 'client:disconnected': z.tuple([z.string().describe('Client ID')]).describe('A REPL client disconnected'),
24
+ 'eval': z.tuple([z.string().describe('The input expression'), z.string().describe('Client ID')]).describe('An expression was evaluated'),
25
+ 'eval:result': z.tuple([z.any().describe('The result'), z.string().describe('Client ID')]).describe('An expression produced a result'),
26
+ 'eval:error': z.tuple([z.string().describe('Error message'), z.string().describe('Client ID')]).describe('An expression threw an error'),
27
+ })
28
+
29
+ /**
30
+ * Socket REPL — a WebSocket-powered interactive read-eval-print loop.
31
+ *
32
+ * Exposes a REPL session over WebSocket so remote clients (browser, other process,
33
+ * terminal UI) can evaluate expressions in a sandboxed VM context populated with
34
+ * the container and its helpers. Each connected client gets its own session tracking
35
+ * but shares the same VM context. Supports tab completion and async/await.
36
+ *
37
+ * Messages use JSON framing:
38
+ * - Client → Server: `{ type: "eval", input: "expression" }`
39
+ * - Client → Server: `{ type: "complete", partial: "container.fea" }`
40
+ * - Server → Client: `{ type: "prompt", prompt: "> " }`
41
+ * - Server → Client: `{ type: "result", value: "..." }`
42
+ * - Server → Client: `{ type: "error", message: "..." }`
43
+ * - Server → Client: `{ type: "completions", items: ["feature", "features"], partial: "fea" }`
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const socketRepl = container.feature('socketRepl', { enable: true })
48
+ * await socketRepl.start({ port: 8282, context: { myVar: 42 } })
49
+ * ```
50
+ */
51
+ export class SocketRepl<
52
+ T extends SocketReplState = SocketReplState,
53
+ K extends SocketReplOptions = SocketReplOptions
54
+ > extends Feature<T, K> {
55
+ static override shortcut = "features.socketRepl" as const
56
+ static override stateSchema = SocketReplStateSchema
57
+ static override optionsSchema = SocketReplOptionsSchema
58
+ static override eventsSchema = SocketReplEventsSchema
59
+ static { Feature.register(this, 'socketRepl') }
60
+
61
+ _vmContext?: vm.Context
62
+ _wsServer?: any
63
+ _history: string[] = []
64
+ _historyPath?: string
65
+ _clientIds = new WeakMap<any, string>()
66
+
67
+ /** The VM context object used for evaluating expressions. */
68
+ get vmContext() {
69
+ return this._vmContext
70
+ }
71
+
72
+ /** Whether the REPL server is currently running. */
73
+ get isStarted() {
74
+ return !!this.state.get('started')
75
+ }
76
+
77
+ /**
78
+ * Start the socket REPL server.
79
+ *
80
+ * Creates a VM context populated with the container and its helpers,
81
+ * starts a WebSocket server, and begins accepting REPL connections.
82
+ *
83
+ * @param options - Configuration for the REPL session
84
+ * @param options.port - Port to listen on (default: 8282)
85
+ * @param options.context - Additional variables to inject into the VM context
86
+ * @param options.historyPath - Custom path for the history file
87
+ * @returns The SocketRepl instance
88
+ *
89
+ * @example
90
+ * ```typescript
91
+ * const socketRepl = container.feature('socketRepl', { enable: true })
92
+ * await socketRepl.start({
93
+ * port: 8282,
94
+ * context: { db: myDatabase },
95
+ * })
96
+ * ```
97
+ */
98
+ async start(options: { port?: number, context?: any, historyPath?: string } = {}) {
99
+ if (this.isStarted) {
100
+ // Merge any new context into the existing VM context
101
+ if (options.context) {
102
+ for (const [k, v] of Object.entries(options.context)) {
103
+ this._vmContext![k] = v
104
+ }
105
+ }
106
+ return this
107
+ }
108
+
109
+ const port = options.port || this.options.port || 8282
110
+
111
+ // Set up history file
112
+ const userHistoryPath = options.historyPath || this.options.historyPath
113
+ if (typeof userHistoryPath === 'string') {
114
+ this._historyPath = this.container.paths.resolve(userHistoryPath)
115
+ } else {
116
+ const cwdHash = this.container.utils.hashObject(this.container.cwd)
117
+ this._historyPath = this.container.paths.resolve(
118
+ this.container.feature('os').cacheDir,
119
+ `socket-repl-${cwdHash}.history`
120
+ )
121
+ }
122
+
123
+ this.container.fs.ensureFolder(this.container.paths.dirname(this._historyPath))
124
+
125
+ // Load existing history
126
+ try {
127
+ const content = this.container.fs.readFile(this._historyPath, 'utf-8') as string
128
+ this._history = content.split(/\r?\n/).filter(Boolean)
129
+ } catch {}
130
+
131
+ // Build VM context
132
+ this._vmContext = vm.createContext({
133
+ ...this.container.context,
134
+ ...options.context,
135
+ setTimeout, setInterval, process, clearInterval, clearTimeout, Buffer, URL, URLSearchParams,
136
+ // @ts-ignore
137
+ client: (...args: any[]) => this.container.client(...args),
138
+ })
139
+
140
+ // Start websocket server
141
+ const ws = this.container.server('websocket', { json: true })
142
+ this._wsServer = ws
143
+
144
+ ws.on('connection', (client: any) => {
145
+ const clientId = this.container.utils.uuid()
146
+ this._clientIds.set(client, clientId)
147
+ this.state.set('activeClients', (this.state.get('activeClients') || 0) + 1)
148
+ this.emit('client:connected', clientId)
149
+
150
+ // Send initial prompt and history
151
+ ws.send(client, {
152
+ type: 'prompt',
153
+ prompt: this.options.prompt || '> ',
154
+ history: this._history.slice(-100),
155
+ })
156
+
157
+ client.on('close', () => {
158
+ const id = this._clientIds.get(client) || 'unknown'
159
+ const count = this.state.get('activeClients') || 1
160
+ this.state.set('activeClients', Math.max(0, count - 1))
161
+ this.emit('client:disconnected', id)
162
+ })
163
+ })
164
+
165
+ ws.on('message', async (data: any, client: any) => {
166
+ const clientId = this._clientIds.get(client) || 'unknown'
167
+
168
+ if (data.type === 'eval') {
169
+ await this._handleEval(data.input, client, clientId)
170
+ } else if (data.type === 'complete') {
171
+ this._handleComplete(data.partial, client)
172
+ }
173
+ })
174
+
175
+ await ws.start({ port })
176
+
177
+ this.state.set('started', true)
178
+ this.state.set('port', port)
179
+
180
+ return this
181
+ }
182
+
183
+ /**
184
+ * Stop the socket REPL server and disconnect all clients.
185
+ */
186
+ async stop() {
187
+ if (!this.isStarted || !this._wsServer) return this
188
+
189
+ await this._wsServer.stop()
190
+ this._wsServer = undefined
191
+ this.state.set('started', false)
192
+ this.state.set('activeClients', 0)
193
+
194
+ return this
195
+ }
196
+
197
+ /** Evaluate an expression and send the result back to the client. */
198
+ private async _handleEval(input: string, ws: any, clientId: string) {
199
+ const trimmed = (input || '').trim()
200
+ if (!trimmed) {
201
+ this._sendPrompt(ws)
202
+ return
203
+ }
204
+
205
+ this._saveHistory(trimmed)
206
+ this.emit('eval', trimmed, clientId)
207
+ const ctx = this._vmContext!
208
+
209
+ try {
210
+ // Wrap top-level await in an async IIFE so vm.Script can handle it
211
+ const code = /\bawait\b/.test(trimmed)
212
+ ? `(async () => { return (${trimmed}); })()`
213
+ : trimmed
214
+ const script = new vm.Script(code)
215
+ let result = script.runInContext(ctx)
216
+
217
+ if (result && typeof result.then === 'function') {
218
+ result = await result
219
+ }
220
+
221
+ ctx._ = result
222
+
223
+ const display = this._formatResult(result)
224
+ this.emit('eval:result', result, clientId)
225
+
226
+ this._wsServer.send(ws, {
227
+ type: 'result',
228
+ value: display,
229
+ })
230
+ } catch (err: any) {
231
+ this.emit('eval:error', err.message, clientId)
232
+ this._wsServer.send(ws, {
233
+ type: 'error',
234
+ message: err.message,
235
+ })
236
+ }
237
+
238
+ this._sendPrompt(ws)
239
+ }
240
+
241
+ /** Handle tab completion requests. */
242
+ private _handleComplete(partial: string, ws: any) {
243
+ const ctx = this._vmContext!
244
+ if (!ctx) return
245
+
246
+ // Dot-notation completion: e.g. container.fea
247
+ const dotMatch = partial.match(/([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\.([a-zA-Z_$][\w$]*)?$/)
248
+ if (dotMatch) {
249
+ const objPath = dotMatch[1]!
250
+ const fragment = dotMatch[2] || ''
251
+ try {
252
+ const obj = new vm.Script(objPath).runInContext(ctx)
253
+ if (obj != null && typeof obj === 'object') {
254
+ const all: string[] = Object.keys(obj)
255
+ let proto = Object.getPrototypeOf(obj)
256
+ while (proto && proto !== Object.prototype) {
257
+ all.push(...Object.getOwnPropertyNames(proto))
258
+ proto = Object.getPrototypeOf(proto)
259
+ }
260
+ const items = [...new Set(all)]
261
+ .filter(p => p.startsWith(fragment) && p !== 'constructor')
262
+ .sort()
263
+ this._wsServer.send(ws, { type: 'completions', items, partial: fragment, prefix: objPath + '.' })
264
+ return
265
+ }
266
+ } catch {}
267
+ }
268
+
269
+ // Top-level identifiers
270
+ const idMatch = partial.match(/([a-zA-Z_$][\w$]*)$/)
271
+ const fragment = idMatch ? idMatch[1]! : ''
272
+ const items = Object.keys(ctx).filter(k => k.startsWith(fragment)).sort()
273
+ this._wsServer.send(ws, { type: 'completions', items, partial: fragment, prefix: '' })
274
+ }
275
+
276
+ /** Format a result value to a string suitable for sending over the wire. */
277
+ private _formatResult(value: any): string {
278
+ if (value === undefined) return 'undefined'
279
+ if (value === null) return 'null'
280
+ if (typeof value !== 'object') return String(value)
281
+
282
+ const hasCustomInspect = typeof value[Symbol.for('nodejs.util.inspect.custom')] === 'function'
283
+ const ctorName = value.constructor?.name
284
+ const BUILTIN_TYPES = new Set(['Object', 'Array', 'Map', 'Set', 'Date', 'RegExp', 'Promise', 'Error', 'Number', 'String', 'Boolean'])
285
+ const isClassInstance = ctorName && !BUILTIN_TYPES.has(ctorName)
286
+
287
+ if (hasCustomInspect || !isClassInstance) {
288
+ return inspect(value, { colors: false, depth: 4 })
289
+ }
290
+
291
+ // Class instances: show clean data
292
+ const data: Record<string, any> = {}
293
+ for (const [k, v] of Object.entries(value)) {
294
+ if (k.startsWith('_') || typeof v === 'function') continue
295
+ data[k] = v
296
+ }
297
+ const body = inspect(data, { colors: false, depth: 3 })
298
+
299
+ const methods: string[] = []
300
+ const getters: string[] = []
301
+ for (const [k, v] of Object.entries(value)) {
302
+ if (k.startsWith('_')) continue
303
+ if (typeof v === 'function') methods.push(k)
304
+ }
305
+ let proto = Object.getPrototypeOf(value)
306
+ while (proto && proto !== Object.prototype) {
307
+ for (const k of Object.getOwnPropertyNames(proto)) {
308
+ if (k === 'constructor' || k.startsWith('_')) continue
309
+ const desc = Object.getOwnPropertyDescriptor(proto, k)
310
+ if (!desc) continue
311
+ if (desc.get && !getters.includes(k)) getters.push(k)
312
+ else if (typeof desc.value === 'function' && !methods.includes(k)) methods.push(k)
313
+ }
314
+ proto = Object.getPrototypeOf(proto)
315
+ }
316
+
317
+ const parts = [`${ctorName} ${body}`]
318
+ if (getters.length) parts.push(` getters: ${getters.sort().join(', ')}`)
319
+ if (methods.length) parts.push(` methods: ${methods.sort().map(m => m + '()').join(', ')}`)
320
+ return parts.join('\n')
321
+ }
322
+
323
+ private _sendPrompt(ws: any) {
324
+ this._wsServer?.send(ws, { type: 'prompt', prompt: this.options.prompt || '> ' })
325
+ }
326
+
327
+ private _saveHistory(line: string) {
328
+ if (!this._historyPath || !line.trim()) return
329
+ this._history.push(line)
330
+ try {
331
+ this.container.fs.appendFile(this._historyPath, line + '\n')
332
+ } catch {}
333
+ }
334
+ }
335
+
336
+ export default SocketRepl