@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.
- 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 +19 -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 +2499 -63
- 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,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
|