@soederpop/luca 0.0.31 → 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.
Files changed (86) hide show
  1. package/README.md +241 -36
  2. package/bun.lock +24 -5
  3. package/commands/build-python-bridge.ts +43 -0
  4. package/docs/apis/clients/rest.md +7 -7
  5. package/docs/apis/clients/websocket.md +23 -10
  6. package/docs/apis/features/agi/assistant.md +155 -8
  7. package/docs/apis/features/agi/assistants-manager.md +90 -22
  8. package/docs/apis/features/agi/auto-assistant.md +377 -0
  9. package/docs/apis/features/agi/browser-use.md +802 -0
  10. package/docs/apis/features/agi/claude-code.md +6 -1
  11. package/docs/apis/features/agi/conversation-history.md +7 -6
  12. package/docs/apis/features/agi/conversation.md +111 -38
  13. package/docs/apis/features/agi/docs-reader.md +35 -57
  14. package/docs/apis/features/agi/file-tools.md +163 -0
  15. package/docs/apis/features/agi/openapi.md +2 -2
  16. package/docs/apis/features/agi/skills-library.md +227 -0
  17. package/docs/apis/features/node/content-db.md +125 -4
  18. package/docs/apis/features/node/disk-cache.md +11 -11
  19. package/docs/apis/features/node/downloader.md +1 -1
  20. package/docs/apis/features/node/file-manager.md +15 -15
  21. package/docs/apis/features/node/fs.md +78 -21
  22. package/docs/apis/features/node/git.md +50 -10
  23. package/docs/apis/features/node/google-calendar.md +3 -0
  24. package/docs/apis/features/node/google-docs.md +10 -1
  25. package/docs/apis/features/node/google-drive.md +3 -0
  26. package/docs/apis/features/node/google-mail.md +214 -0
  27. package/docs/apis/features/node/google-sheets.md +3 -0
  28. package/docs/apis/features/node/ink.md +10 -10
  29. package/docs/apis/features/node/ipc-socket.md +83 -93
  30. package/docs/apis/features/node/networking.md +5 -5
  31. package/docs/apis/features/node/os.md +7 -7
  32. package/docs/apis/features/node/package-finder.md +14 -14
  33. package/docs/apis/features/node/proc.md +2 -1
  34. package/docs/apis/features/node/process-manager.md +70 -3
  35. package/docs/apis/features/node/python.md +265 -9
  36. package/docs/apis/features/node/redis.md +380 -0
  37. package/docs/apis/features/node/ui.md +13 -13
  38. package/docs/apis/servers/express.md +35 -7
  39. package/docs/apis/servers/mcp.md +3 -3
  40. package/docs/apis/servers/websocket.md +51 -8
  41. package/docs/bootstrap/CLAUDE.md +1 -1
  42. package/docs/bootstrap/SKILL.md +93 -7
  43. package/docs/examples/feature-as-tool-provider.md +143 -0
  44. package/docs/examples/python.md +42 -1
  45. package/docs/introspection.md +15 -5
  46. package/docs/tutorials/00-bootstrap.md +3 -3
  47. package/docs/tutorials/02-container.md +2 -2
  48. package/docs/tutorials/10-creating-features.md +5 -0
  49. package/docs/tutorials/13-introspection.md +12 -2
  50. package/docs/tutorials/19-python-sessions.md +401 -0
  51. package/package.json +8 -4
  52. package/src/agi/container.server.ts +8 -0
  53. package/src/agi/features/assistant.ts +19 -0
  54. package/src/agi/features/autonomous-assistant.ts +435 -0
  55. package/src/agi/features/conversation.ts +58 -6
  56. package/src/agi/features/file-tools.ts +286 -0
  57. package/src/agi/features/luca-coder.ts +643 -0
  58. package/src/bootstrap/generated.ts +705 -17
  59. package/src/cli/build-info.ts +2 -2
  60. package/src/cli/cli.ts +22 -13
  61. package/src/commands/bootstrap.ts +49 -6
  62. package/src/commands/code.ts +369 -0
  63. package/src/commands/describe.ts +7 -2
  64. package/src/commands/index.ts +1 -0
  65. package/src/commands/sandbox-mcp.ts +7 -7
  66. package/src/commands/save-api-docs.ts +1 -1
  67. package/src/container-describer.ts +4 -4
  68. package/src/container.ts +10 -19
  69. package/src/helper.ts +24 -33
  70. package/src/introspection/generated.agi.ts +2499 -63
  71. package/src/introspection/generated.node.ts +1625 -688
  72. package/src/introspection/generated.web.ts +15 -57
  73. package/src/node/container.ts +5 -0
  74. package/src/node/features/figlet-fonts.ts +597 -0
  75. package/src/node/features/fs.ts +3 -9
  76. package/src/node/features/helpers.ts +20 -0
  77. package/src/node/features/python.ts +429 -16
  78. package/src/node/features/redis.ts +446 -0
  79. package/src/node/features/ui.ts +4 -11
  80. package/src/python/bridge.py +220 -0
  81. package/src/python/generated.ts +227 -0
  82. package/src/scaffolds/generated.ts +1 -1
  83. package/test/python-session.test.ts +105 -0
  84. package/assistants/lucaExpert/CORE.md +0 -37
  85. package/assistants/lucaExpert/hooks.ts +0 -9
  86. 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
+ }
@@ -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 && figlet) {
261
- this.state.set("fonts", figlet.fontsSync());
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
- if (!figlet) return text;
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()