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:
egregore 2026-02-02 11:37:48 +00:00
commit 5710c44821
6 changed files with 906 additions and 0 deletions

43
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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 substantiveno 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
View 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)}"