@metabob/minibob 0.1.2

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 (174) hide show
  1. package/ARCHITECTURE.md +255 -0
  2. package/CHANGELOG.md +112 -0
  3. package/README.md +380 -0
  4. package/bin/minibob.js +36 -0
  5. package/dist/acp-gossip.d.ts +72 -0
  6. package/dist/acp-gossip.d.ts.map +1 -0
  7. package/dist/acp-gossip.js +156 -0
  8. package/dist/acp-gossip.js.map +1 -0
  9. package/dist/acp.d.ts +62 -0
  10. package/dist/acp.d.ts.map +1 -0
  11. package/dist/acp.js +292 -0
  12. package/dist/acp.js.map +1 -0
  13. package/dist/activity.d.ts +157 -0
  14. package/dist/activity.d.ts.map +1 -0
  15. package/dist/activity.js +518 -0
  16. package/dist/activity.js.map +1 -0
  17. package/dist/agent-runtime.d.ts +104 -0
  18. package/dist/agent-runtime.d.ts.map +1 -0
  19. package/dist/boredom.d.ts +125 -0
  20. package/dist/boredom.d.ts.map +1 -0
  21. package/dist/boredom.js +244 -0
  22. package/dist/boredom.js.map +1 -0
  23. package/dist/cli/acp-server.d.ts +23 -0
  24. package/dist/cli/acp-server.d.ts.map +1 -0
  25. package/dist/cli/burrow.d.ts +26 -0
  26. package/dist/cli/burrow.d.ts.map +1 -0
  27. package/dist/cli/doctor.d.ts +22 -0
  28. package/dist/cli/doctor.d.ts.map +1 -0
  29. package/dist/cli/goal.d.ts +22 -0
  30. package/dist/cli/goal.d.ts.map +1 -0
  31. package/dist/cli/index.d.ts +47 -0
  32. package/dist/cli/index.d.ts.map +1 -0
  33. package/dist/cli/instance-registry.d.ts +78 -0
  34. package/dist/cli/instance-registry.d.ts.map +1 -0
  35. package/dist/cli/observe.d.ts +35 -0
  36. package/dist/cli/observe.d.ts.map +1 -0
  37. package/dist/cli/vessel.d.ts +14 -0
  38. package/dist/cli/vessel.d.ts.map +1 -0
  39. package/dist/composition-observer.d.ts +96 -0
  40. package/dist/composition-observer.d.ts.map +1 -0
  41. package/dist/config.d.ts +36 -0
  42. package/dist/config.d.ts.map +1 -0
  43. package/dist/config.js +128 -0
  44. package/dist/config.js.map +1 -0
  45. package/dist/docker/Dockerfile +35 -0
  46. package/dist/environment.d.ts +72 -0
  47. package/dist/environment.d.ts.map +1 -0
  48. package/dist/environment.js +142 -0
  49. package/dist/environment.js.map +1 -0
  50. package/dist/goal-processor.d.ts +165 -0
  51. package/dist/goal-processor.d.ts.map +1 -0
  52. package/dist/helm/minibob-cluster/Chart.yaml +13 -0
  53. package/dist/helm/minibob-cluster/templates/_helpers.tpl +60 -0
  54. package/dist/helm/minibob-cluster/templates/configmap.yaml +11 -0
  55. package/dist/helm/minibob-cluster/templates/deployment.yaml +108 -0
  56. package/dist/helm/minibob-cluster/templates/secret.yaml +10 -0
  57. package/dist/helm/minibob-cluster/templates/service.yaml +37 -0
  58. package/dist/helm/minibob-cluster/values-local.yaml +41 -0
  59. package/dist/helm/minibob-cluster/values-production.yaml +57 -0
  60. package/dist/helm/minibob-cluster/values-testing-cluster.yaml +43 -0
  61. package/dist/helm/minibob-cluster/values.yaml +127 -0
  62. package/dist/improviser.d.ts +74 -0
  63. package/dist/improviser.d.ts.map +1 -0
  64. package/dist/impulse-filter.d.ts +74 -0
  65. package/dist/impulse-filter.d.ts.map +1 -0
  66. package/dist/impulse.d.ts +92 -0
  67. package/dist/impulse.d.ts.map +1 -0
  68. package/dist/impulse.js +234 -0
  69. package/dist/impulse.js.map +1 -0
  70. package/dist/lib.d.ts +29 -0
  71. package/dist/lib.d.ts.map +1 -0
  72. package/dist/lib.js +18561 -0
  73. package/dist/lib.js.map +98 -0
  74. package/dist/lifecycle-hooks.d.ts +99 -0
  75. package/dist/lifecycle-hooks.d.ts.map +1 -0
  76. package/dist/lifecycle-hooks.js +135 -0
  77. package/dist/lifecycle-hooks.js.map +1 -0
  78. package/dist/llm.d.ts +31 -0
  79. package/dist/llm.d.ts.map +1 -0
  80. package/dist/llm.js +349 -0
  81. package/dist/llm.js.map +1 -0
  82. package/dist/mcp-activity-bridge.d.ts +66 -0
  83. package/dist/mcp-activity-bridge.d.ts.map +1 -0
  84. package/dist/mcp-activity-bridge.js +126 -0
  85. package/dist/mcp-activity-bridge.js.map +1 -0
  86. package/dist/mcp.d.ts +216 -0
  87. package/dist/mcp.d.ts.map +1 -0
  88. package/dist/mcp.js +292 -0
  89. package/dist/mcp.js.map +1 -0
  90. package/dist/memory-agent.d.ts +92 -0
  91. package/dist/memory-agent.d.ts.map +1 -0
  92. package/dist/memory-agent.js +277 -0
  93. package/dist/memory-agent.js.map +1 -0
  94. package/dist/runtime-mapping.d.ts +97 -0
  95. package/dist/runtime-mapping.d.ts.map +1 -0
  96. package/dist/search-first-executor.d.ts +113 -0
  97. package/dist/search-first-executor.d.ts.map +1 -0
  98. package/dist/session.d.ts +48 -0
  99. package/dist/session.d.ts.map +1 -0
  100. package/dist/template-extractor.d.ts +9 -0
  101. package/dist/template-extractor.d.ts.map +1 -0
  102. package/dist/template-generator.d.ts +12 -0
  103. package/dist/template-generator.d.ts.map +1 -0
  104. package/dist/tools.d.ts +58 -0
  105. package/dist/tools.d.ts.map +1 -0
  106. package/dist/tools.js +771 -0
  107. package/dist/tools.js.map +1 -0
  108. package/dist/types.d.ts +503 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/types.js +8 -0
  111. package/dist/types.js.map +1 -0
  112. package/dist/understanding/analyzer.d.ts +55 -0
  113. package/dist/understanding/analyzer.d.ts.map +1 -0
  114. package/dist/understanding/explorer.d.ts +73 -0
  115. package/dist/understanding/explorer.d.ts.map +1 -0
  116. package/dist/understanding/index.d.ts +7 -0
  117. package/dist/understanding/index.d.ts.map +1 -0
  118. package/dist/understanding/types.d.ts +136 -0
  119. package/dist/understanding/types.d.ts.map +1 -0
  120. package/dist/validation.d.ts +29 -0
  121. package/dist/validation.d.ts.map +1 -0
  122. package/dist/validation.js +106 -0
  123. package/dist/validation.js.map +1 -0
  124. package/dist/vessel-bootstrap.d.ts +190 -0
  125. package/dist/vessel-bootstrap.d.ts.map +1 -0
  126. package/dist/vessel-registry.d.ts +229 -0
  127. package/dist/vessel-registry.d.ts.map +1 -0
  128. package/index.ts +1329 -0
  129. package/package.json +54 -0
  130. package/src/acp-gossip.ts +193 -0
  131. package/src/acp.ts +362 -0
  132. package/src/activity.ts +1464 -0
  133. package/src/agent-runtime.ts +365 -0
  134. package/src/boredom.ts +423 -0
  135. package/src/cli/acp-server.ts +377 -0
  136. package/src/cli/burrow.ts +896 -0
  137. package/src/cli/doctor.ts +526 -0
  138. package/src/cli/goal.ts +224 -0
  139. package/src/cli/index.ts +147 -0
  140. package/src/cli/instance-registry.ts +271 -0
  141. package/src/cli/observe.ts +682 -0
  142. package/src/cli/vessel.ts +287 -0
  143. package/src/components/SystemOverview.tsx +331 -0
  144. package/src/composition-observer.ts +449 -0
  145. package/src/config.ts +172 -0
  146. package/src/environment.ts +167 -0
  147. package/src/goal-processor.ts +654 -0
  148. package/src/improviser.ts +591 -0
  149. package/src/impulse-filter.ts +273 -0
  150. package/src/impulse.ts +311 -0
  151. package/src/lib.ts +147 -0
  152. package/src/lifecycle-hooks.ts +181 -0
  153. package/src/llm.ts +434 -0
  154. package/src/mcp-activity-bridge.ts +158 -0
  155. package/src/mcp.ts +747 -0
  156. package/src/memory-agent.ts +316 -0
  157. package/src/runtime-mapping.ts +527 -0
  158. package/src/search-first-executor.ts +666 -0
  159. package/src/session.ts +141 -0
  160. package/src/template-extractor.ts +256 -0
  161. package/src/template-generator.ts +130 -0
  162. package/src/tools.ts +924 -0
  163. package/src/types.ts +497 -0
  164. package/src/understanding/analyzer.ts +354 -0
  165. package/src/understanding/explorer.ts +488 -0
  166. package/src/understanding/index.ts +27 -0
  167. package/src/understanding/types.ts +153 -0
  168. package/src/validation.ts +125 -0
  169. package/src/vessel-bootstrap.ts +440 -0
  170. package/src/vessel-registry.ts +621 -0
  171. package/templates/core/edit-file.json +85 -0
  172. package/templates/understanding/diagnose-problem.json +32 -0
  173. package/templates/understanding/explore-codebase-v2.json +57 -0
  174. package/templates/understanding/explore-codebase.json +37 -0
