@soederpop/luca 0.0.32 → 0.0.34
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.
- package/README.md +241 -36
- package/bun.lock +24 -5
- package/commands/build-python-bridge.ts +43 -0
- package/docs/apis/clients/rest.md +7 -7
- package/docs/apis/clients/websocket.md +23 -10
- package/docs/apis/features/agi/assistant.md +155 -8
- package/docs/apis/features/agi/assistants-manager.md +90 -22
- package/docs/apis/features/agi/auto-assistant.md +377 -0
- package/docs/apis/features/agi/browser-use.md +802 -0
- package/docs/apis/features/agi/claude-code.md +6 -1
- package/docs/apis/features/agi/conversation-history.md +7 -6
- package/docs/apis/features/agi/conversation.md +111 -38
- package/docs/apis/features/agi/docs-reader.md +35 -57
- package/docs/apis/features/agi/file-tools.md +163 -0
- package/docs/apis/features/agi/openapi.md +2 -2
- package/docs/apis/features/agi/skills-library.md +227 -0
- package/docs/apis/features/node/content-db.md +125 -4
- package/docs/apis/features/node/disk-cache.md +11 -11
- package/docs/apis/features/node/downloader.md +1 -1
- package/docs/apis/features/node/file-manager.md +15 -15
- package/docs/apis/features/node/fs.md +78 -21
- package/docs/apis/features/node/git.md +50 -10
- package/docs/apis/features/node/google-calendar.md +3 -0
- package/docs/apis/features/node/google-docs.md +10 -1
- package/docs/apis/features/node/google-drive.md +3 -0
- package/docs/apis/features/node/google-mail.md +214 -0
- package/docs/apis/features/node/google-sheets.md +3 -0
- package/docs/apis/features/node/ink.md +10 -10
- package/docs/apis/features/node/ipc-socket.md +83 -93
- package/docs/apis/features/node/networking.md +5 -5
- package/docs/apis/features/node/os.md +7 -7
- package/docs/apis/features/node/package-finder.md +14 -14
- package/docs/apis/features/node/proc.md +2 -1
- package/docs/apis/features/node/process-manager.md +70 -3
- package/docs/apis/features/node/python.md +265 -9
- package/docs/apis/features/node/redis.md +380 -0
- package/docs/apis/features/node/ui.md +13 -13
- package/docs/apis/servers/express.md +35 -7
- package/docs/apis/servers/mcp.md +3 -3
- package/docs/apis/servers/websocket.md +51 -8
- package/docs/bootstrap/CLAUDE.md +1 -1
- package/docs/bootstrap/SKILL.md +93 -7
- package/docs/examples/feature-as-tool-provider.md +143 -0
- package/docs/examples/python.md +42 -1
- package/docs/introspection.md +15 -5
- package/docs/tutorials/00-bootstrap.md +3 -3
- package/docs/tutorials/02-container.md +2 -2
- package/docs/tutorials/10-creating-features.md +5 -0
- package/docs/tutorials/13-introspection.md +12 -2
- package/docs/tutorials/19-python-sessions.md +401 -0
- package/package.json +8 -4
- package/src/agi/container.server.ts +8 -0
- package/src/agi/features/assistant.ts +18 -0
- package/src/agi/features/autonomous-assistant.ts +435 -0
- package/src/agi/features/conversation.ts +58 -6
- package/src/agi/features/file-tools.ts +286 -0
- package/src/agi/features/luca-coder.ts +643 -0
- package/src/bootstrap/generated.ts +705 -17
- package/src/cli/build-info.ts +2 -2
- package/src/cli/cli.ts +22 -13
- package/src/commands/bootstrap.ts +49 -6
- package/src/commands/code.ts +369 -0
- package/src/commands/describe.ts +7 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/sandbox-mcp.ts +7 -7
- package/src/commands/save-api-docs.ts +1 -1
- package/src/container-describer.ts +4 -4
- package/src/container.ts +10 -19
- package/src/helper.ts +24 -33
- package/src/introspection/generated.agi.ts +3026 -590
- package/src/introspection/generated.node.ts +1625 -688
- package/src/introspection/generated.web.ts +15 -57
- package/src/node/container.ts +5 -0
- package/src/node/features/figlet-fonts.ts +597 -0
- package/src/node/features/fs.ts +3 -9
- package/src/node/features/helpers.ts +20 -0
- package/src/node/features/python.ts +429 -16
- package/src/node/features/redis.ts +446 -0
- package/src/node/features/ui.ts +4 -11
- package/src/python/bridge.py +220 -0
- package/src/python/generated.ts +227 -0
- package/src/scaffolds/generated.ts +1 -1
- package/test/python-session.test.ts +105 -0
- package/assistants/lucaExpert/CORE.md +0 -37
- package/assistants/lucaExpert/hooks.ts +0 -9
- package/assistants/lucaExpert/tools.ts +0 -177
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import Redis from 'ioredis'
|
|
3
|
+
import { Feature } from '../feature.js'
|
|
4
|
+
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
5
|
+
import type { ContainerContext } from '../../container.js'
|
|
6
|
+
|
|
7
|
+
export const RedisStateSchema = FeatureStateSchema.extend({
|
|
8
|
+
connected: z.boolean().default(false).describe('Whether the redis connection is currently open'),
|
|
9
|
+
url: z.string().default('').describe('Connection URL used for this redis feature instance'),
|
|
10
|
+
subscriberConnected: z.boolean().default(false).describe('Whether the dedicated subscriber connection is open'),
|
|
11
|
+
subscribedChannels: z.array(z.string()).default([]).describe('List of channels currently subscribed to'),
|
|
12
|
+
lastError: z.string().optional().describe('Most recent redis error message, if any'),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const RedisOptionsSchema = FeatureOptionsSchema.extend({
|
|
16
|
+
url: z.string().optional().describe('Redis connection URL, e.g. redis://localhost:6379. Defaults to redis://localhost:6379'),
|
|
17
|
+
prefix: z.string().optional().describe('Key prefix applied to all get/set/del operations for namespace isolation'),
|
|
18
|
+
lazyConnect: z.boolean().default(false).describe('If true, connection is deferred until first command'),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export type RedisState = z.infer<typeof RedisStateSchema>
|
|
22
|
+
export type RedisOptions = z.infer<typeof RedisOptionsSchema>
|
|
23
|
+
|
|
24
|
+
export const RedisEventsSchema = FeatureEventsSchema.extend({
|
|
25
|
+
message: z.tuple([
|
|
26
|
+
z.string().describe('The channel name'),
|
|
27
|
+
z.string().describe('The message payload'),
|
|
28
|
+
]).describe('When a message is received on a subscribed channel'),
|
|
29
|
+
subscribed: z.tuple([
|
|
30
|
+
z.string().describe('The channel name'),
|
|
31
|
+
]).describe('When successfully subscribed to a channel'),
|
|
32
|
+
unsubscribed: z.tuple([
|
|
33
|
+
z.string().describe('The channel name'),
|
|
34
|
+
]).describe('When unsubscribed from a channel'),
|
|
35
|
+
error: z.tuple([z.string().describe('The error message')]).describe('When a redis operation fails'),
|
|
36
|
+
closed: z.tuple([]).describe('When the redis connection is closed'),
|
|
37
|
+
}).describe('Redis events')
|
|
38
|
+
|
|
39
|
+
type MessageHandler = (channel: string, message: string) => void
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Redis feature for shared state and pub/sub communication between container instances.
|
|
43
|
+
*
|
|
44
|
+
* Wraps ioredis with a focused API for the primitives that matter most:
|
|
45
|
+
* key/value state, pub/sub messaging, and cross-instance coordination.
|
|
46
|
+
*
|
|
47
|
+
* Uses a dedicated subscriber connection for pub/sub (ioredis requirement),
|
|
48
|
+
* created lazily on first subscribe call.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const redis = container.feature('redis', { url: 'redis://localhost:6379' })
|
|
53
|
+
*
|
|
54
|
+
* // Shared state
|
|
55
|
+
* await redis.set('worker:status', 'active')
|
|
56
|
+
* const status = await redis.get('worker:status')
|
|
57
|
+
*
|
|
58
|
+
* // Pub/sub between instances
|
|
59
|
+
* redis.on('message', (channel, msg) => console.log(`${channel}: ${msg}`))
|
|
60
|
+
* await redis.subscribe('tasks')
|
|
61
|
+
* await redis.publish('tasks', JSON.stringify({ type: 'ping' }))
|
|
62
|
+
*
|
|
63
|
+
* // JSON helpers
|
|
64
|
+
* await redis.setJSON('config', { workers: 4, debug: true })
|
|
65
|
+
* const config = await redis.getJSON<{ workers: number }>('config')
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export class RedisFeature extends Feature<RedisState, RedisOptions> {
|
|
69
|
+
static override shortcut = 'features.redis' as const
|
|
70
|
+
static override stateSchema = RedisStateSchema
|
|
71
|
+
static override optionsSchema = RedisOptionsSchema
|
|
72
|
+
static override eventsSchema = RedisEventsSchema
|
|
73
|
+
static { Feature.register(this, 'redis') }
|
|
74
|
+
|
|
75
|
+
private _client: Redis
|
|
76
|
+
private _subscriber: Redis | null = null
|
|
77
|
+
private _prefix: string
|
|
78
|
+
private _messageHandlers: Map<string, Set<MessageHandler>> = new Map()
|
|
79
|
+
|
|
80
|
+
override get initialState(): RedisState {
|
|
81
|
+
return {
|
|
82
|
+
enabled: false,
|
|
83
|
+
connected: false,
|
|
84
|
+
url: '',
|
|
85
|
+
subscriberConnected: false,
|
|
86
|
+
subscribedChannels: [],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
constructor(options: RedisOptions, context: ContainerContext) {
|
|
91
|
+
super(options, context)
|
|
92
|
+
|
|
93
|
+
const url = options.url || 'redis://localhost:6379'
|
|
94
|
+
this._prefix = options.prefix || ''
|
|
95
|
+
|
|
96
|
+
this._client = new Redis(url, {
|
|
97
|
+
lazyConnect: options.lazyConnect ?? false,
|
|
98
|
+
retryStrategy: (times: number) => Math.min(times * 200, 5000),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
this.hide('_client')
|
|
102
|
+
this.hide('_subscriber')
|
|
103
|
+
this.hide('_messageHandlers')
|
|
104
|
+
|
|
105
|
+
this._client.on('connect', () => {
|
|
106
|
+
this.setState({ connected: true, url })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
this._client.on('error', (err: Error) => {
|
|
110
|
+
this.setState({ lastError: err.message })
|
|
111
|
+
this.emit('error', err.message)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
this._client.on('close', () => {
|
|
115
|
+
this.setState({ connected: false })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (!options.lazyConnect) {
|
|
119
|
+
this.setState({ connected: true, url })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The underlying ioredis client for advanced operations. */
|
|
124
|
+
get client(): Redis {
|
|
125
|
+
return this._client
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The dedicated subscriber connection, if pub/sub is active. */
|
|
129
|
+
get subscriber(): Redis | null {
|
|
130
|
+
return this._subscriber
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Key/Value Primitives ──────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
private _key(key: string): string {
|
|
136
|
+
return this._prefix ? `${this._prefix}:${key}` : key
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Set a key to a string value with optional TTL.
|
|
141
|
+
*
|
|
142
|
+
* @param key - The key name
|
|
143
|
+
* @param value - The string value to store
|
|
144
|
+
* @param ttl - Optional time-to-live in seconds
|
|
145
|
+
*/
|
|
146
|
+
async set(key: string, value: string, ttl?: number): Promise<void> {
|
|
147
|
+
if (ttl) {
|
|
148
|
+
await this._client.set(this._key(key), value, 'EX', ttl)
|
|
149
|
+
} else {
|
|
150
|
+
await this._client.set(this._key(key), value)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get a key's value. Returns null if the key doesn't exist.
|
|
156
|
+
*
|
|
157
|
+
* @param key - The key name
|
|
158
|
+
* @returns The stored value, or null
|
|
159
|
+
*/
|
|
160
|
+
async get(key: string): Promise<string | null> {
|
|
161
|
+
return this._client.get(this._key(key))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Delete one or more keys.
|
|
166
|
+
*
|
|
167
|
+
* @param keys - One or more key names to delete
|
|
168
|
+
* @returns Number of keys that were deleted
|
|
169
|
+
*/
|
|
170
|
+
async del(...keys: string[]): Promise<number> {
|
|
171
|
+
return this._client.del(...keys.map(k => this._key(k)))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if a key exists.
|
|
176
|
+
*
|
|
177
|
+
* @param key - The key name
|
|
178
|
+
*/
|
|
179
|
+
async exists(key: string): Promise<boolean> {
|
|
180
|
+
return (await this._client.exists(this._key(key))) === 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Set a key's TTL in seconds.
|
|
185
|
+
*
|
|
186
|
+
* @param key - The key name
|
|
187
|
+
* @param seconds - TTL in seconds
|
|
188
|
+
*/
|
|
189
|
+
async expire(key: string, seconds: number): Promise<boolean> {
|
|
190
|
+
return (await this._client.expire(this._key(key), seconds)) === 1
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Find keys matching a glob pattern (respects prefix).
|
|
195
|
+
*
|
|
196
|
+
* @param pattern - Glob pattern, e.g. "worker:*"
|
|
197
|
+
* @returns Array of matching key names (with prefix stripped)
|
|
198
|
+
*/
|
|
199
|
+
async keys(pattern: string = '*'): Promise<string[]> {
|
|
200
|
+
const results = await this._client.keys(this._key(pattern))
|
|
201
|
+
if (!this._prefix) return results
|
|
202
|
+
const strip = `${this._prefix}:`
|
|
203
|
+
return results.map(k => k.startsWith(strip) ? k.slice(strip.length) : k)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── JSON Helpers ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Store a value as JSON.
|
|
210
|
+
*
|
|
211
|
+
* @param key - The key name
|
|
212
|
+
* @param value - Any JSON-serializable value
|
|
213
|
+
* @param ttl - Optional TTL in seconds
|
|
214
|
+
*/
|
|
215
|
+
async setJSON(key: string, value: unknown, ttl?: number): Promise<void> {
|
|
216
|
+
await this.set(key, JSON.stringify(value), ttl)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Retrieve and parse a JSON value.
|
|
221
|
+
*
|
|
222
|
+
* @param key - The key name
|
|
223
|
+
* @returns The parsed value, or null if the key doesn't exist
|
|
224
|
+
*/
|
|
225
|
+
async getJSON<T = unknown>(key: string): Promise<T | null> {
|
|
226
|
+
const raw = await this.get(key)
|
|
227
|
+
if (raw === null) return null
|
|
228
|
+
return JSON.parse(raw) as T
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Hash Helpers ──────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Set fields on a hash.
|
|
235
|
+
*
|
|
236
|
+
* @param key - The hash key
|
|
237
|
+
* @param fields - Object of field/value pairs
|
|
238
|
+
*/
|
|
239
|
+
async hset(key: string, fields: Record<string, string>): Promise<void> {
|
|
240
|
+
await this._client.hset(this._key(key), fields)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get all fields from a hash.
|
|
245
|
+
*
|
|
246
|
+
* @param key - The hash key
|
|
247
|
+
* @returns Object of field/value pairs
|
|
248
|
+
*/
|
|
249
|
+
async hgetall(key: string): Promise<Record<string, string>> {
|
|
250
|
+
return this._client.hgetall(this._key(key))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get a single field from a hash.
|
|
255
|
+
*
|
|
256
|
+
* @param key - The hash key
|
|
257
|
+
* @param field - The field name
|
|
258
|
+
*/
|
|
259
|
+
async hget(key: string, field: string): Promise<string | null> {
|
|
260
|
+
return this._client.hget(this._key(key), field)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Pub/Sub ───────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Ensures the dedicated subscriber connection exists.
|
|
267
|
+
* ioredis requires a separate connection for subscriptions.
|
|
268
|
+
*/
|
|
269
|
+
private _ensureSubscriber(): Redis {
|
|
270
|
+
if (this._subscriber) return this._subscriber
|
|
271
|
+
|
|
272
|
+
const url = this.state.get('url') || 'redis://localhost:6379'
|
|
273
|
+
this._subscriber = new Redis(url)
|
|
274
|
+
|
|
275
|
+
this._subscriber.on('message', (channel: string, message: string) => {
|
|
276
|
+
this.emit('message', channel, message)
|
|
277
|
+
|
|
278
|
+
const handlers = this._messageHandlers.get(channel)
|
|
279
|
+
if (handlers) {
|
|
280
|
+
for (const handler of handlers) {
|
|
281
|
+
handler(channel, message)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
this._subscriber.on('connect', () => {
|
|
287
|
+
this.setState({ subscriberConnected: true })
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
this._subscriber.on('error', (err: Error) => {
|
|
291
|
+
this.setState({ lastError: err.message })
|
|
292
|
+
this.emit('error', err.message)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
this._subscriber.on('close', () => {
|
|
296
|
+
this.setState({ subscriberConnected: false })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
this.setState({ subscriberConnected: true })
|
|
300
|
+
return this._subscriber
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Subscribe to one or more channels.
|
|
305
|
+
*
|
|
306
|
+
* Optionally pass a handler that fires only for these channels.
|
|
307
|
+
* The feature also emits a `message` event for all messages.
|
|
308
|
+
*
|
|
309
|
+
* @param channels - Channel name(s) to subscribe to
|
|
310
|
+
* @param handler - Optional per-channel message handler
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```typescript
|
|
314
|
+
* await redis.subscribe('tasks', (channel, msg) => {
|
|
315
|
+
* console.log(`Got ${msg} on ${channel}`)
|
|
316
|
+
* })
|
|
317
|
+
* ```
|
|
318
|
+
*/
|
|
319
|
+
async subscribe(channels: string | string[], handler?: MessageHandler): Promise<void> {
|
|
320
|
+
const sub = this._ensureSubscriber()
|
|
321
|
+
const list = Array.isArray(channels) ? channels : [channels]
|
|
322
|
+
|
|
323
|
+
await sub.subscribe(...list)
|
|
324
|
+
|
|
325
|
+
const current = this.state.get('subscribedChannels') || []
|
|
326
|
+
const next = [...new Set([...current, ...list])]
|
|
327
|
+
this.setState({ subscribedChannels: next })
|
|
328
|
+
|
|
329
|
+
if (handler) {
|
|
330
|
+
for (const ch of list) {
|
|
331
|
+
if (!this._messageHandlers.has(ch)) {
|
|
332
|
+
this._messageHandlers.set(ch, new Set())
|
|
333
|
+
}
|
|
334
|
+
this._messageHandlers.get(ch)!.add(handler)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const ch of list) {
|
|
339
|
+
this.emit('subscribed', ch)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Unsubscribe from one or more channels.
|
|
345
|
+
*
|
|
346
|
+
* @param channels - Channel name(s) to unsubscribe from
|
|
347
|
+
*/
|
|
348
|
+
async unsubscribe(...channels: string[]): Promise<void> {
|
|
349
|
+
if (!this._subscriber) return
|
|
350
|
+
|
|
351
|
+
await this._subscriber.unsubscribe(...channels)
|
|
352
|
+
|
|
353
|
+
const current = this.state.get('subscribedChannels') || []
|
|
354
|
+
this.setState({
|
|
355
|
+
subscribedChannels: current.filter((ch: string) => !channels.includes(ch)),
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
for (const ch of channels) {
|
|
359
|
+
this._messageHandlers.delete(ch)
|
|
360
|
+
this.emit('unsubscribed', ch)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Publish a message to a channel.
|
|
366
|
+
*
|
|
367
|
+
* @param channel - The channel to publish to
|
|
368
|
+
* @param message - The message string (use JSON.stringify for objects)
|
|
369
|
+
* @returns Number of subscribers that received the message
|
|
370
|
+
*/
|
|
371
|
+
async publish(channel: string, message: string): Promise<number> {
|
|
372
|
+
return this._client.publish(channel, message)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Docker Convenience ──────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Spin up a local Redis instance via Docker. Checks if a container with
|
|
379
|
+
* the given name already exists and starts it if stopped, or creates a
|
|
380
|
+
* new one from redis:alpine.
|
|
381
|
+
*
|
|
382
|
+
* Requires the docker feature to be available on the container.
|
|
383
|
+
*
|
|
384
|
+
* @param options - Container name and host port
|
|
385
|
+
* @returns The docker container ID
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```typescript
|
|
389
|
+
* const redis = container.feature('redis', { url: 'redis://localhost:6379', lazyConnect: true })
|
|
390
|
+
* await redis.ensureLocalDocker()
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
async ensureLocalDocker(options: { name?: string; port?: number; image?: string } = {}): Promise<string> {
|
|
394
|
+
const { name = 'luca-redis', port = 6379, image = 'redis:alpine' } = options
|
|
395
|
+
const docker = this.container.feature('docker', { enable: true })
|
|
396
|
+
|
|
397
|
+
const containers = await docker.listContainers({ all: true })
|
|
398
|
+
const existing = containers.find((c: any) =>
|
|
399
|
+
c.names?.includes(name) || c.names?.includes(`/${name}`)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if (existing) {
|
|
403
|
+
if (existing.state !== 'running') {
|
|
404
|
+
await docker.startContainer(name)
|
|
405
|
+
}
|
|
406
|
+
return existing.id
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return docker.runContainer(image, {
|
|
410
|
+
name,
|
|
411
|
+
ports: [`${port}:6379`],
|
|
412
|
+
detach: true,
|
|
413
|
+
restart: 'unless-stopped',
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Close all redis connections (main client + subscriber).
|
|
421
|
+
*/
|
|
422
|
+
async close(): Promise<this> {
|
|
423
|
+
if (this._subscriber) {
|
|
424
|
+
this._subscriber.disconnect()
|
|
425
|
+
this._subscriber = null
|
|
426
|
+
}
|
|
427
|
+
this._client.disconnect()
|
|
428
|
+
this._messageHandlers.clear()
|
|
429
|
+
this.setState({
|
|
430
|
+
connected: false,
|
|
431
|
+
subscriberConnected: false,
|
|
432
|
+
subscribedChannels: [],
|
|
433
|
+
})
|
|
434
|
+
this.emit('closed')
|
|
435
|
+
return this
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export { RedisFeature as Redis }
|
|
440
|
+
export default RedisFeature
|
|
441
|
+
|
|
442
|
+
declare module '../../feature.js' {
|
|
443
|
+
interface AvailableFeatures {
|
|
444
|
+
redis: typeof RedisFeature
|
|
445
|
+
}
|
|
446
|
+
}
|
package/src/node/features/ui.ts
CHANGED
|
@@ -3,13 +3,7 @@ import { FeatureStateSchema } from '../../schemas/base.js'
|
|
|
3
3
|
import { Feature } from "../feature.js";
|
|
4
4
|
import colors from "chalk";
|
|
5
5
|
import type { Fonts } from "figlet";
|
|
6
|
-
|
|
7
|
-
let figlet: typeof import("figlet").default | null = null;
|
|
8
|
-
try {
|
|
9
|
-
figlet = require("figlet");
|
|
10
|
-
} catch {
|
|
11
|
-
// figlet not installed — asciiArt/banner will fall back to plain text
|
|
12
|
-
}
|
|
6
|
+
import { figlet, fontNames } from "./figlet-fonts.js";
|
|
13
7
|
import inquirer from "inquirer";
|
|
14
8
|
import { marked } from 'marked';
|
|
15
9
|
import { markedTerminal } from 'marked-terminal';
|
|
@@ -257,8 +251,8 @@ export class UI<T extends UIState = UIState> extends Feature<T> {
|
|
|
257
251
|
get fonts(): string[] {
|
|
258
252
|
const fonts = this.state.get("fonts")! || [];
|
|
259
253
|
|
|
260
|
-
if (!fonts.length
|
|
261
|
-
this.state.set("fonts",
|
|
254
|
+
if (!fonts.length) {
|
|
255
|
+
this.state.set("fonts", fontNames);
|
|
262
256
|
}
|
|
263
257
|
|
|
264
258
|
return this.state.get("fonts")!;
|
|
@@ -430,8 +424,7 @@ export class UI<T extends UIState = UIState> extends Feature<T> {
|
|
|
430
424
|
* ```
|
|
431
425
|
*/
|
|
432
426
|
asciiArt(text: string, font: Fonts): string {
|
|
433
|
-
|
|
434
|
-
return figlet.textSync(text, font);
|
|
427
|
+
return figlet.textSync(text, font as any);
|
|
435
428
|
}
|
|
436
429
|
|
|
437
430
|
/**
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Luca Python Bridge - persistent interactive Python session.
|
|
3
|
+
|
|
4
|
+
Communicates via JSON lines over stdin/stdout. Each request is a single
|
|
5
|
+
JSON object per line on stdin; each response is a single JSON object per
|
|
6
|
+
line on stdout. User print() output is captured per-execution via
|
|
7
|
+
io.StringIO so it never corrupts the protocol.
|
|
8
|
+
|
|
9
|
+
Python 3.8+ compatible (stdlib only).
|
|
10
|
+
"""
|
|
11
|
+
import sys
|
|
12
|
+
import json
|
|
13
|
+
import io
|
|
14
|
+
import traceback
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
# Persistent namespace shared across all exec/eval calls
|
|
18
|
+
_namespace = {"__builtins__": __builtins__}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_sys_path(project_dir):
|
|
22
|
+
"""Insert project_dir and common sub-paths into sys.path."""
|
|
23
|
+
paths_to_add = [project_dir]
|
|
24
|
+
|
|
25
|
+
# src/ layout (PEP 621 / setuptools)
|
|
26
|
+
src_dir = os.path.join(project_dir, "src")
|
|
27
|
+
if os.path.isdir(src_dir):
|
|
28
|
+
paths_to_add.append(src_dir)
|
|
29
|
+
|
|
30
|
+
# lib/ layout (less common but exists)
|
|
31
|
+
lib_dir = os.path.join(project_dir, "lib")
|
|
32
|
+
if os.path.isdir(lib_dir):
|
|
33
|
+
paths_to_add.append(lib_dir)
|
|
34
|
+
|
|
35
|
+
for p in reversed(paths_to_add):
|
|
36
|
+
if p not in sys.path:
|
|
37
|
+
sys.path.insert(0, p)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_serialize(value):
|
|
41
|
+
"""Attempt JSON serialization; fall back to repr()."""
|
|
42
|
+
try:
|
|
43
|
+
json.dumps(value, default=str)
|
|
44
|
+
return value
|
|
45
|
+
except (TypeError, ValueError, OverflowError):
|
|
46
|
+
return repr(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def handle_exec(req):
|
|
50
|
+
"""Execute code in the persistent namespace."""
|
|
51
|
+
code = req.get("code", "")
|
|
52
|
+
variables = req.get("variables", {})
|
|
53
|
+
_namespace.update(variables)
|
|
54
|
+
|
|
55
|
+
old_stdout = sys.stdout
|
|
56
|
+
captured = io.StringIO()
|
|
57
|
+
sys.stdout = captured
|
|
58
|
+
try:
|
|
59
|
+
exec(code, _namespace)
|
|
60
|
+
sys.stdout = old_stdout
|
|
61
|
+
return {"ok": True, "stdout": captured.getvalue(), "result": None}
|
|
62
|
+
except Exception as e:
|
|
63
|
+
sys.stdout = old_stdout
|
|
64
|
+
return {
|
|
65
|
+
"ok": False,
|
|
66
|
+
"error": str(e),
|
|
67
|
+
"traceback": traceback.format_exc(),
|
|
68
|
+
"stdout": captured.getvalue(),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def handle_eval(req):
|
|
73
|
+
"""Evaluate an expression and return its value."""
|
|
74
|
+
expression = req.get("expression", "")
|
|
75
|
+
|
|
76
|
+
old_stdout = sys.stdout
|
|
77
|
+
captured = io.StringIO()
|
|
78
|
+
sys.stdout = captured
|
|
79
|
+
try:
|
|
80
|
+
result = eval(expression, _namespace)
|
|
81
|
+
sys.stdout = old_stdout
|
|
82
|
+
return {
|
|
83
|
+
"ok": True,
|
|
84
|
+
"result": _safe_serialize(result),
|
|
85
|
+
"stdout": captured.getvalue(),
|
|
86
|
+
}
|
|
87
|
+
except Exception as e:
|
|
88
|
+
sys.stdout = old_stdout
|
|
89
|
+
return {
|
|
90
|
+
"ok": False,
|
|
91
|
+
"error": str(e),
|
|
92
|
+
"traceback": traceback.format_exc(),
|
|
93
|
+
"stdout": captured.getvalue(),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def handle_import(req):
|
|
98
|
+
"""Import a module into the namespace."""
|
|
99
|
+
module_name = req.get("module", "")
|
|
100
|
+
alias = req.get("alias", module_name.split(".")[-1])
|
|
101
|
+
try:
|
|
102
|
+
mod = __import__(
|
|
103
|
+
module_name,
|
|
104
|
+
fromlist=[module_name.split(".")[-1]] if "." in module_name else [],
|
|
105
|
+
)
|
|
106
|
+
_namespace[alias] = mod
|
|
107
|
+
return {"ok": True, "result": "Imported {} as {}".format(module_name, alias)}
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return {
|
|
110
|
+
"ok": False,
|
|
111
|
+
"error": str(e),
|
|
112
|
+
"traceback": traceback.format_exc(),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def handle_call(req):
|
|
117
|
+
"""Call a function by dotted path in the namespace."""
|
|
118
|
+
func_path = req.get("function", "")
|
|
119
|
+
args = req.get("args", [])
|
|
120
|
+
kwargs = req.get("kwargs", {})
|
|
121
|
+
|
|
122
|
+
old_stdout = sys.stdout
|
|
123
|
+
captured = io.StringIO()
|
|
124
|
+
sys.stdout = captured
|
|
125
|
+
try:
|
|
126
|
+
parts = func_path.split(".")
|
|
127
|
+
obj = _namespace[parts[0]]
|
|
128
|
+
for part in parts[1:]:
|
|
129
|
+
obj = getattr(obj, part)
|
|
130
|
+
result = obj(*args, **kwargs)
|
|
131
|
+
sys.stdout = old_stdout
|
|
132
|
+
return {
|
|
133
|
+
"ok": True,
|
|
134
|
+
"result": _safe_serialize(result),
|
|
135
|
+
"stdout": captured.getvalue(),
|
|
136
|
+
}
|
|
137
|
+
except Exception as e:
|
|
138
|
+
sys.stdout = old_stdout
|
|
139
|
+
return {
|
|
140
|
+
"ok": False,
|
|
141
|
+
"error": str(e),
|
|
142
|
+
"traceback": traceback.format_exc(),
|
|
143
|
+
"stdout": captured.getvalue(),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def handle_get_locals(req):
|
|
148
|
+
"""Return all non-dunder keys from the namespace."""
|
|
149
|
+
safe = {}
|
|
150
|
+
for k, v in _namespace.items():
|
|
151
|
+
if k.startswith("__"):
|
|
152
|
+
continue
|
|
153
|
+
safe[k] = _safe_serialize(v)
|
|
154
|
+
return {"ok": True, "result": safe}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def handle_reset(req):
|
|
158
|
+
"""Clear the namespace."""
|
|
159
|
+
_namespace.clear()
|
|
160
|
+
_namespace["__builtins__"] = __builtins__
|
|
161
|
+
return {"ok": True, "result": "Session reset"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
HANDLERS = {
|
|
165
|
+
"exec": handle_exec,
|
|
166
|
+
"eval": handle_eval,
|
|
167
|
+
"import": handle_import,
|
|
168
|
+
"call": handle_call,
|
|
169
|
+
"get_locals": handle_get_locals,
|
|
170
|
+
"reset": handle_reset,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main():
|
|
175
|
+
# First line from stdin is the init handshake with project_dir
|
|
176
|
+
init_line = sys.stdin.readline().strip()
|
|
177
|
+
if init_line:
|
|
178
|
+
try:
|
|
179
|
+
init = json.loads(init_line)
|
|
180
|
+
if "project_dir" in init:
|
|
181
|
+
setup_sys_path(init["project_dir"])
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
# Signal ready
|
|
186
|
+
sys.stdout.write(json.dumps({"ok": True, "type": "ready"}) + "\n")
|
|
187
|
+
sys.stdout.flush()
|
|
188
|
+
|
|
189
|
+
# Main loop: read JSON commands, execute, respond
|
|
190
|
+
for line in sys.stdin:
|
|
191
|
+
line = line.strip()
|
|
192
|
+
if not line:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
req = json.loads(line)
|
|
197
|
+
except json.JSONDecodeError as e:
|
|
198
|
+
resp = {"ok": False, "error": "Invalid JSON: {}".format(e)}
|
|
199
|
+
sys.stdout.write(json.dumps(resp) + "\n")
|
|
200
|
+
sys.stdout.flush()
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
req_id = req.get("id")
|
|
204
|
+
req_type = req.get("type", "exec")
|
|
205
|
+
handler = HANDLERS.get(req_type)
|
|
206
|
+
|
|
207
|
+
if not handler:
|
|
208
|
+
resp = {"ok": False, "error": "Unknown request type: {}".format(req_type)}
|
|
209
|
+
else:
|
|
210
|
+
resp = handler(req)
|
|
211
|
+
|
|
212
|
+
if req_id:
|
|
213
|
+
resp["id"] = req_id
|
|
214
|
+
|
|
215
|
+
sys.stdout.write(json.dumps(resp, default=str) + "\n")
|
|
216
|
+
sys.stdout.flush()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
main()
|