Initial commit: Egregore brain service
AI logic with Claude API integration, tool execution, and system prompts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
5710c44821
6 changed files with 906 additions and 0 deletions
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Secrets - BE PARANOID
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
credentials*
|
||||
secrets*
|
||||
tokens*
|
||||
*_secret*
|
||||
*_token*
|
||||
*.credentials
|
||||
|
||||
# Logs and data
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.backup
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
21
__init__.py
Normal file
21
__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""
|
||||
Egregore Brain - AI reasoning and tool execution
|
||||
|
||||
This module handles:
|
||||
- Claude API interactions
|
||||
- System prompt generation with dynamic context
|
||||
- Tool definition and execution
|
||||
- Conversation processing
|
||||
"""
|
||||
|
||||
from .tools import TOOLS, execute_tool
|
||||
from .prompts import get_system_prompt, SYSTEM_PROMPT_BASE
|
||||
from .conversation import process_conversation
|
||||
|
||||
__all__ = [
|
||||
"TOOLS",
|
||||
"execute_tool",
|
||||
"get_system_prompt",
|
||||
"SYSTEM_PROMPT_BASE",
|
||||
"process_conversation",
|
||||
]
|
||||
175
conversation.py
Normal file
175
conversation.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""
|
||||
Egregore Brain - Conversation processing with tool use loop
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
|
||||
from tools import TOOLS, execute_tool
|
||||
from prompts import get_system_prompt
|
||||
|
||||
|
||||
def extract_embedded_tool_calls(text: str) -> tuple[list, str]:
|
||||
"""
|
||||
Detect if text contains JSON-encoded tool calls and extract them.
|
||||
Returns (tool_calls, remaining_text) where tool_calls is a list of parsed
|
||||
tool_use dicts if found, or empty list if not.
|
||||
"""
|
||||
if not text or not text.strip().startswith('['):
|
||||
return [], text
|
||||
|
||||
try:
|
||||
parsed = json.loads(text.strip())
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
if all(isinstance(b, dict) and b.get('type') in ('tool_use', 'tool_result', 'text') for b in parsed):
|
||||
tool_calls = [b for b in parsed if b.get('type') == 'tool_use']
|
||||
if tool_calls:
|
||||
return tool_calls, ""
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return [], text
|
||||
|
||||
|
||||
def serialize_content_blocks(blocks) -> list:
|
||||
"""Convert Claude API blocks to JSON-serializable format"""
|
||||
result = []
|
||||
for block in blocks:
|
||||
if hasattr(block, 'type'):
|
||||
if block.type == "text":
|
||||
embedded_tools, remaining = extract_embedded_tool_calls(block.text)
|
||||
if embedded_tools:
|
||||
for tool in embedded_tools:
|
||||
result.append(tool)
|
||||
elif remaining:
|
||||
result.append({"type": "text", "content": remaining})
|
||||
elif block.type == "tool_use":
|
||||
result.append({
|
||||
"type": "tool_use",
|
||||
"id": block.id,
|
||||
"name": block.name,
|
||||
"input": block.input
|
||||
})
|
||||
elif isinstance(block, dict):
|
||||
result.append(block)
|
||||
return result
|
||||
|
||||
|
||||
def extract_text_from_blocks(blocks) -> str:
|
||||
"""Extract plain text for notifications etc"""
|
||||
texts = []
|
||||
for block in blocks:
|
||||
if hasattr(block, 'type') and block.type == "text":
|
||||
texts.append(block.text)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
texts.append(block.get("content", ""))
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
async def process_conversation(
|
||||
client: anthropic.AsyncAnthropic,
|
||||
model: str,
|
||||
history: list[dict],
|
||||
max_iterations: int = 10
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Process a conversation with tool use loop.
|
||||
|
||||
Args:
|
||||
client: Async Anthropic client
|
||||
model: Model ID to use
|
||||
history: Conversation history in Claude API format
|
||||
max_iterations: Maximum tool use iterations
|
||||
|
||||
Returns:
|
||||
List of response blocks (text, tool_use, tool_result)
|
||||
"""
|
||||
system_prompt = await get_system_prompt()
|
||||
all_response_blocks = []
|
||||
|
||||
for _ in range(max_iterations):
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
messages=history,
|
||||
tools=TOOLS
|
||||
)
|
||||
|
||||
tool_uses = []
|
||||
embedded_tool_calls = []
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
tool_uses.append(block)
|
||||
all_response_blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": block.id,
|
||||
"name": block.name,
|
||||
"input": block.input
|
||||
})
|
||||
elif block.type == "text":
|
||||
embedded, remaining = extract_embedded_tool_calls(block.text)
|
||||
if embedded:
|
||||
embedded_tool_calls.extend(embedded)
|
||||
for tool in embedded:
|
||||
all_response_blocks.append(tool)
|
||||
elif remaining:
|
||||
all_response_blocks.append({
|
||||
"type": "text",
|
||||
"content": remaining
|
||||
})
|
||||
|
||||
if not tool_uses and not embedded_tool_calls:
|
||||
break
|
||||
|
||||
# Handle embedded tool calls (model misbehavior - output JSON as text)
|
||||
if embedded_tool_calls and not tool_uses:
|
||||
embedded_results = []
|
||||
for idx, tool_call in enumerate(embedded_tool_calls):
|
||||
tool_id = tool_call.get("id", f"embedded_{idx}")
|
||||
tool_name = tool_call.get("name")
|
||||
tool_input = tool_call.get("input", {})
|
||||
|
||||
if tool_name:
|
||||
result = await execute_tool(tool_name, tool_input)
|
||||
embedded_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_id,
|
||||
"content": result
|
||||
})
|
||||
all_response_blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_id,
|
||||
"tool_name": tool_name,
|
||||
"content": result
|
||||
})
|
||||
|
||||
if embedded_results:
|
||||
history.append({"role": "assistant", "content": response.content})
|
||||
history.append({"role": "user", "content": embedded_results})
|
||||
continue
|
||||
|
||||
# Execute tools and add results to history
|
||||
tool_results = []
|
||||
for tool_use in tool_uses:
|
||||
result = await execute_tool(tool_use.name, tool_use.input)
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": result
|
||||
})
|
||||
all_response_blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"tool_name": tool_use.name,
|
||||
"content": result
|
||||
})
|
||||
|
||||
history.append({"role": "assistant", "content": response.content})
|
||||
history.append({"role": "user", "content": tool_results})
|
||||
|
||||
return all_response_blocks
|
||||
90
main.py
Normal file
90
main.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Egregore Brain Service - AI reasoning API
|
||||
|
||||
Provides HTTP API for conversation processing with Claude.
|
||||
Runs on port 8081.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from dotenv import load_dotenv
|
||||
import anthropic
|
||||
|
||||
from tools import TOOLS, execute_tool
|
||||
from prompts import get_system_prompt
|
||||
from conversation import process_conversation
|
||||
|
||||
# Load environment
|
||||
load_dotenv("/home/admin/.env")
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise ValueError("ANTHROPIC_API_KEY not set")
|
||||
|
||||
app = FastAPI(title="Egregore Brain Service", docs_url="/docs")
|
||||
|
||||
# Initialize Anthropic client
|
||||
client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||
|
||||
|
||||
# Request models
|
||||
class ProcessRequest(BaseModel):
|
||||
model: str = "claude-sonnet-4-20250514"
|
||||
history: list # Conversation history in Claude API format
|
||||
max_iterations: int = 10
|
||||
|
||||
|
||||
class ToolRequest(BaseModel):
|
||||
name: str
|
||||
input: dict
|
||||
|
||||
|
||||
# Endpoints
|
||||
@app.post("/process")
|
||||
async def api_process(req: ProcessRequest):
|
||||
"""Process a conversation with tool use loop"""
|
||||
try:
|
||||
response_blocks = await process_conversation(
|
||||
client=client,
|
||||
model=req.model,
|
||||
history=req.history,
|
||||
max_iterations=req.max_iterations
|
||||
)
|
||||
return {"blocks": response_blocks}
|
||||
except anthropic.APIError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/tool")
|
||||
async def api_execute_tool(req: ToolRequest):
|
||||
"""Execute a single tool directly"""
|
||||
result = await execute_tool(req.name, req.input)
|
||||
return {"result": result}
|
||||
|
||||
|
||||
@app.get("/tools")
|
||||
async def api_get_tools():
|
||||
"""Get available tool definitions"""
|
||||
return {"tools": TOOLS}
|
||||
|
||||
|
||||
@app.get("/prompt")
|
||||
async def api_get_prompt():
|
||||
"""Get current system prompt with context"""
|
||||
prompt = await get_system_prompt()
|
||||
return {"prompt": prompt}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "ok", "service": "brain"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8081)
|
||||
51
prompts.py
Normal file
51
prompts.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
Egregore Brain - System prompt and context injection
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import aiofiles
|
||||
|
||||
|
||||
SYSTEM_PROMPT_BASE = """You are Egregore, a personal AI assistant running on a dedicated VPS.
|
||||
|
||||
Context:
|
||||
- You're hosted at egregore.leaf.ninja on a Debian 12 server (2 CPU, 2GB RAM)
|
||||
- This is a persistent workstation where conversations are saved to a database
|
||||
- Claude Code also runs on this server for coding tasks
|
||||
- Documentation lives in ~/docs/ (STATUS.md, HISTORY.md, RUNBOOK.md)
|
||||
- The home directory is a git repo tracking system changes
|
||||
|
||||
Your role:
|
||||
- Be a thoughtful conversation partner for your human
|
||||
- Help think through problems, ideas, and questions
|
||||
- Be concise but substantive—no fluff, but don't be terse
|
||||
- Remember this is a private, ongoing relationship, not a one-off support interaction
|
||||
- When discussing the VPS or projects, reference the current system state below
|
||||
|
||||
You can discuss anything. Be genuine and direct."""
|
||||
|
||||
|
||||
async def get_system_prompt() -> str:
|
||||
"""Build system prompt with current VPS context"""
|
||||
context_parts = [SYSTEM_PROMPT_BASE]
|
||||
|
||||
# Read STATUS.md
|
||||
try:
|
||||
async with aiofiles.open("/home/admin/docs/STATUS.md") as f:
|
||||
status = await f.read()
|
||||
context_parts.append(f"\n\n## Current System Status\n```\n{status.strip()}\n```")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get recent git commits
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", "/home/admin", "log", "--oneline", "-5"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
context_parts.append(f"\n\n## Recent Changes (git log)\n```\n{result.stdout.strip()}\n```")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "\n".join(context_parts)
|
||||
526
tools.py
Normal file
526
tools.py
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
"""
|
||||
Egregore Brain - Tool definitions and execution
|
||||
|
||||
This module contains the tool definitions exposed to Claude and their execution logic.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import subprocess
|
||||
import glob as globlib
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
|
||||
|
||||
# Tool definitions for Claude
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "git",
|
||||
"description": "Interact with the VPS git repository. Use for viewing status, history, diffs, and making commits.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"enum": ["status", "log", "diff", "commit"],
|
||||
"description": "Git operation to perform"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Commit message (required for commit command)"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Files to stage (for commit). Omit to commit all staged changes."
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "docs",
|
||||
"description": "Read or update VPS documentation files (STATUS.md, HISTORY.md, RUNBOOK.md)",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["read", "append", "update"],
|
||||
"description": "Action to perform"
|
||||
},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"enum": ["STATUS.md", "HISTORY.md", "RUNBOOK.md"],
|
||||
"description": "Which doc file to operate on"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to append or new content for update"
|
||||
},
|
||||
"section": {
|
||||
"type": "string",
|
||||
"description": "Section header to update (for update action)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read",
|
||||
"description": "Read a file from the filesystem. Use this instead of bash cat/head/tail. Returns file contents with line numbers.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the file to read"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Line number to start reading from (1-indexed, optional)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to read (optional, default 500)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write",
|
||||
"description": "Write content to a file, creating it if it doesn't exist or overwriting if it does.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the file to write"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"description": "Make precise string replacements in a file. Use this instead of bash sed/awk. The old_string must match exactly.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the file to edit"
|
||||
},
|
||||
"old_string": {
|
||||
"type": "string",
|
||||
"description": "Exact string to find and replace"
|
||||
},
|
||||
"new_string": {
|
||||
"type": "string",
|
||||
"description": "String to replace it with"
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace all occurrences (default: false, replace first only)"
|
||||
}
|
||||
},
|
||||
"required": ["path", "old_string", "new_string"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "glob",
|
||||
"description": "Find files matching a glob pattern. Use this instead of bash find/ls for file discovery.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern (e.g., '**/*.py', 'docs/*.md')"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Base directory to search in (default: /home/admin)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "grep",
|
||||
"description": "Search file contents using regex patterns. Use this instead of bash grep/rg.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to search for"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File or directory to search in (default: /home/admin)"
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to filter files (e.g., '*.py')"
|
||||
},
|
||||
"context": {
|
||||
"type": "integer",
|
||||
"description": "Lines of context around matches (default: 0)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Run safe bash commands for system inspection. Limited to read-only and status commands. Prefer read/write/edit/glob/grep tools for file operations.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Bash command to execute (must be safe/read-only)"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# Allowlist for bash commands
|
||||
ALLOWED_BASH_PREFIXES = [
|
||||
"df", "free", "uptime", "ps", "systemctl status", "cat", "ls",
|
||||
"head", "tail", "du", "top -bn1", "hostname", "date", "who", "last", "htop"
|
||||
]
|
||||
|
||||
|
||||
async def execute_tool(name: str, tool_input: dict) -> str:
|
||||
"""Execute a tool and return the result"""
|
||||
executors = {
|
||||
"git": execute_git,
|
||||
"docs": execute_docs,
|
||||
"read": execute_read,
|
||||
"write": execute_write,
|
||||
"edit": execute_edit,
|
||||
"glob": execute_glob,
|
||||
"grep": execute_grep,
|
||||
"bash": execute_bash,
|
||||
}
|
||||
|
||||
executor = executors.get(name)
|
||||
if executor:
|
||||
return await executor(tool_input)
|
||||
return f"Unknown tool: {name}"
|
||||
|
||||
|
||||
async def execute_git(tool_input: dict) -> str:
|
||||
"""Execute git commands"""
|
||||
cmd = tool_input.get("command")
|
||||
try:
|
||||
if cmd == "status":
|
||||
result = subprocess.run(
|
||||
["git", "-C", "/home/admin", "status", "--short"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout.strip() or "Working tree clean"
|
||||
elif cmd == "log":
|
||||
result = subprocess.run(
|
||||
["git", "-C", "/home/admin", "log", "--oneline", "-10"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout.strip()
|
||||
elif cmd == "diff":
|
||||
result = subprocess.run(
|
||||
["git", "-C", "/home/admin", "diff", "--stat"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout.strip() or "No changes"
|
||||
elif cmd == "commit":
|
||||
message = tool_input.get("message")
|
||||
if not message:
|
||||
return "Error: commit message required"
|
||||
files = tool_input.get("files", [])
|
||||
if files:
|
||||
subprocess.run(
|
||||
["git", "-C", "/home/admin", "add"] + files,
|
||||
timeout=10
|
||||
)
|
||||
result = subprocess.run(
|
||||
["git", "-C", "/home/admin", "commit", "-m", message],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
return output.strip() if output.strip() else "Commit completed"
|
||||
return "Unknown git command"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: command timed out"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_docs(tool_input: dict) -> str:
|
||||
"""Execute docs operations"""
|
||||
action = tool_input.get("action")
|
||||
filename = tool_input.get("file")
|
||||
|
||||
if filename not in ["STATUS.md", "HISTORY.md", "RUNBOOK.md"]:
|
||||
return "Error: invalid file"
|
||||
|
||||
filepath = f"/home/admin/docs/{filename}"
|
||||
|
||||
try:
|
||||
if action == "read":
|
||||
async with aiofiles.open(filepath) as f:
|
||||
content = await f.read()
|
||||
return content[:10000]
|
||||
elif action == "append":
|
||||
content = tool_input.get("content", "")
|
||||
if not content:
|
||||
return "Error: content required for append"
|
||||
async with aiofiles.open(filepath, "a") as f:
|
||||
await f.write("\n" + content)
|
||||
return f"Appended to {filename}"
|
||||
elif action == "update":
|
||||
content = tool_input.get("content", "")
|
||||
if not content:
|
||||
return "Error: content required for update"
|
||||
async with aiofiles.open(filepath, "w") as f:
|
||||
await f.write(content)
|
||||
return f"Updated {filename}"
|
||||
return "Unknown action"
|
||||
except FileNotFoundError:
|
||||
return f"Error: {filename} not found"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_read(tool_input: dict) -> str:
|
||||
"""Read a file with optional offset and limit"""
|
||||
path = tool_input.get("path", "")
|
||||
offset = tool_input.get("offset", 1)
|
||||
limit = tool_input.get("limit", 500)
|
||||
|
||||
# Security: restrict to /home/admin
|
||||
if not path.startswith("/home/admin"):
|
||||
return "Error: can only read files under /home/admin"
|
||||
|
||||
try:
|
||||
async with aiofiles.open(path, "r") as f:
|
||||
lines = await f.readlines()
|
||||
|
||||
start = max(0, offset - 1)
|
||||
end = start + limit
|
||||
selected = lines[start:end]
|
||||
|
||||
result = []
|
||||
for i, line in enumerate(selected, start=start + 1):
|
||||
result.append(f"{i:4d}\t{line.rstrip()}")
|
||||
|
||||
output = "\n".join(result)
|
||||
if len(output) > 50000:
|
||||
output = output[:50000] + "\n... (truncated)"
|
||||
return output if output else "(empty file)"
|
||||
except FileNotFoundError:
|
||||
return f"Error: file not found: {path}"
|
||||
except IsADirectoryError:
|
||||
return f"Error: path is a directory: {path}"
|
||||
except PermissionError:
|
||||
return f"Error: permission denied: {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_write(tool_input: dict) -> str:
|
||||
"""Write content to a file"""
|
||||
path = tool_input.get("path", "")
|
||||
content = tool_input.get("content", "")
|
||||
|
||||
if not path.startswith("/home/admin"):
|
||||
return "Error: can only write files under /home/admin"
|
||||
|
||||
critical = ["/home/admin/.ssh", "/home/admin/.bashrc", "/home/admin/.profile"]
|
||||
for c in critical:
|
||||
if path.startswith(c):
|
||||
return f"Error: cannot write to protected path: {c}"
|
||||
|
||||
try:
|
||||
parent = os.path.dirname(path)
|
||||
if parent and not os.path.exists(parent):
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(path, "w") as f:
|
||||
await f.write(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except PermissionError:
|
||||
return f"Error: permission denied: {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_edit(tool_input: dict) -> str:
|
||||
"""Make string replacements in a file"""
|
||||
path = tool_input.get("path", "")
|
||||
old_string = tool_input.get("old_string", "")
|
||||
new_string = tool_input.get("new_string", "")
|
||||
replace_all = tool_input.get("replace_all", False)
|
||||
|
||||
if not path.startswith("/home/admin"):
|
||||
return "Error: can only edit files under /home/admin"
|
||||
|
||||
if not old_string:
|
||||
return "Error: old_string cannot be empty"
|
||||
|
||||
try:
|
||||
async with aiofiles.open(path, "r") as f:
|
||||
content = await f.read()
|
||||
|
||||
if old_string not in content:
|
||||
return f"Error: old_string not found in file"
|
||||
|
||||
count = content.count(old_string)
|
||||
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
replaced = count
|
||||
else:
|
||||
if count > 1:
|
||||
return f"Error: old_string appears {count} times. Use replace_all=true or provide more context to make it unique."
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
replaced = 1
|
||||
|
||||
async with aiofiles.open(path, "w") as f:
|
||||
await f.write(new_content)
|
||||
|
||||
return f"Replaced {replaced} occurrence(s) in {path}"
|
||||
except FileNotFoundError:
|
||||
return f"Error: file not found: {path}"
|
||||
except PermissionError:
|
||||
return f"Error: permission denied: {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_glob(tool_input: dict) -> str:
|
||||
"""Find files matching a glob pattern"""
|
||||
pattern = tool_input.get("pattern", "")
|
||||
base_path = tool_input.get("path", "/home/admin")
|
||||
|
||||
if not base_path.startswith("/home/admin"):
|
||||
base_path = "/home/admin"
|
||||
|
||||
try:
|
||||
full_pattern = os.path.join(base_path, pattern)
|
||||
matches = globlib.glob(full_pattern, recursive=True)
|
||||
matches.sort(key=lambda x: os.path.getmtime(x) if os.path.exists(x) else 0, reverse=True)
|
||||
|
||||
if len(matches) > 100:
|
||||
matches = matches[:100]
|
||||
return "\n".join(matches) + f"\n... (showing 100 of {len(matches)} matches)"
|
||||
|
||||
return "\n".join(matches) if matches else "No matches found"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_grep(tool_input: dict) -> str:
|
||||
"""Search file contents using regex"""
|
||||
pattern = tool_input.get("pattern", "")
|
||||
path = tool_input.get("path", "/home/admin")
|
||||
file_glob = tool_input.get("glob", None)
|
||||
context = tool_input.get("context", 0)
|
||||
|
||||
if not path.startswith("/home/admin"):
|
||||
path = "/home/admin"
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
except re.error as e:
|
||||
return f"Error: invalid regex: {e}"
|
||||
|
||||
results = []
|
||||
files_searched = 0
|
||||
max_results = 50
|
||||
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
files = [path]
|
||||
else:
|
||||
if file_glob:
|
||||
files = globlib.glob(os.path.join(path, "**", file_glob), recursive=True)
|
||||
else:
|
||||
files = globlib.glob(os.path.join(path, "**", "*"), recursive=True)
|
||||
files = [f for f in files if os.path.isfile(f)]
|
||||
|
||||
for filepath in files:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
try:
|
||||
if os.path.getsize(filepath) > 1_000_000:
|
||||
continue
|
||||
|
||||
async with aiofiles.open(filepath, "r", errors="ignore") as f:
|
||||
lines = await f.readlines()
|
||||
|
||||
files_searched += 1
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if regex.search(line):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
match_lines = []
|
||||
start = max(0, i - context)
|
||||
end = min(len(lines), i + context + 1)
|
||||
|
||||
for j in range(start, end):
|
||||
prefix = ">" if j == i else " "
|
||||
match_lines.append(f"{prefix}{j+1:4d}: {lines[j].rstrip()}")
|
||||
|
||||
results.append(f"{filepath}:\n" + "\n".join(match_lines))
|
||||
except (PermissionError, IsADirectoryError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
if not results:
|
||||
return f"No matches found (searched {files_searched} files)"
|
||||
|
||||
output = "\n\n".join(results)
|
||||
if len(results) >= max_results:
|
||||
output += f"\n\n... (showing first {max_results} matches)"
|
||||
|
||||
return output
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
async def execute_bash(tool_input: dict) -> str:
|
||||
"""Execute safe bash commands"""
|
||||
cmd = tool_input.get("command", "").strip()
|
||||
|
||||
allowed = False
|
||||
for prefix in ALLOWED_BASH_PREFIXES:
|
||||
if cmd.startswith(prefix):
|
||||
allowed = True
|
||||
break
|
||||
|
||||
if not allowed:
|
||||
return f"Error: command not allowed. Permitted prefixes: {', '.join(ALLOWED_BASH_PREFIXES)}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True,
|
||||
timeout=30, cwd="/home/admin"
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
return output[:5000].strip() if output.strip() else "Command completed (no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: command timed out (30s limit)"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue