mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 23:01:32 +00:00
mom: Docker sandbox support with --sandbox=docker:container-name option
This commit is contained in:
parent
da26edb2a7
commit
f140f2e432
10 changed files with 885 additions and 814 deletions
95
packages/mom/docker.sh
Executable file
95
packages/mom/docker.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Mom Docker Sandbox Management Script
|
||||||
|
# Usage:
|
||||||
|
# ./docker.sh create <data-dir> - Create and start the container
|
||||||
|
# ./docker.sh start - Start the container
|
||||||
|
# ./docker.sh stop - Stop the container
|
||||||
|
# ./docker.sh remove - Remove the container
|
||||||
|
# ./docker.sh status - Check container status
|
||||||
|
# ./docker.sh shell - Open a shell in the container
|
||||||
|
|
||||||
|
CONTAINER_NAME="mom-sandbox"
|
||||||
|
IMAGE="alpine:latest"
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
create)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 create <data-dir>"
|
||||||
|
echo "Example: $0 create ./data"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DATA_DIR=$(cd "$2" && pwd)
|
||||||
|
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
echo "Container '${CONTAINER_NAME}' already exists. Remove it first with: $0 remove"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating container '${CONTAINER_NAME}'..."
|
||||||
|
echo " Data dir: ${DATA_DIR} -> /workspace"
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
-v "${DATA_DIR}:/workspace" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tail -f /dev/null
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Container created and running."
|
||||||
|
echo ""
|
||||||
|
echo "Run mom with: mom --sandbox=docker:${CONTAINER_NAME} $2"
|
||||||
|
else
|
||||||
|
echo "Failed to create container."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
start)
|
||||||
|
echo "Starting container '${CONTAINER_NAME}'..."
|
||||||
|
docker start "$CONTAINER_NAME"
|
||||||
|
;;
|
||||||
|
|
||||||
|
stop)
|
||||||
|
echo "Stopping container '${CONTAINER_NAME}'..."
|
||||||
|
docker stop "$CONTAINER_NAME"
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove)
|
||||||
|
echo "Removing container '${CONTAINER_NAME}'..."
|
||||||
|
docker rm -f "$CONTAINER_NAME"
|
||||||
|
;;
|
||||||
|
|
||||||
|
status)
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
echo "Container '${CONTAINER_NAME}' is running."
|
||||||
|
docker ps --filter "name=${CONTAINER_NAME}" --format "table {{.ID}}\t{{.Image}}\t{{.Status}}"
|
||||||
|
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
echo "Container '${CONTAINER_NAME}' exists but is not running."
|
||||||
|
echo "Start it with: $0 start"
|
||||||
|
else
|
||||||
|
echo "Container '${CONTAINER_NAME}' does not exist."
|
||||||
|
echo "Create it with: $0 create <data-dir>"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
shell)
|
||||||
|
echo "Opening shell in '${CONTAINER_NAME}'..."
|
||||||
|
docker exec -it "$CONTAINER_NAME" /bin/sh
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Mom Docker Sandbox Management"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 <command> [args]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " create <data-dir> - Create and start the container"
|
||||||
|
echo " start - Start the container"
|
||||||
|
echo " stop - Stop the container"
|
||||||
|
echo " remove - Remove the container"
|
||||||
|
echo " status - Check container status"
|
||||||
|
echo " shell - Open a shell in the container"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -1,95 +1,153 @@
|
||||||
# Mom Sandbox Implementation
|
# Mom Docker Sandbox
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Mom uses [@anthropic-ai/sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) to restrict what the bash tool can do at the OS level.
|
Mom can run tools either directly on the host or inside a Docker container for isolation.
|
||||||
|
|
||||||
## Current Implementation
|
## Why Docker?
|
||||||
|
|
||||||
Located in `src/sandbox.ts`:
|
When mom runs on your machine and is accessible via Slack, anyone in your workspace could potentially:
|
||||||
|
- Execute arbitrary commands on your machine
|
||||||
|
- Access your files, credentials, etc.
|
||||||
|
- Cause damage via prompt injection
|
||||||
|
|
||||||
```typescript
|
The Docker sandbox isolates mom's tools to a container where she can only access what you explicitly mount.
|
||||||
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
||||||
|
|
||||||
const runtimeConfig: SandboxRuntimeConfig = {
|
## Quick Start
|
||||||
network: {
|
|
||||||
allowedDomains: [], // Currently no network - should be ["*"] for full access
|
|
||||||
deniedDomains: [],
|
|
||||||
},
|
|
||||||
filesystem: {
|
|
||||||
denyRead: ["~/.ssh", "~/.aws", ...], // Sensitive paths
|
|
||||||
allowWrite: [channelDir, scratchpadDir], // Only mom's folders
|
|
||||||
denyWrite: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await SandboxManager.initialize(runtimeConfig);
|
```bash
|
||||||
const sandboxedCommand = await SandboxManager.wrapWithSandbox(command);
|
# 1. Create and start the container
|
||||||
|
cd packages/mom
|
||||||
|
./docker.sh create ./data
|
||||||
|
|
||||||
|
# 2. Run mom with Docker sandbox
|
||||||
|
mom --sandbox=docker:mom-sandbox ./data
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Limitation: Read Access
|
|
||||||
|
|
||||||
**Read is deny-only** - allowed everywhere by default. We can only deny specific paths, NOT allow only specific paths.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
- ❌ Cannot say "only allow reads from channelDir and scratchpadDir"
|
|
||||||
- ✅ Can say "deny reads from ~/.ssh, ~/.aws, etc."
|
|
||||||
|
|
||||||
The bash tool CAN read files outside the mom data folder. We mitigate by denying sensitive directories.
|
|
||||||
|
|
||||||
## Write Access
|
|
||||||
|
|
||||||
**Write is allow-only** - denied everywhere by default. This works perfectly for our use case:
|
|
||||||
- Only `channelDir` and `scratchpadDir` can be written to
|
|
||||||
- Everything else is blocked
|
|
||||||
|
|
||||||
## Network Access
|
|
||||||
|
|
||||||
- `allowedDomains: []` = no network access
|
|
||||||
- `allowedDomains: ["*"]` = full network access
|
|
||||||
- `allowedDomains: ["github.com", "*.github.com"]` = specific domains
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
- **macOS**: Uses `sandbox-exec` with Seatbelt profiles
|
```
|
||||||
- **Linux**: Uses `bubblewrap` for containerization
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Host │
|
||||||
The sandbox wraps commands - `SandboxManager.wrapWithSandbox("ls")` returns a modified command that runs inside the sandbox.
|
│ │
|
||||||
|
│ mom process (Node.js) │
|
||||||
## Files
|
│ ├── Slack connection │
|
||||||
|
│ ├── LLM API calls │
|
||||||
- `src/sandbox.ts` - Sandbox initialization and command wrapping
|
│ └── Tool execution ──────┐ │
|
||||||
- `src/tools/bash.ts` - Uses `wrapCommand()` before executing
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────┐ │
|
||||||
## Usage in Agent
|
│ │ Docker Container │ │
|
||||||
|
│ │ ├── bash, git, gh, etc │ │
|
||||||
```typescript
|
│ │ └── /workspace (mount) │ │
|
||||||
// In runAgent():
|
│ └─────────────────────────┘ │
|
||||||
await initializeSandbox({ channelDir, scratchpadDir });
|
└─────────────────────────────────────────────────────┘
|
||||||
try {
|
|
||||||
// ... run agent
|
|
||||||
} finally {
|
|
||||||
await resetSandbox();
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## TODO
|
- Mom process runs on host (handles Slack, LLM calls)
|
||||||
|
- All tool execution (`bash`, `read`, `write`, `edit`) happens inside the container
|
||||||
|
- Only `/workspace` (your data dir) is accessible to the container
|
||||||
|
|
||||||
1. **Update network config** - Change `allowedDomains: []` to `["*"]` for full network access
|
## Container Setup
|
||||||
2. **Consider stricter read restrictions** - Current approach denies known sensitive paths but allows reads elsewhere
|
|
||||||
3. **Test on Linux** - Requires `bubblewrap` and `socat` installed
|
|
||||||
|
|
||||||
## Dependencies
|
Use the provided script:
|
||||||
|
|
||||||
macOS:
|
```bash
|
||||||
- `ripgrep` (brew install ripgrep)
|
./docker.sh create <data-dir> # Create and start container
|
||||||
|
./docker.sh start # Start existing container
|
||||||
|
./docker.sh stop # Stop container
|
||||||
|
./docker.sh remove # Remove container
|
||||||
|
./docker.sh status # Check if running
|
||||||
|
./docker.sh shell # Open shell in container
|
||||||
|
```
|
||||||
|
|
||||||
Linux:
|
Or manually:
|
||||||
- `bubblewrap` (apt install bubblewrap)
|
|
||||||
- `socat` (apt install socat)
|
|
||||||
- `ripgrep` (apt install ripgrep)
|
|
||||||
|
|
||||||
## Reference
|
```bash
|
||||||
|
docker run -d --name mom-sandbox \
|
||||||
|
-v /path/to/mom-data:/workspace \
|
||||||
|
alpine:latest tail -f /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
- [sandbox-runtime README](https://github.com/anthropic-experimental/sandbox-runtime)
|
## Mom Manages Her Own Computer
|
||||||
- [Claude Code Sandboxing Docs](https://docs.claude.com/en/docs/claude-code/sandboxing)
|
|
||||||
|
The container is treated as mom's personal computer. She can:
|
||||||
|
|
||||||
|
- Install tools: `apk add github-cli git curl`
|
||||||
|
- Configure credentials: `gh auth login`
|
||||||
|
- Create files and directories
|
||||||
|
- Persist state across restarts
|
||||||
|
|
||||||
|
When mom needs a tool, she installs it. When she needs credentials, she asks you.
|
||||||
|
|
||||||
|
### Example Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "@mom check the spine-runtimes repo"
|
||||||
|
Mom: "I need gh CLI. Installing..."
|
||||||
|
(runs: apk add github-cli)
|
||||||
|
Mom: "I need a GitHub token. Please provide one."
|
||||||
|
User: "ghp_xxxx..."
|
||||||
|
Mom: (runs: echo "ghp_xxxx" | gh auth login --with-token)
|
||||||
|
Mom: "Done. Checking repo..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The container persists across:
|
||||||
|
- `docker stop` / `docker start`
|
||||||
|
- Host reboots
|
||||||
|
|
||||||
|
Installed tools and configs remain until you `docker rm` the container.
|
||||||
|
|
||||||
|
To start fresh: `./docker.sh remove && ./docker.sh create ./data`
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run on host (default, no isolation)
|
||||||
|
mom ./data
|
||||||
|
|
||||||
|
# Run with Docker sandbox
|
||||||
|
mom --sandbox=docker:mom-sandbox ./data
|
||||||
|
|
||||||
|
# Explicit host mode
|
||||||
|
mom --sandbox=host ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**What the container CAN do:**
|
||||||
|
- Read/write files in `/workspace` (your data dir)
|
||||||
|
- Make network requests (for git, gh, curl, etc.)
|
||||||
|
- Install packages
|
||||||
|
- Run any commands
|
||||||
|
|
||||||
|
**What the container CANNOT do:**
|
||||||
|
- Access files outside `/workspace`
|
||||||
|
- Access your host's credentials
|
||||||
|
- Affect your host system
|
||||||
|
|
||||||
|
**For maximum security:**
|
||||||
|
1. Create a dedicated GitHub bot account with limited repo access
|
||||||
|
2. Only share that bot's token with mom
|
||||||
|
3. Don't mount sensitive directories
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container not running
|
||||||
|
```bash
|
||||||
|
./docker.sh status # Check status
|
||||||
|
./docker.sh start # Start it
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset container
|
||||||
|
```bash
|
||||||
|
./docker.sh remove
|
||||||
|
./docker.sh create ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing tools
|
||||||
|
Ask mom to install them, or manually:
|
||||||
|
```bash
|
||||||
|
docker exec mom-sandbox apk add <package>
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
|
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import { existsSync, readFileSync, rmSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { mkdtemp } from "fs/promises";
|
import { mkdir } from "fs/promises";
|
||||||
import { tmpdir } from "os";
|
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
|
import { createExecutor, type SandboxConfig } from "./sandbox.js";
|
||||||
import type { SlackContext } from "./slack.js";
|
import type { SlackContext } from "./slack.js";
|
||||||
import type { ChannelStore } from "./store.js";
|
import type { ChannelStore } from "./store.js";
|
||||||
import { momTools, setUploadFunction } from "./tools/index.js";
|
import { createMomTools, setUploadFunction } from "./tools/index.js";
|
||||||
|
|
||||||
// Hardcoded model for now
|
// Hardcoded model for now
|
||||||
const model = getModel("anthropic", "claude-opus-4-5");
|
const model = getModel("anthropic", "claude-opus-4-5");
|
||||||
|
|
@ -42,7 +42,9 @@ function getRecentMessages(channelDir: string, count: number): string {
|
||||||
return recentLines.join("\n");
|
return recentLines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMessages: string): string {
|
function buildSystemPrompt(workspacePath: string, channelId: string, recentMessages: string): string {
|
||||||
|
const channelPath = `${workspacePath}/${channelId}`;
|
||||||
|
|
||||||
return `You are mom, a helpful Slack bot assistant.
|
return `You are mom, a helpful Slack bot assistant.
|
||||||
|
|
||||||
## Communication Style
|
## Communication Style
|
||||||
|
|
@ -59,46 +61,36 @@ function buildSystemPrompt(channelDir: string, scratchpadDir: string, recentMess
|
||||||
- Links: <url|text>
|
- Links: <url|text>
|
||||||
- Do NOT use **double asterisks** or [markdown](links)
|
- Do NOT use **double asterisks** or [markdown](links)
|
||||||
|
|
||||||
## Channel Data
|
## Your Workspace
|
||||||
The channel's data directory is: ${channelDir}
|
Your working directory is: ${channelPath}
|
||||||
|
|
||||||
### Message History
|
This is YOUR computer - you have full control. You can:
|
||||||
- File: ${channelDir}/log.jsonl
|
- Install tools with the system package manager (apk, apt, etc.)
|
||||||
- Format: One JSON object per line (JSONL)
|
- Configure tools and save credentials
|
||||||
- Each line has: {"ts", "user", "userName", "displayName", "text", "attachments", "isBot"}
|
- Create files and directories as needed
|
||||||
- "ts" is the Slack timestamp
|
|
||||||
- "user" is the user ID, "userName" is their handle, "displayName" is their full name
|
### Channel Data
|
||||||
- "attachments" is an array of {"original", "local"} where "local" is the path relative to the working directory
|
- Message history: ${channelPath}/log.jsonl (JSONL format)
|
||||||
- "isBot" is true for bot responses
|
- Attachments from users: ${channelPath}/attachments/
|
||||||
|
|
||||||
### Recent Messages (last 50)
|
### Recent Messages (last 50)
|
||||||
Below are the most recent messages. If you need more context, read ${channelDir}/log.jsonl directly.
|
|
||||||
|
|
||||||
${recentMessages}
|
${recentMessages}
|
||||||
|
|
||||||
### Attachments
|
|
||||||
Files shared in the channel are stored in: ${channelDir}/attachments/
|
|
||||||
The "local" field in attachments points to these files.
|
|
||||||
|
|
||||||
## Scratchpad
|
|
||||||
Your temporary working directory is: ${scratchpadDir}
|
|
||||||
Use this for any file operations. It will be deleted after you complete.
|
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
You have access to: read, edit, write, bash, attach tools.
|
You have access to: bash, read, edit, write, attach tools.
|
||||||
|
- bash: Run shell commands (this is your main tool)
|
||||||
- read: Read files
|
- read: Read files
|
||||||
- edit: Edit files
|
- edit: Edit files surgically
|
||||||
- write: Write new files
|
- write: Create/overwrite files
|
||||||
- bash: Run shell commands
|
- attach: Share a file with the user in Slack
|
||||||
- attach: Attach a file to your response (share files with the user)
|
|
||||||
|
|
||||||
Each tool requires a "label" parameter - this is a brief description of what you're doing that will be shown to the user.
|
Each tool requires a "label" parameter - brief description shown to the user.
|
||||||
Keep labels short and informative, e.g., "Reading message history" or "Searching for user's previous questions".
|
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
- Be concise and helpful
|
- Be concise and helpful
|
||||||
- If you need more conversation history beyond the recent messages above, read log.jsonl
|
- Use bash for most operations
|
||||||
- Use the scratchpad for any temporary work
|
- If you need a tool, install it
|
||||||
|
- If you need credentials, ask the user
|
||||||
|
|
||||||
## CRITICAL
|
## CRITICAL
|
||||||
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
|
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
|
||||||
|
|
@ -110,129 +102,127 @@ function truncate(text: string, maxLen: number): string {
|
||||||
return text.substring(0, maxLen - 3) + "...";
|
return text.substring(0, maxLen - 3) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAgentRunner(): AgentRunner {
|
export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
let agent: Agent | null = null;
|
let agent: Agent | null = null;
|
||||||
|
const executor = createExecutor(sandboxConfig);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
|
async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<void> {
|
||||||
// Create scratchpad
|
// Ensure channel directory exists
|
||||||
const scratchpadDir = await mkdtemp(join(tmpdir(), "mom-scratchpad-"));
|
await mkdir(channelDir, { recursive: true });
|
||||||
|
|
||||||
try {
|
const channelId = ctx.message.channel;
|
||||||
const recentMessages = getRecentMessages(channelDir, 50);
|
const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
|
||||||
const systemPrompt = buildSystemPrompt(channelDir, scratchpadDir, recentMessages);
|
const recentMessages = getRecentMessages(channelDir, 50);
|
||||||
|
const systemPrompt = buildSystemPrompt(workspacePath, channelId, recentMessages);
|
||||||
|
|
||||||
// Set up file upload function for the attach tool
|
// Set up file upload function for the attach tool
|
||||||
setUploadFunction(async (filePath: string, title?: string) => {
|
// For Docker, we need to translate paths back to host
|
||||||
await ctx.uploadFile(filePath, title);
|
setUploadFunction(async (filePath: string, title?: string) => {
|
||||||
});
|
const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
|
||||||
|
await ctx.uploadFile(hostPath, title);
|
||||||
|
});
|
||||||
|
|
||||||
// Create ephemeral agent
|
// Create tools with executor
|
||||||
agent = new Agent({
|
const tools = createMomTools(executor);
|
||||||
initialState: {
|
|
||||||
systemPrompt,
|
|
||||||
model,
|
|
||||||
thinkingLevel: "off",
|
|
||||||
tools: momTools,
|
|
||||||
},
|
|
||||||
transport: new ProviderTransport({
|
|
||||||
getApiKey: async () => getAnthropicApiKey(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to events
|
// Create ephemeral agent
|
||||||
agent.subscribe(async (event: AgentEvent) => {
|
agent = new Agent({
|
||||||
switch (event.type) {
|
initialState: {
|
||||||
case "tool_execution_start": {
|
systemPrompt,
|
||||||
const args = event.args as { label?: string };
|
model,
|
||||||
const label = args.label || event.toolName;
|
thinkingLevel: "off",
|
||||||
|
tools,
|
||||||
|
},
|
||||||
|
transport: new ProviderTransport({
|
||||||
|
getApiKey: async () => getAnthropicApiKey(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// Log to console
|
// Subscribe to events
|
||||||
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
|
agent.subscribe(async (event: AgentEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "tool_execution_start": {
|
||||||
|
const args = event.args as { label?: string };
|
||||||
|
const label = args.label || event.toolName;
|
||||||
|
|
||||||
// Log to jsonl
|
// Log to console
|
||||||
await store.logMessage(ctx.message.channel, {
|
console.log(`\n[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`);
|
||||||
ts: Date.now().toString(),
|
|
||||||
user: "bot",
|
|
||||||
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
|
||||||
attachments: [],
|
|
||||||
isBot: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show only label to user (italic)
|
// Log to jsonl
|
||||||
await ctx.respond(`_${label}_`);
|
await store.logMessage(ctx.message.channel, {
|
||||||
break;
|
ts: Date.now().toString(),
|
||||||
}
|
user: "bot",
|
||||||
|
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
||||||
|
attachments: [],
|
||||||
|
isBot: true,
|
||||||
|
});
|
||||||
|
|
||||||
case "tool_execution_end": {
|
// Show only label to user (italic)
|
||||||
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
await ctx.respond(`_${label}_`);
|
||||||
|
break;
|
||||||
// Log to console
|
|
||||||
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
|
|
||||||
|
|
||||||
// Log to jsonl
|
|
||||||
await store.logMessage(ctx.message.channel, {
|
|
||||||
ts: Date.now().toString(),
|
|
||||||
user: "bot",
|
|
||||||
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
|
||||||
attachments: [],
|
|
||||||
isBot: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show brief status to user (only on error)
|
|
||||||
if (event.isError) {
|
|
||||||
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "message_update": {
|
|
||||||
const ev = event.assistantMessageEvent;
|
|
||||||
// Stream deltas to console
|
|
||||||
if (ev.type === "text_delta") {
|
|
||||||
process.stdout.write(ev.delta);
|
|
||||||
} else if (ev.type === "thinking_delta") {
|
|
||||||
process.stdout.write(ev.delta);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "message_start":
|
|
||||||
if (event.message.role === "assistant") {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "message_end":
|
|
||||||
if (event.message.role === "assistant") {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
// Extract text from assistant message
|
|
||||||
const content = event.message.content;
|
|
||||||
let text = "";
|
|
||||||
for (const part of content) {
|
|
||||||
if (part.type === "text") {
|
|
||||||
text += part.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (text.trim()) {
|
|
||||||
await ctx.respond(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Run the agent with user's message
|
case "tool_execution_end": {
|
||||||
await agent.prompt(ctx.message.text || "(attached files)");
|
const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
|
||||||
} finally {
|
|
||||||
agent = null;
|
// Log to console
|
||||||
// Cleanup scratchpad
|
console.log(`[Tool Result] ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}\n`);
|
||||||
try {
|
|
||||||
rmSync(scratchpadDir, { recursive: true, force: true });
|
// Log to jsonl
|
||||||
} catch {
|
await store.logMessage(ctx.message.channel, {
|
||||||
// Ignore cleanup errors
|
ts: Date.now().toString(),
|
||||||
|
user: "bot",
|
||||||
|
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
||||||
|
attachments: [],
|
||||||
|
isBot: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show brief status to user (only on error)
|
||||||
|
if (event.isError) {
|
||||||
|
await ctx.respond(`_Error: ${truncate(resultStr, 200)}_`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_update": {
|
||||||
|
const ev = event.assistantMessageEvent;
|
||||||
|
// Stream deltas to console
|
||||||
|
if (ev.type === "text_delta") {
|
||||||
|
process.stdout.write(ev.delta);
|
||||||
|
} else if (ev.type === "thinking_delta") {
|
||||||
|
process.stdout.write(ev.delta);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_start":
|
||||||
|
if (event.message.role === "assistant") {
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_end":
|
||||||
|
if (event.message.role === "assistant") {
|
||||||
|
process.stdout.write("\n");
|
||||||
|
// Extract text from assistant message
|
||||||
|
const content = event.message.content;
|
||||||
|
let text = "";
|
||||||
|
for (const part of content) {
|
||||||
|
if (part.type === "text") {
|
||||||
|
text += part.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text.trim()) {
|
||||||
|
await ctx.respond(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// Run the agent with user's message
|
||||||
|
await agent.prompt(ctx.message.text || "(attached files)");
|
||||||
},
|
},
|
||||||
|
|
||||||
abort(): void {
|
abort(): void {
|
||||||
|
|
@ -240,3 +230,27 @@ export function createAgentRunner(): AgentRunner {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate container path back to host path for file operations
|
||||||
|
*/
|
||||||
|
function translateToHostPath(
|
||||||
|
containerPath: string,
|
||||||
|
channelDir: string,
|
||||||
|
workspacePath: string,
|
||||||
|
channelId: string,
|
||||||
|
): string {
|
||||||
|
if (workspacePath === "/workspace") {
|
||||||
|
// Docker mode - translate /workspace/channelId/... to host path
|
||||||
|
const prefix = `/workspace/${channelId}/`;
|
||||||
|
if (containerPath.startsWith(prefix)) {
|
||||||
|
return join(channelDir, containerPath.slice(prefix.length));
|
||||||
|
}
|
||||||
|
// Maybe it's just /workspace/...
|
||||||
|
if (containerPath.startsWith("/workspace/")) {
|
||||||
|
return join(channelDir, "..", containerPath.slice("/workspace/".length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Host mode or already a host path
|
||||||
|
return containerPath;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,60 @@
|
||||||
|
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import { type AgentRunner, createAgentRunner } from "./agent.js";
|
import { type AgentRunner, createAgentRunner } from "./agent.js";
|
||||||
|
import { parseSandboxArg, type SandboxConfig, validateSandbox } from "./sandbox.js";
|
||||||
import { MomBot, type SlackContext } from "./slack.js";
|
import { MomBot, type SlackContext } from "./slack.js";
|
||||||
|
|
||||||
console.log("Starting mom bot...");
|
|
||||||
|
|
||||||
const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
||||||
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
||||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||||
const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;
|
const ANTHROPIC_OAUTH_TOKEN = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||||
|
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
const args = process.argv.slice(2);
|
function parseArgs(): { workingDir: string; sandbox: SandboxConfig } {
|
||||||
if (args.length !== 1) {
|
const args = process.argv.slice(2);
|
||||||
console.error("Usage: mom <working-directory>");
|
let sandbox: SandboxConfig = { type: "host" };
|
||||||
console.error("Example: mom ./mom-data");
|
let workingDir: string | undefined;
|
||||||
process.exit(1);
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg.startsWith("--sandbox=")) {
|
||||||
|
sandbox = parseSandboxArg(arg.slice("--sandbox=".length));
|
||||||
|
} else if (arg === "--sandbox") {
|
||||||
|
const next = args[++i];
|
||||||
|
if (!next) {
|
||||||
|
console.error("Error: --sandbox requires a value (host or docker:<container-name>)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
sandbox = parseSandboxArg(next);
|
||||||
|
} else if (!arg.startsWith("-")) {
|
||||||
|
workingDir = arg;
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown option: ${arg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workingDir) {
|
||||||
|
console.error("Usage: mom [--sandbox=host|docker:<container-name>] <working-directory>");
|
||||||
|
console.error("");
|
||||||
|
console.error("Options:");
|
||||||
|
console.error(" --sandbox=host Run tools directly on host (default)");
|
||||||
|
console.error(" --sandbox=docker:<container> Run tools in Docker container");
|
||||||
|
console.error("");
|
||||||
|
console.error("Examples:");
|
||||||
|
console.error(" mom ./data");
|
||||||
|
console.error(" mom --sandbox=docker:mom-sandbox ./data");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { workingDir: resolve(workingDir), sandbox };
|
||||||
}
|
}
|
||||||
|
|
||||||
const workingDir = resolve(args[0]);
|
const { workingDir, sandbox } = parseArgs();
|
||||||
|
|
||||||
|
console.log("Starting mom bot...");
|
||||||
|
console.log(` Working directory: ${workingDir}`);
|
||||||
|
console.log(` Sandbox: ${sandbox.type === "host" ? "host" : `docker:${sandbox.container}`}`);
|
||||||
|
|
||||||
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
|
if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTHROPIC_OAUTH_TOKEN)) {
|
||||||
console.error("Missing required environment variables:");
|
console.error("Missing required environment variables:");
|
||||||
|
|
@ -29,6 +65,9 @@ if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTH
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate sandbox configuration
|
||||||
|
await validateSandbox(sandbox);
|
||||||
|
|
||||||
// Track active agent runs per channel
|
// Track active agent runs per channel
|
||||||
const activeRuns = new Map<string, AgentRunner>();
|
const activeRuns = new Map<string, AgentRunner>();
|
||||||
|
|
||||||
|
|
@ -58,7 +97,7 @@ async function handleMessage(ctx: SlackContext, source: "channel" | "dm"): Promi
|
||||||
console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`);
|
console.log(`${source === "channel" ? "Channel mention" : "DM"} from <@${ctx.message.user}>: ${ctx.message.text}`);
|
||||||
const channelDir = join(workingDir, channelId);
|
const channelDir = join(workingDir, channelId);
|
||||||
|
|
||||||
const runner = createAgentRunner();
|
const runner = createAgentRunner(sandbox);
|
||||||
activeRuns.set(channelId, runner);
|
activeRuns.set(channelId, runner);
|
||||||
|
|
||||||
await ctx.setTyping(true);
|
await ctx.setTyping(true);
|
||||||
|
|
|
||||||
221
packages/mom/src/sandbox.ts
Normal file
221
packages/mom/src/sandbox.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
|
||||||
|
export type SandboxConfig = { type: "host" } | { type: "docker"; container: string };
|
||||||
|
|
||||||
|
export function parseSandboxArg(value: string): SandboxConfig {
|
||||||
|
if (value === "host") {
|
||||||
|
return { type: "host" };
|
||||||
|
}
|
||||||
|
if (value.startsWith("docker:")) {
|
||||||
|
const container = value.slice("docker:".length);
|
||||||
|
if (!container) {
|
||||||
|
console.error("Error: docker sandbox requires container name (e.g., docker:mom-sandbox)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return { type: "docker", container };
|
||||||
|
}
|
||||||
|
console.error(`Error: Invalid sandbox type '${value}'. Use 'host' or 'docker:<container-name>'`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSandbox(config: SandboxConfig): Promise<void> {
|
||||||
|
if (config.type === "host") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Docker is available
|
||||||
|
try {
|
||||||
|
await execSimple("docker", ["--version"]);
|
||||||
|
} catch {
|
||||||
|
console.error("Error: Docker is not installed or not in PATH");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if container exists and is running
|
||||||
|
try {
|
||||||
|
const result = await execSimple("docker", ["inspect", "-f", "{{.State.Running}}", config.container]);
|
||||||
|
if (result.trim() !== "true") {
|
||||||
|
console.error(`Error: Container '${config.container}' is not running.`);
|
||||||
|
console.error(`Start it with: docker start ${config.container}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`Error: Container '${config.container}' does not exist.`);
|
||||||
|
console.error("Create it with: ./docker.sh create <data-dir>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Docker container '${config.container}' is running.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function execSimple(cmd: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout?.on("data", (d) => {
|
||||||
|
stdout += d;
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (d) => {
|
||||||
|
stderr += d;
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) resolve(stdout);
|
||||||
|
else reject(new Error(stderr || `Exit code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an executor that runs commands either on host or in Docker container
|
||||||
|
*/
|
||||||
|
export function createExecutor(config: SandboxConfig): Executor {
|
||||||
|
if (config.type === "host") {
|
||||||
|
return new HostExecutor();
|
||||||
|
}
|
||||||
|
return new DockerExecutor(config.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Executor {
|
||||||
|
/**
|
||||||
|
* Execute a bash command
|
||||||
|
*/
|
||||||
|
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the workspace path prefix for this executor
|
||||||
|
* Host: returns the actual path
|
||||||
|
* Docker: returns /workspace
|
||||||
|
*/
|
||||||
|
getWorkspacePath(hostPath: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecOptions {
|
||||||
|
timeout?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HostExecutor implements Executor {
|
||||||
|
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const shell = process.platform === "win32" ? "cmd" : "sh";
|
||||||
|
const shellArgs = process.platform === "win32" ? ["/c"] : ["-c"];
|
||||||
|
|
||||||
|
const child = spawn(shell, [...shellArgs, command], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
const timeoutHandle =
|
||||||
|
options?.timeout && options.timeout > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
killProcessTree(child.pid!);
|
||||||
|
}, options.timeout * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
if (child.pid) killProcessTree(child.pid);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.signal) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
} else {
|
||||||
|
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
if (stdout.length > 10 * 1024 * 1024) {
|
||||||
|
stdout = stdout.slice(0, 10 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
if (stderr.length > 10 * 1024 * 1024) {
|
||||||
|
stderr = stderr.slice(0, 10 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
if (options?.signal) {
|
||||||
|
options.signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
reject(new Error(`${stdout}\n${stderr}\nCommand aborted`.trim()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
reject(new Error(`${stdout}\n${stderr}\nCommand timed out after ${options?.timeout} seconds`.trim()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ stdout, stderr, code: code ?? 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspacePath(hostPath: string): string {
|
||||||
|
return hostPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DockerExecutor implements Executor {
|
||||||
|
constructor(private container: string) {}
|
||||||
|
|
||||||
|
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
|
||||||
|
// Wrap command for docker exec
|
||||||
|
const dockerCmd = `docker exec ${this.container} sh -c ${shellEscape(command)}`;
|
||||||
|
const hostExecutor = new HostExecutor();
|
||||||
|
return hostExecutor.exec(dockerCmd, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspacePath(_hostPath: string): string {
|
||||||
|
// Docker container sees /workspace
|
||||||
|
return "/workspace";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killProcessTree(pid: number): void {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
try {
|
||||||
|
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Process already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellEscape(s: string): string {
|
||||||
|
// Escape for passing to sh -c
|
||||||
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
@ -1,65 +1,6 @@
|
||||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { spawn } from "child_process";
|
import type { Executor } from "../sandbox.js";
|
||||||
import { existsSync } from "fs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get shell configuration based on platform
|
|
||||||
*/
|
|
||||||
function getShellConfig(): { shell: string; args: string[] } {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const paths: string[] = [];
|
|
||||||
const programFiles = process.env.ProgramFiles;
|
|
||||||
if (programFiles) {
|
|
||||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
||||||
}
|
|
||||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
|
||||||
if (programFilesX86) {
|
|
||||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
if (existsSync(path)) {
|
|
||||||
return { shell: path, args: ["-c"] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` +
|
|
||||||
`Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { shell: "sh", args: ["-c"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill a process and all its children
|
|
||||||
*/
|
|
||||||
function killProcessTree(pid: number): void {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
// Use taskkill on Windows to kill process tree
|
|
||||||
try {
|
|
||||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
||||||
stdio: "ignore",
|
|
||||||
detached: true,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Ignore errors if taskkill fails
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use SIGKILL on Unix/Linux/Mac
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
// Fallback to killing just the child if process group kill fails
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
// Process already dead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bashSchema = Type.Object({
|
const bashSchema = Type.Object({
|
||||||
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
|
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
|
||||||
|
|
@ -67,123 +8,31 @@ const bashSchema = Type.Object({
|
||||||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bashTool: AgentTool<typeof bashSchema> = {
|
export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
|
||||||
name: "bash",
|
return {
|
||||||
label: "bash",
|
name: "bash",
|
||||||
description:
|
label: "bash",
|
||||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
description:
|
||||||
parameters: bashSchema,
|
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
||||||
execute: async (
|
parameters: bashSchema,
|
||||||
_toolCallId: string,
|
execute: async (
|
||||||
{ command, timeout }: { label: string; command: string; timeout?: number },
|
_toolCallId: string,
|
||||||
signal?: AbortSignal,
|
{ command, timeout }: { label: string; command: string; timeout?: number },
|
||||||
) => {
|
signal?: AbortSignal,
|
||||||
return new Promise((resolve, reject) => {
|
) => {
|
||||||
const { shell, args } = getShellConfig();
|
const result = await executor.exec(command, { timeout, signal });
|
||||||
const child = spawn(shell, [...args, command], {
|
let output = "";
|
||||||
detached: true,
|
if (result.stdout) output += result.stdout;
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
if (result.stderr) {
|
||||||
});
|
if (output) output += "\n";
|
||||||
|
output += result.stderr;
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
// Set timeout if provided
|
|
||||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
||||||
if (timeout !== undefined && timeout > 0) {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
onAbort();
|
|
||||||
}, timeout * 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect stdout
|
if (result.code !== 0) {
|
||||||
if (child.stdout) {
|
throw new Error(`${output}\n\nCommand exited with code ${result.code}`.trim());
|
||||||
child.stdout.on("data", (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
// Limit buffer size
|
|
||||||
if (stdout.length > 10 * 1024 * 1024) {
|
|
||||||
stdout = stdout.slice(0, 10 * 1024 * 1024);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect stderr
|
return { content: [{ type: "text", text: output || "(no output)" }], details: undefined };
|
||||||
if (child.stderr) {
|
},
|
||||||
child.stderr.on("data", (data) => {
|
};
|
||||||
stderr += data.toString();
|
}
|
||||||
// Limit buffer size
|
|
||||||
if (stderr.length > 10 * 1024 * 1024) {
|
|
||||||
stderr = stderr.slice(0, 10 * 1024 * 1024);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle process exit
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
}
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
let output = "";
|
|
||||||
if (stdout) output += stdout;
|
|
||||||
if (stderr) {
|
|
||||||
if (output) output += "\n";
|
|
||||||
output += stderr;
|
|
||||||
}
|
|
||||||
if (output) output += "\n\n";
|
|
||||||
output += "Command aborted";
|
|
||||||
reject(new Error(output));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timedOut) {
|
|
||||||
let output = "";
|
|
||||||
if (stdout) output += stdout;
|
|
||||||
if (stderr) {
|
|
||||||
if (output) output += "\n";
|
|
||||||
output += stderr;
|
|
||||||
}
|
|
||||||
if (output) output += "\n\n";
|
|
||||||
output += `Command timed out after ${timeout} seconds`;
|
|
||||||
reject(new Error(output));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = "";
|
|
||||||
if (stdout) output += stdout;
|
|
||||||
if (stderr) {
|
|
||||||
if (output) output += "\n";
|
|
||||||
output += stderr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code !== 0 && code !== null) {
|
|
||||||
if (output) output += "\n\n";
|
|
||||||
reject(new Error(`${output}Command exited with code ${code}`));
|
|
||||||
} else {
|
|
||||||
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle abort signal - kill entire process tree
|
|
||||||
const onAbort = () => {
|
|
||||||
if (child.pid) {
|
|
||||||
killProcessTree(child.pid);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
onAbort();
|
|
||||||
} else {
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,7 @@
|
||||||
import * as os from "node:os";
|
|
||||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import * as Diff from "diff";
|
import * as Diff from "diff";
|
||||||
import { constants } from "fs";
|
import type { Executor } from "../sandbox.js";
|
||||||
import { access, readFile, writeFile } from "fs/promises";
|
|
||||||
import { resolve as resolvePath } from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand ~ to home directory
|
|
||||||
*/
|
|
||||||
function expandPath(filePath: string): string {
|
|
||||||
if (filePath === "~") {
|
|
||||||
return os.homedir();
|
|
||||||
}
|
|
||||||
if (filePath.startsWith("~/")) {
|
|
||||||
return os.homedir() + filePath.slice(1);
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unified diff string with line numbers and context
|
* Generate a unified diff string with line numbers and context
|
||||||
|
|
@ -43,14 +27,12 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.added || part.removed) {
|
if (part.added || part.removed) {
|
||||||
// Show the change
|
|
||||||
for (const line of raw) {
|
for (const line of raw) {
|
||||||
if (part.added) {
|
if (part.added) {
|
||||||
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
||||||
output.push(`+${lineNum} ${line}`);
|
output.push(`+${lineNum} ${line}`);
|
||||||
newLineNum++;
|
newLineNum++;
|
||||||
} else {
|
} else {
|
||||||
// removed
|
|
||||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||||
output.push(`-${lineNum} ${line}`);
|
output.push(`-${lineNum} ${line}`);
|
||||||
oldLineNum++;
|
oldLineNum++;
|
||||||
|
|
@ -58,28 +40,23 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
||||||
}
|
}
|
||||||
lastWasChange = true;
|
lastWasChange = true;
|
||||||
} else {
|
} else {
|
||||||
// Context lines - only show a few before/after changes
|
|
||||||
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
||||||
|
|
||||||
if (lastWasChange || nextPartIsChange) {
|
if (lastWasChange || nextPartIsChange) {
|
||||||
// Show context
|
|
||||||
let linesToShow = raw;
|
let linesToShow = raw;
|
||||||
let skipStart = 0;
|
let skipStart = 0;
|
||||||
let skipEnd = 0;
|
let skipEnd = 0;
|
||||||
|
|
||||||
if (!lastWasChange) {
|
if (!lastWasChange) {
|
||||||
// Show only last N lines as leading context
|
|
||||||
skipStart = Math.max(0, raw.length - contextLines);
|
skipStart = Math.max(0, raw.length - contextLines);
|
||||||
linesToShow = raw.slice(skipStart);
|
linesToShow = raw.slice(skipStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
||||||
// Show only first N lines as trailing context
|
|
||||||
skipEnd = linesToShow.length - contextLines;
|
skipEnd = linesToShow.length - contextLines;
|
||||||
linesToShow = linesToShow.slice(0, contextLines);
|
linesToShow = linesToShow.slice(0, contextLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ellipsis if we skipped lines at start
|
|
||||||
if (skipStart > 0) {
|
if (skipStart > 0) {
|
||||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||||
}
|
}
|
||||||
|
|
@ -91,16 +68,13 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
||||||
newLineNum++;
|
newLineNum++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ellipsis if we skipped lines at end
|
|
||||||
if (skipEnd > 0) {
|
if (skipEnd > 0) {
|
||||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update line numbers for skipped lines
|
|
||||||
oldLineNum += skipStart + skipEnd;
|
oldLineNum += skipStart + skipEnd;
|
||||||
newLineNum += skipStart + skipEnd;
|
newLineNum += skipStart + skipEnd;
|
||||||
} else {
|
} else {
|
||||||
// Skip these context lines entirely
|
|
||||||
oldLineNum += raw.length;
|
oldLineNum += raw.length;
|
||||||
newLineNum += raw.length;
|
newLineNum += raw.length;
|
||||||
}
|
}
|
||||||
|
|
@ -119,151 +93,73 @@ const editSchema = Type.Object({
|
||||||
newText: Type.String({ description: "New text to replace the old text with" }),
|
newText: Type.String({ description: "New text to replace the old text with" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const editTool: AgentTool<typeof editSchema> = {
|
export function createEditTool(executor: Executor): AgentTool<typeof editSchema> {
|
||||||
name: "edit",
|
return {
|
||||||
label: "edit",
|
name: "edit",
|
||||||
description:
|
label: "edit",
|
||||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
description:
|
||||||
parameters: editSchema,
|
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||||
execute: async (
|
parameters: editSchema,
|
||||||
_toolCallId: string,
|
execute: async (
|
||||||
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
|
_toolCallId: string,
|
||||||
signal?: AbortSignal,
|
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
|
||||||
) => {
|
signal?: AbortSignal,
|
||||||
const absolutePath = resolvePath(expandPath(path));
|
) => {
|
||||||
|
// Read the file
|
||||||
return new Promise<{
|
const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
|
||||||
content: Array<{ type: "text"; text: string }>;
|
if (readResult.code !== 0) {
|
||||||
details: { diff: string } | undefined;
|
throw new Error(readResult.stderr || `File not found: ${path}`);
|
||||||
}>((resolve, reject) => {
|
|
||||||
// Check if already aborted
|
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error("Operation aborted"));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let aborted = false;
|
const content = readResult.stdout;
|
||||||
|
|
||||||
// Set up abort handler
|
// Check if old text exists
|
||||||
const onAbort = () => {
|
if (!content.includes(oldText)) {
|
||||||
aborted = true;
|
throw new Error(
|
||||||
reject(new Error("Operation aborted"));
|
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count occurrences
|
||||||
|
const occurrences = content.split(oldText).length - 1;
|
||||||
|
|
||||||
|
if (occurrences > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform replacement
|
||||||
|
const index = content.indexOf(oldText);
|
||||||
|
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
|
||||||
|
|
||||||
|
if (content === newContent) {
|
||||||
|
throw new Error(
|
||||||
|
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the file back
|
||||||
|
const writeResult = await executor.exec(`printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (writeResult.code !== 0) {
|
||||||
|
throw new Error(writeResult.stderr || `Failed to write file: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { diff: generateDiffString(content, newContent) },
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (signal) {
|
function shellEscape(s: string): string {
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the edit operation
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// Check if file exists
|
|
||||||
try {
|
|
||||||
await access(absolutePath, constants.R_OK | constants.W_OK);
|
|
||||||
} catch {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
reject(new Error(`File not found: ${path}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if aborted before reading
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file
|
|
||||||
const content = await readFile(absolutePath, "utf-8");
|
|
||||||
|
|
||||||
// Check if aborted after reading
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if old text exists
|
|
||||||
if (!content.includes(oldText)) {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count occurrences
|
|
||||||
const occurrences = content.split(oldText).length - 1;
|
|
||||||
|
|
||||||
if (occurrences > 1) {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if aborted before writing
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform replacement using indexOf + substring (raw string replace, no special character interpretation)
|
|
||||||
// String.replace() interprets $ in the replacement string, so we do manual replacement
|
|
||||||
const index = content.indexOf(oldText);
|
|
||||||
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
|
|
||||||
|
|
||||||
// Verify the replacement actually changed something
|
|
||||||
if (content === newContent) {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(absolutePath, newContent, "utf-8");
|
|
||||||
|
|
||||||
// Check if aborted after writing
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up abort handler
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { diff: generateDiffString(content, newContent) },
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Clean up abort handler
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!aborted) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
export { attachTool, setUploadFunction } from "./attach.js";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
export { bashTool } from "./bash.js";
|
import type { Executor } from "../sandbox.js";
|
||||||
export { editTool } from "./edit.js";
|
|
||||||
export { readTool } from "./read.js";
|
|
||||||
export { writeTool } from "./write.js";
|
|
||||||
|
|
||||||
import { attachTool } from "./attach.js";
|
import { attachTool } from "./attach.js";
|
||||||
import { bashTool } from "./bash.js";
|
import { createBashTool } from "./bash.js";
|
||||||
import { editTool } from "./edit.js";
|
import { createEditTool } from "./edit.js";
|
||||||
import { readTool } from "./read.js";
|
import { createReadTool } from "./read.js";
|
||||||
import { writeTool } from "./write.js";
|
import { createWriteTool } from "./write.js";
|
||||||
|
|
||||||
export const momTools = [readTool, bashTool, editTool, writeTool, attachTool];
|
export { setUploadFunction } from "./attach.js";
|
||||||
|
|
||||||
|
export function createMomTools(executor: Executor): AgentTool<any>[] {
|
||||||
|
return [
|
||||||
|
createReadTool(executor),
|
||||||
|
createBashTool(executor),
|
||||||
|
createEditTool(executor),
|
||||||
|
createWriteTool(executor),
|
||||||
|
attachTool,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,7 @@
|
||||||
import * as os from "node:os";
|
|
||||||
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
|
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { constants } from "fs";
|
import { extname } from "path";
|
||||||
import { access, readFile } from "fs/promises";
|
import type { Executor } from "../sandbox.js";
|
||||||
import { extname, resolve as resolvePath } from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand ~ to home directory
|
|
||||||
*/
|
|
||||||
function expandPath(filePath: string): string {
|
|
||||||
if (filePath === "~") {
|
|
||||||
return os.homedir();
|
|
||||||
}
|
|
||||||
if (filePath.startsWith("~/")) {
|
|
||||||
return os.homedir() + filePath.slice(1);
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of file extensions to MIME types for common image formats
|
* Map of file extensions to MIME types for common image formats
|
||||||
|
|
@ -47,133 +32,96 @@ const readSchema = Type.Object({
|
||||||
const MAX_LINES = 2000;
|
const MAX_LINES = 2000;
|
||||||
const MAX_LINE_LENGTH = 2000;
|
const MAX_LINE_LENGTH = 2000;
|
||||||
|
|
||||||
export const readTool: AgentTool<typeof readSchema> = {
|
export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
|
||||||
name: "read",
|
return {
|
||||||
label: "read",
|
name: "read",
|
||||||
description:
|
label: "read",
|
||||||
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
description:
|
||||||
parameters: readSchema,
|
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
||||||
execute: async (
|
parameters: readSchema,
|
||||||
_toolCallId: string,
|
execute: async (
|
||||||
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
_toolCallId: string,
|
||||||
signal?: AbortSignal,
|
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
||||||
) => {
|
signal?: AbortSignal,
|
||||||
const absolutePath = resolvePath(expandPath(path));
|
) => {
|
||||||
const mimeType = isImageFile(absolutePath);
|
const mimeType = isImageFile(path);
|
||||||
|
|
||||||
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
|
if (mimeType) {
|
||||||
// Check if already aborted
|
// Read as image (binary) - use base64
|
||||||
if (signal?.aborted) {
|
const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
|
||||||
reject(new Error("Operation aborted"));
|
if (result.code !== 0) {
|
||||||
return;
|
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
||||||
}
|
|
||||||
|
|
||||||
let aborted = false;
|
|
||||||
|
|
||||||
// Set up abort handler
|
|
||||||
const onAbort = () => {
|
|
||||||
aborted = true;
|
|
||||||
reject(new Error("Operation aborted"));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the read operation
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// Check if file exists
|
|
||||||
await access(absolutePath, constants.R_OK);
|
|
||||||
|
|
||||||
// Check if aborted before reading
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file based on type
|
|
||||||
let content: (TextContent | ImageContent)[];
|
|
||||||
|
|
||||||
if (mimeType) {
|
|
||||||
// Read as image (binary)
|
|
||||||
const buffer = await readFile(absolutePath);
|
|
||||||
const base64 = buffer.toString("base64");
|
|
||||||
|
|
||||||
content = [
|
|
||||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
|
||||||
{ type: "image", data: base64, mimeType },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// Read as text
|
|
||||||
const textContent = await readFile(absolutePath, "utf-8");
|
|
||||||
const lines = textContent.split("\n");
|
|
||||||
|
|
||||||
// Apply offset and limit (matching Claude Code Read tool behavior)
|
|
||||||
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
|
|
||||||
const maxLines = limit || MAX_LINES;
|
|
||||||
const endLine = Math.min(startLine + maxLines, lines.length);
|
|
||||||
|
|
||||||
// Check if offset is out of bounds
|
|
||||||
if (startLine >= lines.length) {
|
|
||||||
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the relevant lines
|
|
||||||
const selectedLines = lines.slice(startLine, endLine);
|
|
||||||
|
|
||||||
// Truncate long lines and track which were truncated
|
|
||||||
let hadTruncatedLines = false;
|
|
||||||
const formattedLines = selectedLines.map((line) => {
|
|
||||||
if (line.length > MAX_LINE_LENGTH) {
|
|
||||||
hadTruncatedLines = true;
|
|
||||||
return line.slice(0, MAX_LINE_LENGTH);
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
|
|
||||||
let outputText = formattedLines.join("\n");
|
|
||||||
|
|
||||||
// Add notices
|
|
||||||
const notices: string[] = [];
|
|
||||||
|
|
||||||
if (hadTruncatedLines) {
|
|
||||||
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endLine < lines.length) {
|
|
||||||
const remaining = lines.length - endLine;
|
|
||||||
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notices.length > 0) {
|
|
||||||
outputText += `\n\n... (${notices.join(". ")})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
content = [{ type: "text", text: outputText }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if aborted after reading
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up abort handler
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({ content, details: undefined });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Clean up abort handler
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!aborted) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
const base64 = result.stdout.replace(/\s/g, ""); // Remove whitespace from base64
|
||||||
});
|
|
||||||
},
|
return {
|
||||||
};
|
content: [
|
||||||
|
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||||
|
{ type: "image", data: base64, mimeType },
|
||||||
|
] as (TextContent | ImageContent)[],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read as text using cat with offset/limit via sed/head/tail
|
||||||
|
let cmd: string;
|
||||||
|
const startLine = offset ? Math.max(1, offset) : 1;
|
||||||
|
const maxLines = limit || MAX_LINES;
|
||||||
|
|
||||||
|
if (startLine === 1) {
|
||||||
|
cmd = `head -n ${maxLines} ${shellEscape(path)}`;
|
||||||
|
} else {
|
||||||
|
cmd = `sed -n '${startLine},${startLine + maxLines - 1}p' ${shellEscape(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get total line count
|
||||||
|
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
|
||||||
|
const totalLines = Number.parseInt(countResult.stdout.trim(), 10) || 0;
|
||||||
|
|
||||||
|
const result = await executor.exec(cmd, { signal });
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = result.stdout.split("\n");
|
||||||
|
|
||||||
|
// Truncate long lines
|
||||||
|
let hadTruncatedLines = false;
|
||||||
|
const formattedLines = lines.map((line) => {
|
||||||
|
if (line.length > MAX_LINE_LENGTH) {
|
||||||
|
hadTruncatedLines = true;
|
||||||
|
return line.slice(0, MAX_LINE_LENGTH);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
let outputText = formattedLines.join("\n");
|
||||||
|
|
||||||
|
// Add notices
|
||||||
|
const notices: string[] = [];
|
||||||
|
const endLine = startLine + lines.length - 1;
|
||||||
|
|
||||||
|
if (hadTruncatedLines) {
|
||||||
|
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endLine < totalLines) {
|
||||||
|
const remaining = totalLines - endLine;
|
||||||
|
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notices.length > 0) {
|
||||||
|
outputText += `\n\n... (${notices.join(". ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: outputText }] as (TextContent | ImageContent)[],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellEscape(s: string): string {
|
||||||
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,6 @@
|
||||||
import * as os from "node:os";
|
|
||||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { mkdir, writeFile } from "fs/promises";
|
import type { Executor } from "../sandbox.js";
|
||||||
import { dirname, resolve as resolvePath } from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand ~ to home directory
|
|
||||||
*/
|
|
||||||
function expandPath(filePath: string): string {
|
|
||||||
if (filePath === "~") {
|
|
||||||
return os.homedir();
|
|
||||||
}
|
|
||||||
if (filePath.startsWith("~/")) {
|
|
||||||
return os.homedir() + filePath.slice(1);
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeSchema = Type.Object({
|
const writeSchema = Type.Object({
|
||||||
label: Type.String({ description: "Brief description of what you're writing (shown to user)" }),
|
label: Type.String({ description: "Brief description of what you're writing (shown to user)" }),
|
||||||
|
|
@ -23,78 +8,38 @@ const writeSchema = Type.Object({
|
||||||
content: Type.String({ description: "Content to write to the file" }),
|
content: Type.String({ description: "Content to write to the file" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const writeTool: AgentTool<typeof writeSchema> = {
|
export function createWriteTool(executor: Executor): AgentTool<typeof writeSchema> {
|
||||||
name: "write",
|
return {
|
||||||
label: "write",
|
name: "write",
|
||||||
description:
|
label: "write",
|
||||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
description:
|
||||||
parameters: writeSchema,
|
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||||
execute: async (
|
parameters: writeSchema,
|
||||||
_toolCallId: string,
|
execute: async (
|
||||||
{ path, content }: { label: string; path: string; content: string },
|
_toolCallId: string,
|
||||||
signal?: AbortSignal,
|
{ path, content }: { label: string; path: string; content: string },
|
||||||
) => {
|
signal?: AbortSignal,
|
||||||
const absolutePath = resolvePath(expandPath(path));
|
) => {
|
||||||
const dir = dirname(absolutePath);
|
// Create parent directories and write file using heredoc
|
||||||
|
const dir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ".";
|
||||||
|
|
||||||
return new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>((resolve, reject) => {
|
// Use printf to handle content with special characters, pipe to file
|
||||||
// Check if already aborted
|
// This avoids issues with heredoc and special characters
|
||||||
if (signal?.aborted) {
|
const cmd = `mkdir -p ${shellEscape(dir)} && printf '%s' ${shellEscape(content)} > ${shellEscape(path)}`;
|
||||||
reject(new Error("Operation aborted"));
|
|
||||||
return;
|
const result = await executor.exec(cmd, { signal });
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr || `Failed to write file: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let aborted = false;
|
return {
|
||||||
|
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||||
// Set up abort handler
|
details: undefined,
|
||||||
const onAbort = () => {
|
|
||||||
aborted = true;
|
|
||||||
reject(new Error("Operation aborted"));
|
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (signal) {
|
function shellEscape(s: string): string {
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the write operation
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// Create parent directories if needed
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
|
|
||||||
// Check if aborted before writing
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the file
|
|
||||||
await writeFile(absolutePath, content, "utf-8");
|
|
||||||
|
|
||||||
// Check if aborted after writing
|
|
||||||
if (aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up abort handler
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
|
||||||
details: undefined,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Clean up abort handler
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!aborted) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue