@soederpop/luca 0.0.32 → 0.0.35

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 (92) hide show
  1. package/README.md +241 -36
  2. package/bun.lock +24 -6
  3. package/commands/build-python-bridge.ts +43 -0
  4. package/docs/README.md +1 -1
  5. package/docs/TABLE-OF-CONTENTS.md +0 -1
  6. package/docs/apis/clients/rest.md +7 -7
  7. package/docs/apis/clients/websocket.md +23 -10
  8. package/docs/apis/features/agi/assistant.md +155 -8
  9. package/docs/apis/features/agi/assistants-manager.md +90 -22
  10. package/docs/apis/features/agi/auto-assistant.md +377 -0
  11. package/docs/apis/features/agi/browser-use.md +802 -0
  12. package/docs/apis/features/agi/claude-code.md +6 -1
  13. package/docs/apis/features/agi/conversation-history.md +7 -6
  14. package/docs/apis/features/agi/conversation.md +111 -38
  15. package/docs/apis/features/agi/docs-reader.md +35 -57
  16. package/docs/apis/features/agi/file-tools.md +163 -0
  17. package/docs/apis/features/agi/openapi.md +2 -2
  18. package/docs/apis/features/agi/skills-library.md +227 -0
  19. package/docs/apis/features/node/content-db.md +125 -4
  20. package/docs/apis/features/node/disk-cache.md +11 -11
  21. package/docs/apis/features/node/downloader.md +1 -1
  22. package/docs/apis/features/node/file-manager.md +15 -15
  23. package/docs/apis/features/node/fs.md +78 -21
  24. package/docs/apis/features/node/git.md +50 -10
  25. package/docs/apis/features/node/google-calendar.md +3 -0
  26. package/docs/apis/features/node/google-docs.md +10 -1
  27. package/docs/apis/features/node/google-drive.md +3 -0
  28. package/docs/apis/features/node/google-mail.md +214 -0
  29. package/docs/apis/features/node/google-sheets.md +3 -0
  30. package/docs/apis/features/node/ink.md +10 -10
  31. package/docs/apis/features/node/ipc-socket.md +83 -93
  32. package/docs/apis/features/node/networking.md +5 -5
  33. package/docs/apis/features/node/os.md +7 -7
  34. package/docs/apis/features/node/package-finder.md +14 -14
  35. package/docs/apis/features/node/proc.md +2 -1
  36. package/docs/apis/features/node/process-manager.md +70 -3
  37. package/docs/apis/features/node/python.md +265 -9
  38. package/docs/apis/features/node/redis.md +380 -0
  39. package/docs/apis/features/node/ui.md +13 -13
  40. package/docs/apis/servers/express.md +35 -7
  41. package/docs/apis/servers/mcp.md +3 -3
  42. package/docs/apis/servers/websocket.md +51 -8
  43. package/docs/bootstrap/CLAUDE.md +1 -1
  44. package/docs/bootstrap/SKILL.md +93 -7
  45. package/docs/examples/feature-as-tool-provider.md +143 -0
  46. package/docs/examples/python.md +42 -1
  47. package/docs/introspection.md +15 -5
  48. package/docs/tutorials/00-bootstrap.md +3 -3
  49. package/docs/tutorials/02-container.md +2 -2
  50. package/docs/tutorials/10-creating-features.md +5 -0
  51. package/docs/tutorials/13-introspection.md +12 -2
  52. package/docs/tutorials/19-python-sessions.md +401 -0
  53. package/package.json +8 -5
  54. package/scripts/examples/using-assistant-with-mcp.ts +2 -7
  55. package/scripts/test-linux-binary.sh +80 -0
  56. package/src/agi/container.server.ts +8 -0
  57. package/src/agi/features/assistant.ts +18 -0
  58. package/src/agi/features/autonomous-assistant.ts +435 -0
  59. package/src/agi/features/conversation.ts +58 -6
  60. package/src/agi/features/file-tools.ts +286 -0
  61. package/src/agi/features/luca-coder.ts +643 -0
  62. package/src/bootstrap/generated.ts +705 -107
  63. package/src/cli/build-info.ts +2 -2
  64. package/src/cli/cli.ts +22 -13
  65. package/src/commands/bootstrap.ts +49 -6
  66. package/src/commands/code.ts +369 -0
  67. package/src/commands/describe.ts +7 -2
  68. package/src/commands/index.ts +1 -0
  69. package/src/commands/sandbox-mcp.ts +7 -7
  70. package/src/commands/save-api-docs.ts +1 -1
  71. package/src/container-describer.ts +4 -4
  72. package/src/container.ts +10 -19
  73. package/src/helper.ts +24 -33
  74. package/src/introspection/generated.agi.ts +3026 -849
  75. package/src/introspection/generated.node.ts +1690 -1012
  76. package/src/introspection/generated.web.ts +15 -57
  77. package/src/node/container.ts +5 -5
  78. package/src/node/features/figlet-fonts.ts +597 -0
  79. package/src/node/features/fs.ts +3 -9
  80. package/src/node/features/helpers.ts +20 -0
  81. package/src/node/features/python.ts +429 -16
  82. package/src/node/features/redis.ts +446 -0
  83. package/src/node/features/ui.ts +4 -11
  84. package/src/python/bridge.py +220 -0
  85. package/src/python/generated.ts +227 -0
  86. package/src/scaffolds/generated.ts +1 -1
  87. package/test/python-session.test.ts +105 -0
  88. package/assistants/lucaExpert/CORE.md +0 -37
  89. package/assistants/lucaExpert/hooks.ts +0 -9
  90. package/assistants/lucaExpert/tools.ts +0 -177
  91. package/docs/examples/port-exposer.md +0 -89
  92. package/src/node/features/port-exposer.ts +0 -351
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # CI-style test: cross-compile luca for Linux and verify it runs in a Docker container
5
+ # Usage: bash scripts/test-linux-binary.sh
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
9
+ DIST_DIR="$PROJECT_DIR/dist"
10
+ LINUX_BINARY="$DIST_DIR/luca-linux"
11
+
12
+ echo "=== Cross-compiling luca for linux-arm64 ==="
13
+ cd "$PROJECT_DIR"
14
+
15
+ # Run the pre-compile build steps (introspection, scaffolds, etc.)
16
+ bun run build:introspection
17
+ bun run build:scaffolds
18
+ bun run build:bootstrap
19
+ bun run build:python-bridge
20
+ bash scripts/stamp-build.sh
21
+
22
+ # Cross-compile for linux arm64 (matches Docker on Apple Silicon)
23
+ bun build ./src/cli/cli.ts --compile --target=bun-linux-arm64 --outfile "$LINUX_BINARY" --external node-llama-cpp
24
+
25
+ echo ""
26
+ echo "=== Built linux binary: $(file "$LINUX_BINARY") ==="
27
+ echo ""
28
+
29
+ # Create a minimal Dockerfile inline
30
+ DOCKER_TAG="luca-linux-test"
31
+
32
+ docker build -t "$DOCKER_TAG" -f - "$DIST_DIR" <<'DOCKERFILE'
33
+ FROM debian:bookworm-slim
34
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
35
+ WORKDIR /app
36
+ COPY luca-linux /usr/local/bin/luca
37
+ RUN chmod +x /usr/local/bin/luca
38
+ DOCKERFILE
39
+
40
+ echo ""
41
+ echo "=== Running smoke tests in Docker container ==="
42
+ echo ""
43
+
44
+ PASS=0
45
+ FAIL=0
46
+
47
+ run_test() {
48
+ local description="$1"
49
+ shift
50
+ echo -n " TEST: $description ... "
51
+ if output=$(docker run --rm "$DOCKER_TAG" "$@" 2>&1); then
52
+ echo "PASS"
53
+ PASS=$((PASS + 1))
54
+ else
55
+ echo "FAIL"
56
+ echo " Output: $output"
57
+ FAIL=$((FAIL + 1))
58
+ fi
59
+ }
60
+
61
+ # Basic smoke tests
62
+ run_test "binary executes" luca --version
63
+ run_test "help flag works" luca --help
64
+ run_test "eval runs JS expression" luca eval "1 + 1"
65
+ run_test "describe features" luca describe features
66
+ run_test "container basics via eval" luca eval "container.uuid"
67
+
68
+ echo ""
69
+ echo "=== Results: $PASS passed, $FAIL failed ==="
70
+
71
+ # Cleanup
72
+ docker rmi "$DOCKER_TAG" > /dev/null 2>&1 || true
73
+ rm -f "$LINUX_BINARY"
74
+
75
+ if [ "$FAIL" -gt 0 ]; then
76
+ echo "FAILED"
77
+ exit 1
78
+ fi
79
+
80
+ echo "ALL TESTS PASSED"
@@ -14,6 +14,8 @@ import { SkillsLibrary } from './features/skills-library'
14
14
  import { BrowserUse } from './features/browser-use'
15
15
  import { SemanticSearch } from '@soederpop/luca/node/features/semantic-search'
16
16
  import { ContentDb } from '@soederpop/luca/node/features/content-db'
17
+ import { FileTools } from './features/file-tools'
18
+ import { LucaCoder } from './features/luca-coder'
17
19
 
18
20
  import type { ConversationTool } from './features/conversation'
19
21
  import type { ZodType } from 'zod'
@@ -28,6 +30,8 @@ export {
28
30
  DocsReader,
29
31
  SkillsLibrary,
30
32
  BrowserUse,
33
+ FileTools,
34
+ LucaCoder,
31
35
  SemanticSearch,
32
36
  ContentDb,
33
37
  NodeContainer,
@@ -52,6 +56,8 @@ export interface AGIFeatures extends NodeFeatures {
52
56
  docsReader: typeof DocsReader
53
57
  skillsLibrary: typeof SkillsLibrary
54
58
  browserUse: typeof BrowserUse
59
+ fileTools: typeof FileTools
60
+ lucaCoder: typeof LucaCoder
55
61
  }
56
62
 
57
63
  export interface ConversationFactoryOptions {
@@ -125,6 +131,8 @@ const container = new AGIContainer()
125
131
  .use(DocsReader)
126
132
  .use(SkillsLibrary)
127
133
  .use(BrowserUse)
134
+ .use(FileTools)
135
+ .use(LucaCoder)
128
136
  .use(SemanticSearch)
129
137
 
130
138
  container.docs = container.feature('contentDb', {
@@ -79,6 +79,18 @@ export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
79
79
  /** Maximum number of output tokens per completion */
80
80
 
81
81
  maxTokens: z.number().optional().describe('Maximum number of output tokens per completion'),
82
+ /** Sampling temperature (0-2). Higher = more random, lower = more deterministic. */
83
+ temperature: z.number().min(0).max(2).optional().describe('Sampling temperature (0-2)'),
84
+ /** Nucleus sampling cutoff (0-1). */
85
+ topP: z.number().min(0).max(1).optional().describe('Nucleus sampling cutoff (0-1)'),
86
+ /** Top-K sampling. Only supported by local/Anthropic models. */
87
+ topK: z.number().optional().describe('Top-K sampling. Only supported by local/Anthropic models'),
88
+ /** Frequency penalty (-2 to 2). */
89
+ frequencyPenalty: z.number().min(-2).max(2).optional().describe('Frequency penalty (-2 to 2)'),
90
+ /** Presence penalty (-2 to 2). */
91
+ presencePenalty: z.number().min(-2).max(2).optional().describe('Presence penalty (-2 to 2)'),
92
+ /** Stop sequences. */
93
+ stop: z.array(z.string()).optional().describe('Stop sequences'),
82
94
 
83
95
  local: z.boolean().default(false).describe('Whether to use our local models for this'),
84
96
 
@@ -261,6 +273,12 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
261
273
  tools: this.tools,
262
274
  api: 'chat',
263
275
  ...(this.options.maxTokens ? { maxTokens: this.options.maxTokens } : {}),
276
+ ...(this.options.temperature != null ? { temperature: this.options.temperature } : {}),
277
+ ...(this.options.topP != null ? { topP: this.options.topP } : {}),
278
+ ...(this.options.topK != null ? { topK: this.options.topK } : {}),
279
+ ...(this.options.frequencyPenalty != null ? { frequencyPenalty: this.options.frequencyPenalty } : {}),
280
+ ...(this.options.presencePenalty != null ? { presencePenalty: this.options.presencePenalty } : {}),
281
+ ...(this.options.stop ? { stop: this.options.stop } : {}),
264
282
  history: [
265
283
  { role: 'system', content: this.effectiveSystemPrompt },
266
284
  ],
@@ -0,0 +1,435 @@
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
+ autoAssistant: typeof AutonomousAssistant
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 AutonomousAssistantEventsSchema = FeatureEventsSchema.extend({
34
+ started: z.tuple([]).describe('Emitted when the autonomous assistant 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 AutonomousAssistantStateSchema = 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
+ })
68
+
69
+ export const AutonomousAssistantOptionsSchema = FeatureOptionsSchema.extend({
70
+ /** Tool bundles to stack — feature names or objects with filtering. */
71
+ tools: z.array(z.union([
72
+ z.string(),
73
+ z.object({
74
+ feature: z.string(),
75
+ only: z.array(z.string()).optional(),
76
+ except: z.array(z.string()).optional(),
77
+ }),
78
+ ])).default([]).describe('Tool bundles to register on the inner assistant'),
79
+
80
+ /** Per-tool permission overrides. */
81
+ permissions: z.record(z.string(), z.enum(['allow', 'ask', 'deny'])).default({}).describe('Permission level per tool name'),
82
+
83
+ /** Default permission for tools not in the permissions map. */
84
+ defaultPermission: z.enum(['allow', 'ask', 'deny']).default('ask').describe('Default permission level for unconfigured tools'),
85
+
86
+ /** System prompt for the inner assistant. */
87
+ systemPrompt: z.string().optional().describe('System prompt for the inner assistant'),
88
+
89
+ /** Model to use. */
90
+ model: z.string().optional().describe('OpenAI model override'),
91
+
92
+ /** History mode for the inner assistant. */
93
+ historyMode: z.enum(['lifecycle', 'daily', 'persistent', 'session']).optional().describe('Conversation history persistence mode'),
94
+
95
+ /** Assistant folder — if provided, loads CORE.md/tools.ts/hooks.ts from disk. */
96
+ folder: z.string().optional().describe('Assistant folder for disk-based definitions'),
97
+ })
98
+
99
+ export type AutonomousAssistantState = z.infer<typeof AutonomousAssistantStateSchema>
100
+ export type AutonomousAssistantOptions = z.infer<typeof AutonomousAssistantOptionsSchema>
101
+
102
+ /**
103
+ * An autonomous assistant that owns a lower-level Assistant instance and
104
+ * gates all tool calls through a permission system.
105
+ *
106
+ * Tools are stacked from feature bundles (fileTools, processManager, etc.)
107
+ * and each tool can be set to 'allow' (runs immediately), 'ask' (blocks
108
+ * until user approves/denies), or 'deny' (always rejected).
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * const auto = container.feature('autoAssistant', {
113
+ * tools: ['fileTools', { feature: 'processManager', except: ['killAllProcesses'] }],
114
+ * permissions: {
115
+ * readFile: 'allow',
116
+ * searchFiles: 'allow',
117
+ * writeFile: 'ask',
118
+ * editFile: 'ask',
119
+ * deleteFile: 'deny',
120
+ * },
121
+ * defaultPermission: 'ask',
122
+ * systemPrompt: 'You are a coding assistant.',
123
+ * })
124
+ *
125
+ * auto.on('permissionRequest', ({ id, toolName, args }) => {
126
+ * console.log(`Tool "${toolName}" wants to run with`, args)
127
+ * // Show UI, then:
128
+ * auto.approve(id) // or auto.deny(id)
129
+ * })
130
+ *
131
+ * await auto.ask('Refactor the auth module to use async/await')
132
+ * ```
133
+ *
134
+ * @extends Feature
135
+ */
136
+ export class AutonomousAssistant extends Feature<AutonomousAssistantState, AutonomousAssistantOptions> {
137
+ static override shortcut = 'features.autoAssistant' as const
138
+ static override stateSchema = AutonomousAssistantStateSchema
139
+ static override optionsSchema = AutonomousAssistantOptionsSchema
140
+ static override eventsSchema = AutonomousAssistantEventsSchema
141
+
142
+ static { Feature.register(this, 'autoAssistant') }
143
+
144
+ /** The inner assistant instance. Created during start(). */
145
+ private _assistant: Assistant | null = null
146
+
147
+ /** Map of pending approval promises keyed by ID. */
148
+ private _pendingResolvers = new Map<string, (decision: 'approve' | 'deny') => void>()
149
+
150
+ override get initialState(): AutonomousAssistantState {
151
+ return {
152
+ ...super.initialState,
153
+ started: false,
154
+ permissions: this.options.permissions || {},
155
+ defaultPermission: this.options.defaultPermission || 'ask',
156
+ pendingApprovals: [],
157
+ approvalHistory: [],
158
+ }
159
+ }
160
+
161
+ override get container(): AGIContainer {
162
+ return super.container as AGIContainer
163
+ }
164
+
165
+ /** The inner assistant. Throws if not started. */
166
+ get assistant(): Assistant {
167
+ if (!this._assistant) throw new Error('AutonomousAssistant not started. Call start() first.')
168
+ return this._assistant
169
+ }
170
+
171
+ /** Current permission map from state. */
172
+ get permissions(): Record<string, PermissionLevel> {
173
+ return this.state.get('permissions') as Record<string, PermissionLevel>
174
+ }
175
+
176
+ /** Current pending approvals. */
177
+ get pendingApprovals(): PendingApproval[] {
178
+ const stored = this.state.get('pendingApprovals') as Array<{ id: string; toolName: string; args: Record<string, any>; timestamp: number }>
179
+ return stored.map(p => ({
180
+ ...p,
181
+ resolve: this._pendingResolvers.get(p.id) || (() => {}),
182
+ }))
183
+ }
184
+
185
+ /** Whether the assistant is started and ready. */
186
+ get isStarted(): boolean {
187
+ return this.state.get('started') as boolean
188
+ }
189
+
190
+ /** The tools registered on the inner assistant. */
191
+ get tools(): Record<string, any> {
192
+ return this._assistant?.tools || {}
193
+ }
194
+
195
+ /** The conversation on the inner assistant (if started). */
196
+ get conversation() {
197
+ return this._assistant?.conversation
198
+ }
199
+
200
+ /** Messages from the inner assistant's conversation. */
201
+ get messages() {
202
+ return this._assistant?.messages || []
203
+ }
204
+
205
+ // -------------------------------------------------------------------------
206
+ // Permission management
207
+ // -------------------------------------------------------------------------
208
+
209
+ /** Get the effective permission level for a tool. */
210
+ getPermission(toolName: string): PermissionLevel {
211
+ const perms = this.permissions
212
+ if (perms[toolName]) return perms[toolName]
213
+ return this.state.get('defaultPermission') as PermissionLevel
214
+ }
215
+
216
+ /** Set permission level for one or more tools. */
217
+ setPermission(toolName: string | string[], level: PermissionLevel): this {
218
+ const names = Array.isArray(toolName) ? toolName : [toolName]
219
+ const perms = { ...this.permissions }
220
+ for (const name of names) {
221
+ perms[name] = level
222
+ }
223
+ this.state.set('permissions', perms)
224
+ return this
225
+ }
226
+
227
+ /** Set the default permission level for unconfigured tools. */
228
+ setDefaultPermission(level: PermissionLevel): this {
229
+ this.state.set('defaultPermission', level)
230
+ return this
231
+ }
232
+
233
+ /** Allow a tool (or tools) to run without approval. */
234
+ permitTool(...toolNames: string[]): this {
235
+ return this.setPermission(toolNames, 'allow')
236
+ }
237
+
238
+ /** Require approval before a tool (or tools) can run. */
239
+ gateTool(...toolNames: string[]): this {
240
+ return this.setPermission(toolNames, 'ask')
241
+ }
242
+
243
+ /** Block a tool (or tools) from ever running. */
244
+ blockTool(...toolNames: string[]): this {
245
+ return this.setPermission(toolNames, 'deny')
246
+ }
247
+
248
+ // -------------------------------------------------------------------------
249
+ // Approval flow
250
+ // -------------------------------------------------------------------------
251
+
252
+ /** Approve a pending tool call by ID. The tool will execute. */
253
+ approve(id: string): this {
254
+ const resolver = this._pendingResolvers.get(id)
255
+ if (resolver) {
256
+ resolver('approve')
257
+ this._removePending(id)
258
+ this._recordDecision(id, 'approve')
259
+ this.emit('permissionGranted', id)
260
+ }
261
+ return this
262
+ }
263
+
264
+ /** Deny a pending tool call by ID. The tool call will be skipped. */
265
+ deny(id: string): this {
266
+ const resolver = this._pendingResolvers.get(id)
267
+ if (resolver) {
268
+ resolver('deny')
269
+ this._removePending(id)
270
+ this._recordDecision(id, 'deny')
271
+ this.emit('permissionDenied', id)
272
+ }
273
+ return this
274
+ }
275
+
276
+ /** Approve all pending tool calls. */
277
+ approveAll(): this {
278
+ for (const { id } of this.pendingApprovals) {
279
+ this.approve(id)
280
+ }
281
+ return this
282
+ }
283
+
284
+ /** Deny all pending tool calls. */
285
+ denyAll(): this {
286
+ for (const { id } of this.pendingApprovals) {
287
+ this.deny(id)
288
+ }
289
+ return this
290
+ }
291
+
292
+ // -------------------------------------------------------------------------
293
+ // Lifecycle
294
+ // -------------------------------------------------------------------------
295
+
296
+ /**
297
+ * Initialize the inner assistant, stack tool bundles, and wire up
298
+ * the permission interceptor.
299
+ */
300
+ async start(): Promise<this> {
301
+ if (this.isStarted) return this
302
+
303
+ // Create the inner assistant
304
+ const assistantOpts: Record<string, any> = {}
305
+ if (this.options.systemPrompt) assistantOpts.systemPrompt = this.options.systemPrompt
306
+ if (this.options.model) assistantOpts.model = this.options.model
307
+ if (this.options.historyMode) assistantOpts.historyMode = this.options.historyMode
308
+ if (this.options.folder) assistantOpts.folder = this.options.folder
309
+
310
+ this._assistant = this.container.feature('assistant', assistantOpts)
311
+
312
+ // Stack tool bundles
313
+ for (const spec of this.options.tools) {
314
+ this._stackToolBundle(spec)
315
+ }
316
+
317
+ // Wire the permission interceptor
318
+ this._assistant.intercept('beforeToolCall', async (ctx: ToolCallCtx, next: () => Promise<void>) => {
319
+ const policy = this.getPermission(ctx.name)
320
+
321
+ if (policy === 'deny') {
322
+ ctx.skip = true
323
+ ctx.result = JSON.stringify({ blocked: true, tool: ctx.name, reason: 'Permission denied by policy.' })
324
+ this.emit('toolBlocked', ctx.name, 'deny policy')
325
+ return
326
+ }
327
+
328
+ if (policy === 'allow') {
329
+ await next()
330
+ return
331
+ }
332
+
333
+ // 'ask' — block until user decides
334
+ const decision = await this._requestApproval(ctx.name, ctx.args)
335
+
336
+ if (decision === 'approve') {
337
+ await next()
338
+ } else {
339
+ ctx.skip = true
340
+ ctx.result = JSON.stringify({ blocked: true, tool: ctx.name, reason: 'User denied this action.' })
341
+ }
342
+ })
343
+
344
+ // Forward events from inner assistant
345
+ this._assistant.on('chunk', (text: string) => this.emit('chunk', text))
346
+ this._assistant.on('response', (text: string) => this.emit('response', text))
347
+ this._assistant.on('toolCall', (name: string, args: any) => this.emit('toolCall', name, args))
348
+ this._assistant.on('toolResult', (name: string, result: any) => this.emit('toolResult', name, result))
349
+ this._assistant.on('toolError', (name: string, error: any) => this.emit('toolError', name, error))
350
+
351
+ // Start the inner assistant
352
+ await this._assistant.start()
353
+
354
+ this.state.set('started', true)
355
+ this.emit('started')
356
+
357
+ return this
358
+ }
359
+
360
+ /**
361
+ * Ask the autonomous assistant a question. Auto-starts if needed.
362
+ * Tool calls will be gated by the permission system.
363
+ */
364
+ async ask(question: string, options?: Record<string, any>): Promise<string> {
365
+ if (!this.isStarted) await this.start()
366
+ return this.assistant.ask(question, options)
367
+ }
368
+
369
+ /**
370
+ * Add a tool bundle after initialization. Useful for dynamically
371
+ * extending the assistant's capabilities.
372
+ */
373
+ use(spec: ToolBundleSpec): this {
374
+ this._stackToolBundle(spec)
375
+ return this
376
+ }
377
+
378
+ // -------------------------------------------------------------------------
379
+ // Internal
380
+ // -------------------------------------------------------------------------
381
+
382
+ /** Resolve a tool bundle spec and register its tools on the inner assistant. */
383
+ private _stackToolBundle(spec: ToolBundleSpec): void {
384
+ if (!this._assistant) throw new Error('Cannot stack tools before start()')
385
+
386
+ const featureName = typeof spec === 'string' ? spec : spec.feature
387
+ const filterOpts = typeof spec === 'string' ? undefined : {
388
+ only: spec.only,
389
+ except: spec.except,
390
+ }
391
+
392
+ const feature = this.container.feature(featureName as any)
393
+ const tools = (feature as any).toTools(filterOpts)
394
+ this._assistant.use(tools)
395
+ }
396
+
397
+ /** Create a pending approval, emit the event, and return a promise that resolves with the decision. */
398
+ private _requestApproval(toolName: string, args: Record<string, any>): Promise<'approve' | 'deny'> {
399
+ const id = this.container.utils.uuid()
400
+
401
+ return new Promise<'approve' | 'deny'>((resolve) => {
402
+ this._pendingResolvers.set(id, resolve)
403
+
404
+ const pending = [...(this.state.get('pendingApprovals') as any[])]
405
+ pending.push({ id, toolName, args, timestamp: Date.now() })
406
+ this.state.set('pendingApprovals', pending)
407
+
408
+ this.emit('permissionRequest', { id, toolName, args })
409
+ })
410
+ }
411
+
412
+ /** Remove a pending approval from state. */
413
+ private _removePending(id: string): void {
414
+ this._pendingResolvers.delete(id)
415
+ const pending = (this.state.get('pendingApprovals') as any[]).filter((p: any) => p.id !== id)
416
+ this.state.set('pendingApprovals', pending)
417
+ }
418
+
419
+ /** Record a decision in the approval history. */
420
+ private _recordDecision(id: string, decision: 'approve' | 'deny'): void {
421
+ const pending = (this.state.get('pendingApprovals') as any[]).find((p: any) => p.id === id)
422
+ const history = [...(this.state.get('approvalHistory') as any[])]
423
+ history.push({
424
+ id,
425
+ toolName: pending?.toolName || 'unknown',
426
+ decision,
427
+ timestamp: Date.now(),
428
+ })
429
+ // Keep last 100 entries
430
+ if (history.length > 100) history.splice(0, history.length - 100)
431
+ this.state.set('approvalHistory', history)
432
+ }
433
+ }
434
+
435
+ export default AutonomousAssistant