@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,643 @@
1
+ import { z } from 'zod'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
+ import { Feature } from '@soederpop/luca/feature'
4
+ import type { AGIContainer } from '../container.server.js'
5
+ import type { Assistant } from './assistant.js'
6
+ import type { ToolCallCtx } from '../lib/interceptor-chain.js'
7
+
8
+ declare module '@soederpop/luca/feature' {
9
+ interface AvailableFeatures {
10
+ lucaCoder: typeof LucaCoder
11
+ }
12
+ }
13
+
14
+ /** Permission level for a tool. 'allow' runs immediately, 'ask' blocks for user approval, 'deny' rejects. */
15
+ export type PermissionLevel = 'allow' | 'ask' | 'deny'
16
+
17
+ /** A pending approval awaiting user decision. */
18
+ export interface PendingApproval {
19
+ id: string
20
+ toolName: string
21
+ args: Record<string, any>
22
+ timestamp: number
23
+ resolve: (decision: 'approve' | 'deny') => void
24
+ }
25
+
26
+ /** Tool bundle spec — either a feature name string, or an object with filtering. */
27
+ export type ToolBundleSpec = string | {
28
+ feature: string
29
+ only?: string[]
30
+ except?: string[]
31
+ }
32
+
33
+ export const LucaCoderEventsSchema = FeatureEventsSchema.extend({
34
+ started: z.tuple([]).describe('Emitted when the luca coder has been initialized'),
35
+ permissionRequest: z.tuple([z.object({
36
+ id: z.string().describe('Unique approval ID'),
37
+ toolName: z.string().describe('The tool requesting permission'),
38
+ args: z.record(z.string(), z.any()).describe('The arguments the tool was called with'),
39
+ })]).describe('Emitted when a tool call requires user approval'),
40
+ permissionGranted: z.tuple([z.string().describe('Approval ID')]).describe('Emitted when a pending tool call is approved'),
41
+ permissionDenied: z.tuple([z.string().describe('Approval ID')]).describe('Emitted when a pending tool call is denied'),
42
+ toolBlocked: z.tuple([z.string().describe('Tool name'), z.string().describe('Reason')]).describe('Emitted when a tool call is blocked by deny policy'),
43
+ // Forwarded from inner assistant
44
+ chunk: z.tuple([z.string().describe('A chunk of streamed text')]).describe('Forwarded: streamed token chunk from the inner assistant'),
45
+ response: z.tuple([z.string().describe('The final response text')]).describe('Forwarded: complete response from the inner assistant'),
46
+ toolCall: z.tuple([z.string().describe('Tool name'), z.any().describe('Tool arguments')]).describe('Forwarded: a tool was called'),
47
+ toolResult: z.tuple([z.string().describe('Tool name'), z.any().describe('Result value')]).describe('Forwarded: a tool returned a result'),
48
+ toolError: z.tuple([z.string().describe('Tool name'), z.any().describe('Error')]).describe('Forwarded: a tool call failed'),
49
+ })
50
+
51
+ export const LucaCoderStateSchema = FeatureStateSchema.extend({
52
+ started: z.boolean().describe('Whether the assistant has been initialized'),
53
+ permissions: z.record(z.string(), z.enum(['allow', 'ask', 'deny'])).describe('Permission level per tool name'),
54
+ defaultPermission: z.enum(['allow', 'ask', 'deny']).describe('Permission level for tools not explicitly configured'),
55
+ pendingApprovals: z.array(z.object({
56
+ id: z.string(),
57
+ toolName: z.string(),
58
+ args: z.record(z.string(), z.any()),
59
+ timestamp: z.number(),
60
+ })).describe('Tool calls currently awaiting user approval'),
61
+ approvalHistory: z.array(z.object({
62
+ id: z.string(),
63
+ toolName: z.string(),
64
+ decision: z.enum(['approve', 'deny']),
65
+ timestamp: z.number(),
66
+ })).describe('Recent approval decisions'),
67
+ loadedSkills: z.array(z.string()).describe('Names of skills auto-loaded into context'),
68
+ })
69
+
70
+ export const LucaCoderOptionsSchema = FeatureOptionsSchema.extend({
71
+ /** Tool bundles to stack — feature names or objects with filtering. */
72
+ tools: z.array(z.union([
73
+ z.string(),
74
+ z.object({
75
+ feature: z.string(),
76
+ only: z.array(z.string()).optional(),
77
+ except: z.array(z.string()).optional(),
78
+ }),
79
+ ])).default(['fileTools']).describe('Tool bundles to register on the inner assistant'),
80
+
81
+ /** Per-tool permission overrides. */
82
+ permissions: z.record(z.string(), z.enum(['allow', 'ask', 'deny'])).default({}).describe('Permission level per tool name'),
83
+
84
+ /** Default permission for tools not in the permissions map. */
85
+ defaultPermission: z.enum(['allow', 'ask', 'deny']).default('ask').describe('Default permission level for unconfigured tools'),
86
+
87
+ /** System prompt for the inner assistant. */
88
+ systemPrompt: z.string().optional().describe('System prompt for the inner assistant'),
89
+
90
+ /** Model to use. */
91
+ model: z.string().optional().describe('OpenAI model override'),
92
+
93
+ /** Maximum output tokens per completion. */
94
+ maxTokens: z.number().default(2048).describe('Maximum number of output tokens per completion'),
95
+
96
+ /** Whether to use a local API server. */
97
+ local: z.boolean().default(false).describe('Use a local API server for the inner assistant'),
98
+
99
+ /** History mode for the inner assistant. */
100
+ historyMode: z.enum(['lifecycle', 'daily', 'persistent', 'session']).optional().describe('Conversation history persistence mode'),
101
+
102
+ /** Assistant folder — if provided, loads CORE.md/tools.ts/hooks.ts from disk. */
103
+ folder: z.string().optional().describe('Assistant folder for disk-based definitions'),
104
+
105
+ /** Skills to auto-load into the system prompt context. If not specified, auto-detects luca-framework. */
106
+ skills: z.array(z.string()).optional().describe('Skill names to auto-load into the system prompt'),
107
+
108
+ /** Whether to auto-detect and load the luca-framework skill. Defaults to true. */
109
+ autoLoadLucaSkill: z.boolean().default(true).describe('Auto-load luca-framework skill if found in .claude/skills path'),
110
+ })
111
+
112
+ export type LucaCoderState = z.infer<typeof LucaCoderStateSchema>
113
+ export type LucaCoderOptions = z.infer<typeof LucaCoderOptionsSchema>
114
+
115
+ /**
116
+ * A coding assistant that owns a lower-level Assistant instance and
117
+ * gates all tool calls through a permission system.
118
+ *
119
+ * Comes with built-in Bash tool (via proc.execAndCapture) and auto-loads
120
+ * the luca-framework skill when found in .claude/skills paths.
121
+ *
122
+ * Tools are stacked from feature bundles (fileTools, etc.)
123
+ * and each tool can be set to 'allow' (runs immediately), 'ask' (blocks
124
+ * until user approves/denies), or 'deny' (always rejected).
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const coder = container.feature('lucaCoder', {
129
+ * tools: ['fileTools'],
130
+ * permissions: {
131
+ * readFile: 'allow',
132
+ * searchFiles: 'allow',
133
+ * writeFile: 'ask',
134
+ * bash: 'ask',
135
+ * },
136
+ * defaultPermission: 'ask',
137
+ * systemPrompt: 'You are a coding assistant.',
138
+ * })
139
+ *
140
+ * coder.on('permissionRequest', ({ id, toolName, args }) => {
141
+ * console.log(`Tool "${toolName}" wants to run with`, args)
142
+ * coder.approve(id) // or coder.deny(id)
143
+ * })
144
+ *
145
+ * await coder.ask('Refactor the auth module to use async/await')
146
+ * ```
147
+ *
148
+ * @extends Feature
149
+ */
150
+ export class LucaCoder extends Feature<LucaCoderState, LucaCoderOptions> {
151
+ static override shortcut = 'features.lucaCoder' as const
152
+ static override stateSchema = LucaCoderStateSchema
153
+ static override optionsSchema = LucaCoderOptionsSchema
154
+ static override eventsSchema = LucaCoderEventsSchema
155
+
156
+ static { Feature.register(this, 'lucaCoder') }
157
+
158
+ /**
159
+ * Default system prompt that establishes baseline coding assistant identity
160
+ * and luca framework knowledge. Project-specific CLAUDE.md and loaded skills
161
+ * are appended below this — the skill will reinforce and expand on these
162
+ * fundamentals without conflicting.
163
+ */
164
+ static defaultSystemPrompt = [
165
+ 'You are an autonomous coding assistant operating inside a luca framework project.',
166
+ 'You have access to file system tools and a bash shell. Use them to explore, understand, and modify code.',
167
+ '',
168
+ '## Working Style',
169
+ '- Always read and understand code before modifying it. Use searchFiles and readFile first.',
170
+ '- Use editFile for surgical changes to existing files — prefer it over writeFile for modifications.',
171
+ '- Explain what you plan to do before doing it.',
172
+ '- Deliver incrementally — each change should leave the project in a working state.',
173
+ '',
174
+ '## The Luca Framework',
175
+ 'This project uses luca — a dependency injection container for bun. The `luca` CLI is available in your shell.',
176
+ '',
177
+ '### Essential CLI Commands',
178
+ '- `luca` — list all available commands',
179
+ '- `luca describe features` / `luca describe clients` / `luca describe servers` — see what the container provides',
180
+ '- `luca describe <name>` — full docs for any feature, client, or server (e.g. `luca describe fs`, `luca describe git`)',
181
+ '- `luca describe <name>.<member>` — docs for a specific method or getter',
182
+ '- `luca eval "expression"` — run JS/TS with the container in scope (great for testing ideas)',
183
+ '- `luca scaffold <type> <name>` — generate boilerplate for new helpers',
184
+ '- `luca scaffold <type> --tutorial` — read the full guide for building that helper type',
185
+ '',
186
+ '### Container Rules',
187
+ '- NEVER import from `fs`, `path`, or other Node builtins. Use `container.feature(\'fs\')` and `container.paths`.',
188
+ '- The container provides everything you need: YAML, SQLite, REST client, grep, chalk, lodash, uuid, and more.',
189
+ '- Use `luca describe` liberally — it is faster and more reliable than searching source files.',
190
+ '- Use `luca eval` to test container code before wiring up full handlers.',
191
+ '',
192
+ '### Project Structure',
193
+ '- `commands/` — CLI commands run via `luca <name>`',
194
+ '- `endpoints/` — HTTP routes served via `luca serve`',
195
+ '- `features/` — custom container features',
196
+ '- Auto-discovered modules: commands, endpoints, features, clients, servers',
197
+ ].join('\n')
198
+
199
+ /** The inner assistant instance. Created during start(). */
200
+ private _assistant: Assistant | null = null
201
+
202
+ /** Map of pending approval promises keyed by ID. */
203
+ private _pendingResolvers = new Map<string, (decision: 'approve' | 'deny') => void>()
204
+
205
+ override get initialState(): LucaCoderState {
206
+ return {
207
+ ...super.initialState,
208
+ started: false,
209
+ permissions: this.options.permissions || {},
210
+ defaultPermission: this.options.defaultPermission || 'ask',
211
+ pendingApprovals: [],
212
+ approvalHistory: [],
213
+ loadedSkills: [],
214
+ }
215
+ }
216
+
217
+ override get container(): AGIContainer {
218
+ return super.container as AGIContainer
219
+ }
220
+
221
+ /** The inner assistant. Throws if not started. */
222
+ get assistant(): Assistant {
223
+ if (!this._assistant) throw new Error('LucaCoder not started. Call start() first.')
224
+ return this._assistant
225
+ }
226
+
227
+ /** Current permission map from state. */
228
+ get permissions(): Record<string, PermissionLevel> {
229
+ return this.state.get('permissions') as Record<string, PermissionLevel>
230
+ }
231
+
232
+ /** Current pending approvals. */
233
+ get pendingApprovals(): PendingApproval[] {
234
+ const stored = this.state.get('pendingApprovals') as Array<{ id: string; toolName: string; args: Record<string, any>; timestamp: number }>
235
+ return stored.map(p => ({
236
+ ...p,
237
+ resolve: this._pendingResolvers.get(p.id) || (() => {}),
238
+ }))
239
+ }
240
+
241
+ /** Whether the assistant is started and ready. */
242
+ get isStarted(): boolean {
243
+ return this.state.get('started') as boolean
244
+ }
245
+
246
+ /** The tools registered on the inner assistant. */
247
+ get tools(): Record<string, any> {
248
+ return this._assistant?.tools || {}
249
+ }
250
+
251
+ /** The conversation on the inner assistant (if started). */
252
+ get conversation() {
253
+ return this._assistant?.conversation
254
+ }
255
+
256
+ /** Messages from the inner assistant's conversation. */
257
+ get messages() {
258
+ return this._assistant?.messages || []
259
+ }
260
+
261
+ // -------------------------------------------------------------------------
262
+ // Permission management
263
+ // -------------------------------------------------------------------------
264
+
265
+ /** Get the effective permission level for a tool. */
266
+ getPermission(toolName: string): PermissionLevel {
267
+ const perms = this.permissions
268
+ if (perms[toolName]) return perms[toolName]
269
+ return this.state.get('defaultPermission') as PermissionLevel
270
+ }
271
+
272
+ /** Set permission level for one or more tools. */
273
+ setPermission(toolName: string | string[], level: PermissionLevel): this {
274
+ const names = Array.isArray(toolName) ? toolName : [toolName]
275
+ const perms = { ...this.permissions }
276
+ for (const name of names) {
277
+ perms[name] = level
278
+ }
279
+ this.state.set('permissions', perms)
280
+ return this
281
+ }
282
+
283
+ /** Set the default permission level for unconfigured tools. */
284
+ setDefaultPermission(level: PermissionLevel): this {
285
+ this.state.set('defaultPermission', level)
286
+ return this
287
+ }
288
+
289
+ /** Allow a tool (or tools) to run without approval. */
290
+ permitTool(...toolNames: string[]): this {
291
+ return this.setPermission(toolNames, 'allow')
292
+ }
293
+
294
+ /** Require approval before a tool (or tools) can run. */
295
+ gateTool(...toolNames: string[]): this {
296
+ return this.setPermission(toolNames, 'ask')
297
+ }
298
+
299
+ /** Block a tool (or tools) from ever running. */
300
+ blockTool(...toolNames: string[]): this {
301
+ return this.setPermission(toolNames, 'deny')
302
+ }
303
+
304
+ // -------------------------------------------------------------------------
305
+ // Approval flow
306
+ // -------------------------------------------------------------------------
307
+
308
+ /** Approve a pending tool call by ID. The tool will execute. */
309
+ approve(id: string): this {
310
+ const resolver = this._pendingResolvers.get(id)
311
+ if (resolver) {
312
+ resolver('approve')
313
+ this._removePending(id)
314
+ this._recordDecision(id, 'approve')
315
+ this.emit('permissionGranted', id)
316
+ }
317
+ return this
318
+ }
319
+
320
+ /** Deny a pending tool call by ID. The tool call will be skipped. */
321
+ deny(id: string): this {
322
+ const resolver = this._pendingResolvers.get(id)
323
+ if (resolver) {
324
+ resolver('deny')
325
+ this._removePending(id)
326
+ this._recordDecision(id, 'deny')
327
+ this.emit('permissionDenied', id)
328
+ }
329
+ return this
330
+ }
331
+
332
+ /** Approve all pending tool calls. */
333
+ approveAll(): this {
334
+ for (const { id } of this.pendingApprovals) {
335
+ this.approve(id)
336
+ }
337
+ return this
338
+ }
339
+
340
+ /** Deny all pending tool calls. */
341
+ denyAll(): this {
342
+ for (const { id } of this.pendingApprovals) {
343
+ this.deny(id)
344
+ }
345
+ return this
346
+ }
347
+
348
+ // -------------------------------------------------------------------------
349
+ // Bash tool
350
+ // -------------------------------------------------------------------------
351
+
352
+ /**
353
+ * Execute a shell command string and return its output.
354
+ * Uses proc.execAndCapture under the hood — runs `sh -c <command>`.
355
+ */
356
+ async bash({ command, cwd, timeout }: { command: string; cwd?: string; timeout?: number }): Promise<{
357
+ exitCode: number
358
+ stdout: string
359
+ stderr: string
360
+ success: boolean
361
+ }> {
362
+ const proc = this.container.feature('proc')
363
+ const options: Record<string, any> = {
364
+ cwd: cwd ?? this.container.cwd,
365
+ }
366
+ if (timeout) options.timeout = timeout
367
+
368
+ const result = await proc.execAndCapture(command, options)
369
+
370
+ return {
371
+ exitCode: result.exitCode,
372
+ stdout: result.stdout,
373
+ stderr: result.stderr,
374
+ success: result.exitCode === 0,
375
+ }
376
+ }
377
+
378
+ // -------------------------------------------------------------------------
379
+ // Project instructions
380
+ // -------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Read project instruction files (CLAUDE.md, AGENTS.md, LUCA.md) from the
384
+ * project root if they exist, and return their combined content.
385
+ */
386
+ private _loadProjectInstructions(): string {
387
+ const { fs, paths } = this.container
388
+ const cwd = this.container.cwd
389
+ const candidates = ['CLAUDE.md', 'AGENTS.md', 'LUCA.md']
390
+ const sections: string[] = []
391
+ const seen = new Set<string>()
392
+
393
+ for (const filename of candidates) {
394
+ const filePath = paths.resolve(cwd, filename)
395
+ if (!fs.exists(filePath)) continue
396
+
397
+ // Resolve symlinks so we don't read the same file twice
398
+ const realPath = fs.realpath(filePath)
399
+ if (seen.has(realPath)) continue
400
+ seen.add(realPath)
401
+
402
+ const content = fs.readFile(filePath)
403
+ sections.push(`# ${filename}\n\n${content}`)
404
+ }
405
+
406
+ return sections.join('\n\n---\n\n')
407
+ }
408
+
409
+ // -------------------------------------------------------------------------
410
+ // Skill loading
411
+ // -------------------------------------------------------------------------
412
+
413
+ /**
414
+ * Detect and load skills into the system prompt context.
415
+ * Auto-loads luca-framework if found and autoLoadLucaSkill is true.
416
+ */
417
+ private async _loadSkillsIntoContext(): Promise<string> {
418
+ const { fs, paths, os } = this.container
419
+ const skillContent: string[] = []
420
+ const loadedSkills: string[] = []
421
+
422
+ // Check for luca-framework skill in known locations
423
+ if (this.options.autoLoadLucaSkill !== false) {
424
+ const skillLocations = [
425
+ paths.resolve(this.container.cwd, '.claude', 'skills', 'luca-framework', 'SKILL.md'),
426
+ paths.resolve(os.homedir, '.claude', 'skills', 'luca-framework', 'SKILL.md'),
427
+ ]
428
+
429
+ for (const skillPath of skillLocations) {
430
+ if (fs.exists(skillPath)) {
431
+ const content = fs.readFile(skillPath)
432
+ skillContent.push(`# Skill: luca-framework\n\n${content}`)
433
+ loadedSkills.push('luca-framework')
434
+ break // Only load once
435
+ }
436
+ }
437
+ }
438
+
439
+ // Load explicitly requested skills via skillsLibrary
440
+ if (this.options.skills?.length) {
441
+ const lib = this.container.feature('skillsLibrary')
442
+ if (!lib.isStarted) await lib.start()
443
+
444
+ for (const skillName of this.options.skills) {
445
+ if (loadedSkills.includes(skillName)) continue
446
+ const skill = lib.find(skillName)
447
+ if (skill) {
448
+ const content = fs.readFile(skill.skillFilePath)
449
+ skillContent.push(`# Skill: ${skillName}\n\n${content}`)
450
+ loadedSkills.push(skillName)
451
+ }
452
+ }
453
+ }
454
+
455
+ this.state.set('loadedSkills', loadedSkills)
456
+ return skillContent.join('\n\n---\n\n')
457
+ }
458
+
459
+ // -------------------------------------------------------------------------
460
+ // Lifecycle
461
+ // -------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Initialize the inner assistant, register the bash tool, stack tool bundles,
465
+ * auto-load skills, and wire up the permission interceptor.
466
+ */
467
+ async start(): Promise<this> {
468
+ if (this.isStarted) return this
469
+
470
+ // Load project instructions and skill content for system prompt
471
+ const projectInstructions = this._loadProjectInstructions()
472
+ const skillContext = await this._loadSkillsIntoContext()
473
+
474
+ // Build the system prompt: default baseline is always present,
475
+ // user-provided systemPrompt is additive on top of it
476
+ const parts: string[] = [LucaCoder.defaultSystemPrompt]
477
+ if (this.options.systemPrompt) parts.push(this.options.systemPrompt)
478
+ if (projectInstructions) parts.push(projectInstructions)
479
+ if (skillContext) parts.push(skillContext)
480
+ const systemPrompt = parts.join('\n\n---\n\n')
481
+
482
+ // Create the inner assistant
483
+ const assistantOpts: Record<string, any> = {}
484
+ if (systemPrompt) assistantOpts.systemPrompt = systemPrompt
485
+ if (this.options.model) assistantOpts.model = this.options.model
486
+ if (this.options.maxTokens) assistantOpts.maxTokens = this.options.maxTokens
487
+ if (this.options.local) assistantOpts.local = this.options.local
488
+ if (this.options.historyMode) assistantOpts.historyMode = this.options.historyMode
489
+ if (this.options.folder) assistantOpts.folder = this.options.folder
490
+
491
+ this._assistant = this.container.feature('assistant', assistantOpts)
492
+
493
+ // Register the built-in bash tool
494
+ const bashSchema = z.object({
495
+ command: z.string().describe('The shell command to execute. Supports pipes, redirects, chaining with && or ;'),
496
+ cwd: z.string().optional().describe('Working directory for the command. Defaults to project root.'),
497
+ timeout: z.number().optional().describe('Timeout in milliseconds. Defaults to no timeout.'),
498
+ }).describe('Execute a shell command and return stdout, stderr, and exit code. Use this for running builds, tests, git commands, installing packages, or any shell operation.')
499
+
500
+ this._assistant.addTool(
501
+ 'bash',
502
+ (args: { command: string; cwd?: string; timeout?: number }) => this.bash(args),
503
+ bashSchema,
504
+ )
505
+
506
+ // Stack tool bundles
507
+ for (const spec of this.options.tools) {
508
+ this._stackToolBundle(spec)
509
+ }
510
+
511
+ // Always register skillsLibrary tools so the assistant can discover
512
+ // and load additional skills dynamically during a conversation
513
+ const skillsLib = this.container.feature('skillsLibrary')
514
+ if (!skillsLib.isStarted) await skillsLib.start()
515
+ this._assistant.use(skillsLib as any)
516
+
517
+ // Wire the permission interceptor
518
+ this._assistant.intercept('beforeToolCall', async (ctx: ToolCallCtx, next: () => Promise<void>) => {
519
+ const policy = this.getPermission(ctx.name)
520
+
521
+ if (policy === 'deny') {
522
+ ctx.skip = true
523
+ ctx.result = JSON.stringify({ blocked: true, tool: ctx.name, reason: 'Permission denied by policy.' })
524
+ this.emit('toolBlocked', ctx.name, 'deny policy')
525
+ return
526
+ }
527
+
528
+ if (policy === 'allow') {
529
+ await next()
530
+ return
531
+ }
532
+
533
+ // 'ask' — block until user decides
534
+ const decision = await this._requestApproval(ctx.name, ctx.args)
535
+
536
+ if (decision === 'approve') {
537
+ await next()
538
+ } else {
539
+ ctx.skip = true
540
+ ctx.result = JSON.stringify({ blocked: true, tool: ctx.name, reason: 'User denied this action.' })
541
+ }
542
+ })
543
+
544
+ // Forward events from inner assistant
545
+ this._assistant.on('chunk', (text: string) => this.emit('chunk', text))
546
+ this._assistant.on('response', (text: string) => this.emit('response', text))
547
+ this._assistant.on('toolCall', (name: string, args: any) => this.emit('toolCall', name, args))
548
+ this._assistant.on('toolResult', (name: string, result: any) => this.emit('toolResult', name, result))
549
+ this._assistant.on('toolError', (name: string, error: any) => this.emit('toolError', name, error))
550
+
551
+ // Start the inner assistant
552
+ await this._assistant.start()
553
+
554
+ this.state.set('started', true)
555
+ this.emit('started')
556
+
557
+ return this
558
+ }
559
+
560
+ /**
561
+ * Ask the coder a question. Auto-starts if needed.
562
+ * Tool calls will be gated by the permission system.
563
+ */
564
+ async ask(question: string, options?: Record<string, any>): Promise<string> {
565
+ if (!this.isStarted) await this.start()
566
+ return this.assistant.ask(question, options)
567
+ }
568
+
569
+ /**
570
+ * Add a tool bundle after initialization. Useful for dynamically
571
+ * extending the assistant's capabilities.
572
+ */
573
+ use(spec: ToolBundleSpec): this {
574
+ this._stackToolBundle(spec)
575
+ return this
576
+ }
577
+
578
+ // -------------------------------------------------------------------------
579
+ // Internal
580
+ // -------------------------------------------------------------------------
581
+
582
+ /** Resolve a tool bundle spec and register its tools on the inner assistant. */
583
+ private _stackToolBundle(spec: ToolBundleSpec): void {
584
+ if (!this._assistant) throw new Error('Cannot stack tools before start()')
585
+
586
+ const featureName = typeof spec === 'string' ? spec : spec.feature
587
+ const feature = this.container.feature(featureName as any)
588
+
589
+ if (typeof spec === 'string') {
590
+ // No filtering — pass the feature directly so assistant.use() calls
591
+ // both _registerTools AND setupToolsConsumer (for system prompt extensions)
592
+ this._assistant.use(feature as any)
593
+ } else {
594
+ // Filtering with only/except — must call toTools with filter opts,
595
+ // then manually trigger setupToolsConsumer
596
+ const filterOpts = { only: spec.only, except: spec.except }
597
+ const tools = (feature as any).toTools(filterOpts)
598
+ this._assistant.use(tools)
599
+ if (typeof (feature as any).setupToolsConsumer === 'function') {
600
+ (feature as any).setupToolsConsumer(this._assistant)
601
+ }
602
+ }
603
+ }
604
+
605
+ /** Create a pending approval, emit the event, and return a promise that resolves with the decision. */
606
+ private _requestApproval(toolName: string, args: Record<string, any>): Promise<'approve' | 'deny'> {
607
+ const id = this.container.utils.uuid()
608
+
609
+ return new Promise<'approve' | 'deny'>((resolve) => {
610
+ this._pendingResolvers.set(id, resolve)
611
+
612
+ const pending = [...(this.state.get('pendingApprovals') as any[])]
613
+ pending.push({ id, toolName, args, timestamp: Date.now() })
614
+ this.state.set('pendingApprovals', pending)
615
+
616
+ this.emit('permissionRequest', { id, toolName, args })
617
+ })
618
+ }
619
+
620
+ /** Remove a pending approval from state. */
621
+ private _removePending(id: string): void {
622
+ this._pendingResolvers.delete(id)
623
+ const pending = (this.state.get('pendingApprovals') as any[]).filter((p: any) => p.id !== id)
624
+ this.state.set('pendingApprovals', pending)
625
+ }
626
+
627
+ /** Record a decision in the approval history. */
628
+ private _recordDecision(id: string, decision: 'approve' | 'deny'): void {
629
+ const pending = (this.state.get('pendingApprovals') as any[]).find((p: any) => p.id === id)
630
+ const history = [...(this.state.get('approvalHistory') as any[])]
631
+ history.push({
632
+ id,
633
+ toolName: pending?.toolName || 'unknown',
634
+ decision,
635
+ timestamp: Date.now(),
636
+ })
637
+ // Keep last 100 entries
638
+ if (history.length > 100) history.splice(0, history.length - 100)
639
+ this.state.set('approvalHistory', history)
640
+ }
641
+ }
642
+
643
+ export default LucaCoder