package/src/tools.ts ADDED
@@ -0,0 +1,924 @@
1
+ /**
2
+ * minibob Built-in Tools
3
+ *
4
+ * Minimal set of tools for activity execution and self-development.
5
+ *
6
+ * SECURITY: Path validation, command whitelisting, and input sanitization
7
+ * enforced to prevent command injection and path traversal attacks.
8
+ */
9
+
10
+ import type { ToolDefinition, ToolHandler, ToolResult } from "./types"
11
+
12
+ // Re-export ToolDefinition for consumers that import from tools.ts
13
+ export type { ToolDefinition } from "./types"
14
+ import * as path from "node:path"
15
+
16
+ // =============================================================================
17
+ // SECURITY UTILITIES
18
+ // =============================================================================
19
+
20
+ /**
21
+ * Allowed bash commands (whitelist approach)
22
+ * Only these commands can be executed via the bash tool
23
+ */
24
+ const ALLOWED_BASH_COMMANDS = new Set([
25
+ // Version control
26
+ "git",
27
+ // Package managers
28
+ "npm", "pnpm", "yarn", "bun",
29
+ // Build tools
30
+ "make", "cmake", "cargo", "go",
31
+ // File operations (read-only)
32
+ "ls", "cat", "head", "tail", "find", "grep", "rg", "fd",
33
+ // Text processing
34
+ "sed", "awk", "cut", "sort", "uniq", "wc",
35
+ // Directory operations (safe)
36
+ "pwd", "cd", "mkdir", "rmdir",
37
+ // Testing
38
+ "pytest", "jest", "vitest", "cargo test",
39
+ // Linting/formatting
40
+ "eslint", "prettier", "black", "rustfmt",
41
+ // Type checking
42
+ "tsc", "mypy", "cargo check",
43
+ ])
44
+
45
+ /**
46
+ * Dangerous command patterns that are always blocked
47
+ */
48
+ const BLOCKED_COMMAND_PATTERNS = [
49
+ /rm\s+-rf\s+\//, // rm -rf /
50
+ /:\(\)\{.*\}/, // Fork bombs
51
+ />\s*\/dev\/sd[a-z]/, // Writing to raw disk
52
+ /mkfs/, // Filesystem formatting
53
+ /dd\s+if=.*of=\/dev/, // Direct disk write
54
+ ]
55
+
56
+ /**
57
+ * Validate and sanitize a bash command
58
+ * @throws Error if command is not allowed
59
+ */
60
+ function validateBashCommand(command: string): void {
61
+ // Check blocked patterns first
62
+ for (const pattern of BLOCKED_COMMAND_PATTERNS) {
63
+ if (pattern.test(command)) {
64
+ throw new Error(
65
+ `Blocked dangerous command pattern: ${pattern.source}`
66
+ )
67
+ }
68
+ }
69
+
70
+ // Extract the first command (before pipes, semicolons, etc.)
71
+ const firstCommand = command
72
+ .split(/[|;&]/)
73
+ [0]?.trim()
74
+ .split(/\s+/)
75
+ [0]
76
+
77
+ if (!firstCommand) {
78
+ throw new Error("Empty command")
79
+ }
80
+
81
+ // Check if command is in whitelist
82
+ if (!ALLOWED_BASH_COMMANDS.has(firstCommand)) {
83
+ throw new Error(
84
+ `Command '${firstCommand}' not in whitelist. Allowed: ${Array.from(ALLOWED_BASH_COMMANDS).join(", ")}`
85
+ )
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Validate a file path is within the working directory
91
+ * Prevents path traversal attacks (../../etc/passwd)
92
+ * @returns Canonicalized absolute path
93
+ * @throws Error if path escapes working directory
94
+ */
95
+ function validatePath(filePath: string, workingDirectory: string): string {
96
+ // Resolve to absolute path
97
+ const absolutePath = path.isAbsolute(filePath)
98
+ ? path.resolve(filePath)
99
+ : path.resolve(workingDirectory, filePath)
100
+
101
+ // Canonicalize (removes .., ., symlinks)
102
+ const canonicalPath = path.normalize(absolutePath)
103
+ const canonicalWorkDir = path.normalize(path.resolve(workingDirectory))
104
+
105
+ // Check if path is within working directory
106
+ if (!canonicalPath.startsWith(canonicalWorkDir)) {
107
+ throw new Error(
108
+ `Path traversal blocked: ${filePath} resolves outside working directory (${workingDirectory})`
109
+ )
110
+ }
111
+
112
+ return canonicalPath
113
+ }
114
+
115
+ // =============================================================================
116
+ // TOOL DEFINITIONS
117
+ // =============================================================================
118
+
119
+ export const toolDefinitions: Record<string, ToolDefinition> = {
120
+ bash: {
121
+ name: "bash",
122
+ description: "Execute a shell command. Returns stdout and stderr.",
123
+ parameters: {
124
+ type: "object",
125
+ properties: {
126
+ command: {
127
+ type: "string",
128
+ description: "The shell command to execute",
129
+ },
130
+ cwd: {
131
+ type: "string",
132
+ description: "Working directory for the command (optional)",
133
+ },
134
+ timeout: {
135
+ type: "number",
136
+ description: "Timeout in milliseconds (default: 60000)",
137
+ },
138
+ },
139
+ required: ["command"],
140
+ },
141
+ },
142
+
143
+ read: {
144
+ name: "read",
145
+ description: "Read a file from the filesystem. Returns file content with line numbers.",
146
+ parameters: {
147
+ type: "object",
148
+ properties: {
149
+ path: {
150
+ type: "string",
151
+ description: "Path to the file to read",
152
+ },
153
+ offset: {
154
+ type: "number",
155
+ description: "Line number to start reading from (0-based)",
156
+ },
157
+ limit: {
158
+ type: "number",
159
+ description: "Maximum number of lines to read (default: 2000)",
160
+ },
161
+ },
162
+ required: ["path"],
163
+ },
164
+ },
165
+
166
+ write: {
167
+ name: "write",
168
+ description: "Write content to a file. Creates directories if needed.",
169
+ parameters: {
170
+ type: "object",
171
+ properties: {
172
+ path: {
173
+ type: "string",
174
+ description: "Path to the file to write",
175
+ },
176
+ content: {
177
+ type: "string",
178
+ description: "Content to write to the file",
179
+ },
180
+ },
181
+ required: ["path", "content"],
182
+ },
183
+ },
184
+
185
+ edit: {
186
+ name: "edit",
187
+ description: "Edit a file by replacing exact text. The oldString must match exactly.",
188
+ parameters: {
189
+ type: "object",
190
+ properties: {
191
+ path: {
192
+ type: "string",
193
+ description: "Path to the file to edit",
194
+ },
195
+ oldString: {
196
+ type: "string",
197
+ description: "Exact text to find and replace",
198
+ },
199
+ newString: {
200
+ type: "string",
201
+ description: "Text to replace with",
202
+ },
203
+ replaceAll: {
204
+ type: "boolean",
205
+ description: "Replace all occurrences (default: false)",
206
+ },
207
+ },
208
+ required: ["path", "oldString", "newString"],
209
+ },
210
+ },
211
+
212
+ glob: {
213
+ name: "glob",
214
+ description: "Find files matching a glob pattern.",
215
+ parameters: {
216
+ type: "object",
217
+ properties: {
218
+ pattern: {
219
+ type: "string",
220
+ description: "Glob pattern to match (e.g., '**/*.ts')",
221
+ },
222
+ cwd: {
223
+ type: "string",
224
+ description: "Directory to search from (optional)",
225
+ },
226
+ },
227
+ required: ["pattern"],
228
+ },
229
+ },
230
+
231
+ grep: {
232
+ name: "grep",
233
+ description: "Search file contents using a regex pattern.",
234
+ parameters: {
235
+ type: "object",
236
+ properties: {
237
+ pattern: {
238
+ type: "string",
239
+ description: "Regex pattern to search for",
240
+ },
241
+ include: {
242
+ type: "string",
243
+ description: "File pattern to include (e.g., '*.ts')",
244
+ },
245
+ cwd: {
246
+ type: "string",
247
+ description: "Directory to search in (optional)",
248
+ },
249
+ },
250
+ required: ["pattern"],
251
+ },
252
+ },
253
+
254
+ list: {
255
+ name: "list",
256
+ description: "List files and directories in a path.",
257
+ parameters: {
258
+ type: "object",
259
+ properties: {
260
+ path: {
261
+ type: "string",
262
+ description: "Directory path to list",
263
+ },
264
+ },
265
+ required: ["path"],
266
+ },
267
+ },
268
+
269
+ git: {
270
+ name: "git",
271
+ description: "Execute git commands for version control operations.",
272
+ parameters: {
273
+ type: "object",
274
+ properties: {
275
+ command: {
276
+ type: "string",
277
+ description: "Git subcommand (e.g., 'status', 'add', 'commit', 'diff')",
278
+ enum: ["status", "add", "commit", "diff", "log", "branch", "checkout", "push", "pull"],
279
+ },
280
+ args: {
281
+ type: "array",
282
+ description: "Arguments for the git command",
283
+ },
284
+ cwd: {
285
+ type: "string",
286
+ description: "Working directory (optional)",
287
+ },
288
+ },
289
+ required: ["command"],
290
+ },
291
+ },
292
+
293
+ activity: {
294
+ name: "activity",
295
+ description: "Execute an activity template (enables nested activity execution). Template can be loaded from MCP backend or local file.",
296
+ parameters: {
297
+ type: "object",
298
+ properties: {
299
+ templateId: {
300
+ type: "string",
301
+ description: "Activity template ID (from MCP) or path (local file)",
302
+ },
303
+ variables: {
304
+ type: "object",
305
+ description: "Variables to pass to the activity",
306
+ },
307
+ reason: {
308
+ type: "string",
309
+ description: "Reason for executing this activity",
310
+ },
311
+ },
312
+ required: ["templateId"],
313
+ },
314
+ },
315
+
316
+ search_activities: {
317
+ name: "search_activities",
318
+ description: "Search for existing activity templates by category or keyword. Use this BEFORE creating new activities to avoid duplication.",
319
+ parameters: {
320
+ type: "object",
321
+ properties: {
322
+ category: {
323
+ type: "string",
324
+ description: "Filter by category (feature, bugfix, refactor, tool, infrastructure)",
325
+ enum: ["feature", "bugfix", "refactor", "tool", "infrastructure"],
326
+ },
327
+ verbose: {
328
+ type: "boolean",
329
+ description: "Show full details (default: false, returns compact list)",
330
+ },
331
+ },
332
+ required: [],
333
+ },
334
+ },
335
+
336
+ create_activity_goal_seeking: {
337
+ name: "create_activity_goal_seeking",
338
+ description: "Create a new activity template for a non-trivial task. The agent will autonomously generate tasks and learn from execution. Use this when no existing activity matches your need.",
339
+ parameters: {
340
+ type: "object",
341
+ properties: {
342
+ goalDescription: {
343
+ type: "string",
344
+ description: "High-level goal description (e.g., 'Deploy app to production with health checks')",
345
+ },
346
+ templateName: {
347
+ type: "string",
348
+ description: "Human-readable template name (e.g., 'Deploy Production Application')",
349
+ },
350
+ category: {
351
+ type: "string",
352
+ description: "Template category",
353
+ enum: ["feature", "bugfix", "refactor", "tool", "infrastructure"],
354
+ },
355
+ variables: {
356
+ type: "object",
357
+ description: "Context variables for goal decomposition",
358
+ },
359
+ },
360
+ required: ["goalDescription", "templateName", "category"],
361
+ },
362
+ },
363
+
364
+ impulse_create: {
365
+ name: "impulse_create",
366
+ description: "Create a new impulse for context management.",
367
+ parameters: {
368
+ type: "object",
369
+ properties: {
370
+ id: {
371
+ type: "string",
372
+ description: "Unique impulse ID",
373
+ },
374
+ type: {
375
+ type: "string",
376
+ description: "Impulse pointer type",
377
+ enum: ["memo", "file", "activityOutput", "custom"],
378
+ },
379
+ content: {
380
+ type: "string",
381
+ description: "Content (for memo type) or path (for file type)",
382
+ },
383
+ budget: {
384
+ type: "number",
385
+ description: "Token budget for this impulse",
386
+ },
387
+ priority: {
388
+ type: "string",
389
+ description: "Priority level",
390
+ enum: ["critical", "high", "medium", "low"],
391
+ },
392
+ },
393
+ required: ["id", "type", "budget"],
394
+ },
395
+ },
396
+ }
397
+
398
+ // =============================================================================
399
+ // TOOL HANDLERS
400
+ // =============================================================================
401
+
402
+ /**
403
+ * Options for creating tool handlers
404
+ */
405
+ export interface ToolHandlerOptions {
406
+ workingDirectory: string
407
+ onActivityExecute?: (templateId: string, variables: Record<string, unknown>, reason?: string) => Promise<unknown>
408
+ onSearchActivities?: (category?: string, verbose?: boolean) => Promise<{ count: number; activities: unknown[] }>
409
+ onCreateActivity?: (params: {
410
+ goalDescription: string
411
+ templateName: string
412
+ category: string
413
+ variables: Record<string, unknown>
414
+ }) => Promise<{ templateId: string }>
415
+ onImpulseCreate?: (impulse: { id: string; type: string; content?: string; budget: number; priority: string }) => void
416
+ /**
417
+ * Custom domain-specific tools injected by the host application.
418
+ * Each entry provides a tool definition (for LLM function calling) and a handler.
419
+ * Custom tools are merged with built-in tools; custom names must not collide with
420
+ * built-in names (bash, read, write, edit, glob, grep, list, git, activity,
421
+ * search_activities, create_activity_goal_seeking, impulse_create).
422
+ */
423
+ customTools?: Record<string, { definition: ToolDefinition; handler: ToolHandler }>
424
+ }
425
+
426
+ /**
427
+ * Create tool handlers with working directory context
428
+ */
429
+ export function createToolHandlers(options: string | ToolHandlerOptions): Record<string, ToolHandler> {
430
+ // Support backward compatibility - if string passed, convert to options
431
+ const opts: ToolHandlerOptions = typeof options === "string"
432
+ ? { workingDirectory: options }
433
+ : options
434
+
435
+ const workingDirectory = opts.workingDirectory
436
+ const resolvePath = (path: string): string => {
437
+ if (path.startsWith("/")) return path
438
+ return `${workingDirectory}/${path}`
439
+ }
440
+
441
+ return {
442
+ bash: async (params): Promise<ToolResult> => {
443
+ const command = params.command as string
444
+ const cwd = (params.cwd as string) ?? workingDirectory
445
+ const timeout = (params.timeout as number) ?? 60000
446
+
447
+ try {
448
+ // SECURITY: Validate command against whitelist
449
+ validateBashCommand(command)
450
+
451
+ const proc = Bun.spawn(["sh", "-c", command], {
452
+ cwd,
453
+ stdout: "pipe",
454
+ stderr: "pipe",
455
+ })
456
+
457
+ // Handle timeout
458
+ const timeoutPromise = new Promise<never>((_, reject) => {
459
+ setTimeout(() => {
460
+ proc.kill()
461
+ reject(new Error(`Command timed out after ${timeout}ms`))
462
+ }, timeout)
463
+ })
464
+
465
+ const exitCode = await Promise.race([proc.exited, timeoutPromise])
466
+ const stdout = await new Response(proc.stdout).text()
467
+ const stderr = await new Response(proc.stderr).text()
468
+
469
+ if (exitCode !== 0) {
470
+ return {
471
+ success: false,
472
+ error: `Command failed with exit code ${exitCode}\nStderr: ${stderr}`,
473
+ output: stdout,
474
+ }
475
+ }
476
+
477
+ return {
478
+ success: true,
479
+ output: stdout + (stderr ? `\nStderr: ${stderr}` : ""),
480
+ }
481
+ } catch (error) {
482
+ return {
483
+ success: false,
484
+ error: error instanceof Error ? error.message : String(error),
485
+ }
486
+ }
487
+ },
488
+
489
+ read: async (params): Promise<ToolResult> => {
490
+ // Validate path parameter exists and is a string
491
+ if (!params.path || typeof params.path !== 'string') {
492
+ return {
493
+ success: false,
494
+ error: `Invalid path parameter: expected string, got ${typeof params.path}. Params: ${JSON.stringify(params)}`,
495
+ }
496
+ }
497
+
498
+ const filePath = params.path as string
499
+ const offset = (params.offset as number) ?? 0
500
+ const limit = (params.limit as number) ?? 2000
501
+
502
+ try {
503
+ // SECURITY: Validate path is within working directory
504
+ const validatedPath = validatePath(filePath, workingDirectory)
505
+
506
+ // Debug logging
507
+ console.log(`[read] Attempting to read file:`, {
508
+ originalPath: filePath,
509
+ validatedPath,
510
+ validatedPathType: typeof validatedPath,
511
+ workingDirectory
512
+ })
513
+
514
+ let file
515
+ try {
516
+ file = Bun.file(validatedPath)
517
+ } catch (bunError) {
518
+ return {
519
+ success: false,
520
+ error: `Bun.file() failed for '${validatedPath}': ${bunError instanceof Error ? bunError.message : String(bunError)}`,
521
+ }
522
+ }
523
+
524
+ if (!(await file.exists())) {
525
+ return {
526
+ success: false,
527
+ error: `File not found: ${validatedPath}`,
528
+ }
529
+ }
530
+
531
+ const content = await file.text()
532
+ const lines = content.split("\n")
533
+ const selectedLines = lines.slice(offset, offset + limit)
534
+
535
+ // Format with line numbers
536
+ const formatted = selectedLines
537
+ .map((line, i) => {
538
+ const lineNum = (offset + i + 1).toString().padStart(5, "0")
539
+ return `${lineNum}| ${line}`
540
+ })
541
+ .join("\n")
542
+
543
+ return {
544
+ success: true,
545
+ output: `<file>\n${formatted}\n</file>`,
546
+ metadata: {
547
+ totalLines: lines.length,
548
+ readLines: selectedLines.length,
549
+ offset,
550
+ },
551
+ }
552
+ } catch (error) {
553
+ // Provide detailed error information for debugging
554
+ const errorMsg = error instanceof Error ? error.message : String(error)
555
+ return {
556
+ success: false,
557
+ error: `Failed to read file '${filePath}': ${errorMsg}`,
558
+ }
559
+ }
560
+ },
561
+
562
+ write: async (params): Promise<ToolResult> => {
563
+ const filePath = params.path as string
564
+ const content = params.content as string
565
+
566
+ try {
567
+ // SECURITY: Validate path is within working directory
568
+ const validatedPath = validatePath(filePath, workingDirectory)
569
+
570
+ // Create directory if needed
571
+ const dir = validatedPath.substring(0, validatedPath.lastIndexOf("/"))
572
+ if (dir) {
573
+ await Bun.$`mkdir -p ${dir}`.quiet()
574
+ }
575
+
576
+ await Bun.write(validatedPath, content)
577
+ return {
578
+ success: true,
579
+ output: `Wrote ${content.length} bytes to ${filePath}`,
580
+ }
581
+ } catch (error) {
582
+ return {
583
+ success: false,
584
+ error: error instanceof Error ? error.message : String(error),
585
+ }
586
+ }
587
+ },
588
+
589
+ edit: async (params): Promise<ToolResult> => {
590
+ const filePath = params.path as string
591
+ const oldString = params.oldString as string
592
+ const newString = params.newString as string
593
+ const replaceAll = (params.replaceAll as boolean) ?? false
594
+
595
+ try {
596
+ // SECURITY: Validate path is within working directory
597
+ const validatedPath = validatePath(filePath, workingDirectory)
598
+ const file = Bun.file(validatedPath)
599
+ if (!(await file.exists())) {
600
+ return {
601
+ success: false,
602
+ error: `File not found: ${filePath}`,
603
+ }
604
+ }
605
+
606
+ const content = await file.text()
607
+
608
+ if (!content.includes(oldString)) {
609
+ return {
610
+ success: false,
611
+ error: `oldString not found in file: "${oldString.substring(0, 50)}..."`,
612
+ }
613
+ }
614
+
615
+ // Check for multiple matches if not replaceAll
616
+ if (!replaceAll) {
617
+ const matches = content.split(oldString).length - 1
618
+ if (matches > 1) {
619
+ return {
620
+ success: false,
621
+ error: `oldString found ${matches} times. Use replaceAll: true or provide more context.`,
622
+ }
623
+ }
624
+ }
625
+
626
+ const newContent = replaceAll
627
+ ? content.split(oldString).join(newString)
628
+ : content.replace(oldString, newString)
629
+
630
+ await Bun.write(validatedPath, newContent)
631
+
632
+ return {
633
+ success: true,
634
+ output: `Edited ${validatedPath}`,
635
+ }
636
+ } catch (error) {
637
+ return {
638
+ success: false,
639
+ error: error instanceof Error ? error.message : String(error),
640
+ }
641
+ }
642
+ },
643
+
644
+ glob: async (params): Promise<ToolResult> => {
645
+ const pattern = params.pattern as string
646
+ const cwd = (params.cwd as string) ?? workingDirectory
647
+
648
+ try {
649
+ const glob = new Bun.Glob(pattern)
650
+ const files: string[] = []
651
+ for await (const file of glob.scan({ cwd })) {
652
+ files.push(file)
653
+ if (files.length >= 100) break // Limit results
654
+ }
655
+
656
+ return {
657
+ success: true,
658
+ output: files.length > 0 ? files.join("\n") : "No files found",
659
+ metadata: { count: files.length },
660
+ }
661
+ } catch (error) {
662
+ return {
663
+ success: false,
664
+ error: error instanceof Error ? error.message : String(error),
665
+ }
666
+ }
667
+ },
668
+
669
+ grep: async (params): Promise<ToolResult> => {
670
+ const pattern = params.pattern as string
671
+ const include = params.include as string | undefined
672
+ const cwd = (params.cwd as string) ?? workingDirectory
673
+
674
+ try {
675
+ // Use ripgrep if available, fall back to grep
676
+ const rgArgs = ["rg", "-l", pattern]
677
+ if (include) {
678
+ rgArgs.push("-g", include)
679
+ }
680
+
681
+ const proc = Bun.spawn(rgArgs, {
682
+ cwd,
683
+ stdout: "pipe",
684
+ stderr: "pipe",
685
+ })
686
+
687
+ const exitCode = await proc.exited
688
+ const stdout = await new Response(proc.stdout).text()
689
+
690
+ if (exitCode !== 0 && exitCode !== 1) {
691
+ // Exit code 1 means no matches, which is OK
692
+ return {
693
+ success: false,
694
+ error: `grep failed with exit code ${exitCode}`,
695
+ }
696
+ }
697
+
698
+ const files = stdout.trim().split("\n").filter(Boolean)
699
+
700
+ return {
701
+ success: true,
702
+ output: files.length > 0 ? `Found ${files.length} files:\n${files.join("\n")}` : "No matches found",
703
+ metadata: { count: files.length },
704
+ }
705
+ } catch (error) {
706
+ return {
707
+ success: false,
708
+ error: error instanceof Error ? error.message : String(error),
709
+ }
710
+ }
711
+ },
712
+
713
+ list: async (params): Promise<ToolResult> => {
714
+ const filePath = params.path as string
715
+
716
+ try {
717
+ // SECURITY: Validate path is within working directory
718
+ const validatedPath = validatePath(filePath, workingDirectory)
719
+ const dir = await Bun.$`ls -la ${validatedPath}`.text()
720
+
721
+ return {
722
+ success: true,
723
+ output: dir,
724
+ }
725
+ } catch (error) {
726
+ return {
727
+ success: false,
728
+ error: error instanceof Error ? error.message : String(error),
729
+ }
730
+ }
731
+ },
732
+
733
+ git: async (params): Promise<ToolResult> => {
734
+ const command = params.command as string
735
+ const args = (params.args as string[]) ?? []
736
+
737
+ // Default to /repos if REPOS_PATH is set and no cwd provided
738
+ const defaultCwd = process.env.REPOS_PATH
739
+ ? process.env.REPOS_PATH
740
+ : workingDirectory
741
+ const cwd = (params.cwd as string) ?? defaultCwd
742
+
743
+ try {
744
+ const gitArgs = ["git", command, ...args]
745
+ const proc = Bun.spawn(gitArgs, {
746
+ cwd,
747
+ stdout: "pipe",
748
+ stderr: "pipe",
749
+ env: {
750
+ ...process.env,
751
+ // Ensure git uses credentials from mounted config
752
+ GIT_CONFIG_GLOBAL: "/root/.gitconfig",
753
+ },
754
+ })
755
+
756
+ const exitCode = await proc.exited
757
+ const stdout = await new Response(proc.stdout).text()
758
+ const stderr = await new Response(proc.stderr).text()
759
+
760
+ if (exitCode !== 0) {
761
+ return {
762
+ success: false,
763
+ error: `git ${command} failed: ${stderr}`,
764
+ output: stdout,
765
+ }
766
+ }
767
+
768
+ return {
769
+ success: true,
770
+ output: stdout || `git ${command} completed successfully`,
771
+ }
772
+ } catch (error) {
773
+ return {
774
+ success: false,
775
+ error: error instanceof Error ? error.message : String(error),
776
+ }
777
+ }
778
+ },
779
+
780
+ activity: async (params): Promise<ToolResult> => {
781
+ if (!opts.onActivityExecute) {
782
+ return {
783
+ success: false,
784
+ error: "Activity execution not configured (onActivityExecute callback required)",
785
+ }
786
+ }
787
+
788
+ try {
789
+ const templateId = params.templateId as string
790
+ const variables = (params.variables as Record<string, unknown>) ?? {}
791
+ const reason = params.reason as string | undefined
792
+
793
+ console.log(`[Tool:activity] Executing nested activity: ${templateId}`)
794
+ const result = await opts.onActivityExecute(templateId, variables, reason)
795
+
796
+ return {
797
+ success: true,
798
+ output: `Activity "${templateId}" executed successfully\n\nResult:\n${JSON.stringify(result, null, 2)}`,
799
+ metadata: { result },
800
+ }
801
+ } catch (error) {
802
+ return {
803
+ success: false,
804
+ error: error instanceof Error ? error.message : String(error),
805
+ }
806
+ }
807
+ },
808
+
809
+ search_activities: async (params): Promise<ToolResult> => {
810
+ if (!opts.onSearchActivities) {
811
+ return {
812
+ success: false,
813
+ error: "Activity search not configured (onSearchActivities callback required)",
814
+ }
815
+ }
816
+
817
+ try {
818
+ const category = params.category as string | undefined
819
+ const verbose = (params.verbose as boolean) ?? false
820
+
821
+ console.log(`[Tool:search_activities] Searching activities:`, { category, verbose })
822
+ const result = await opts.onSearchActivities(category, verbose)
823
+
824
+ return {
825
+ success: true,
826
+ output: `Found ${result.count} activities:\n\n${JSON.stringify(result.activities, null, 2)}`,
827
+ metadata: { result },
828
+ }
829
+ } catch (error) {
830
+ return {
831
+ success: false,
832
+ error: error instanceof Error ? error.message : String(error),
833
+ }
834
+ }
835
+ },
836
+
837
+ create_activity_goal_seeking: async (params): Promise<ToolResult> => {
838
+ if (!opts.onCreateActivity) {
839
+ return {
840
+ success: false,
841
+ error: "Activity creation not configured (onCreateActivity callback required)",
842
+ }
843
+ }
844
+
845
+ try {
846
+ const goalDescription = params.goalDescription as string
847
+ const templateName = params.templateName as string
848
+ const category = params.category as string
849
+ const variables = (params.variables as Record<string, unknown>) ?? {}
850
+
851
+ console.log(`[Tool:create_activity_goal_seeking] Creating activity:`, {
852
+ templateName,
853
+ category,
854
+ goalDescription: goalDescription.substring(0, 100) + "...",
855
+ })
856
+
857
+ const result = await opts.onCreateActivity({
858
+ goalDescription,
859
+ templateName,
860
+ category,
861
+ variables,
862
+ })
863
+
864
+ return {
865
+ success: true,
866
+ output: `Activity template "${templateName}" created successfully\n\nTemplate ID: ${result.templateId}\n\nYou can now execute it with: activity(templateId: "${result.templateId}")`,
867
+ metadata: { result },
868
+ }
869
+ } catch (error) {
870
+ return {
871
+ success: false,
872
+ error: error instanceof Error ? error.message : String(error),
873
+ }
874
+ }
875
+ },
876
+
877
+ impulse_create: async (params): Promise<ToolResult> => {
878
+ if (!opts.onImpulseCreate) {
879
+ return {
880
+ success: false,
881
+ error: "Impulse creation not configured (onImpulseCreate callback required)",
882
+ }
883
+ }
884
+
885
+ try {
886
+ const id = params.id as string
887
+ const type = params.type as string
888
+ const content = params.content as string | undefined
889
+ const budget = params.budget as number
890
+ const priority = params.priority as string
891
+
892
+ const impulse = { id, type, content, budget, priority }
893
+ opts.onImpulseCreate(impulse)
894
+
895
+ return {
896
+ success: true,
897
+ output: `Impulse "${id}" created with type ${type} and budget ${budget} tokens`,
898
+ }
899
+ } catch (error) {
900
+ return {
901
+ success: false,
902
+ error: error instanceof Error ? error.message : String(error),
903
+ }
904
+ }
905
+ },
906
+
907
+ // Merge in any custom tools provided by the host application
908
+ ...Object.fromEntries(
909
+ Object.entries(opts.customTools ?? {}).map(([name, { handler }]) => [name, handler])
910
+ ),
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Get all tool definitions as an array.
916
+ * @param extraDefinitions Additional tool definitions from custom tools (e.g. Perspective domain tools).
917
+ */
918
+ export function getAllToolDefinitions(extraDefinitions?: ToolDefinition[]): ToolDefinition[] {
919
+ const base = Object.values(toolDefinitions)
920
+ if (extraDefinitions && extraDefinitions.length > 0) {
921
+ return [...base, ...extraDefinitions]
922
+ }
923
+ return base
924
+ }