Compare commits
4 Commits
feature/sy
...
feature/un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6685388357 | ||
| a95da9e73d | |||
| 5e8380b858 | |||
|
|
266d6ec008 |
22
configs/llama-server.service
Normal file
22
configs/llama-server.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=llama.cpp inference server for Timmy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/timmy
|
||||
ExecStart=/root/timmy/llama-server \
|
||||
-m /root/timmy/models/hermes-3-8b.Q4_K_M.gguf \
|
||||
--host 127.0.0.1 \
|
||||
--port 8081 \
|
||||
-c 8192 \
|
||||
-np 1 \
|
||||
--jinja \
|
||||
-ngl 0
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment="HOME=/root"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
17
configs/timmy-agent.service
Normal file
17
configs/timmy-agent.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Timmy Agent Harness
|
||||
After=llama-server.service
|
||||
Requires=llama-server.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/timmy
|
||||
ExecStart=/root/timmy/venv/bin/python /root/timmy/timmy-home/agent/agent_daemon.py
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
Environment="HOME=/root"
|
||||
Environment="TIMMY_MODEL_URL=http://127.0.0.1:8081"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
16
configs/timmy-health.service
Normal file
16
configs/timmy-health.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Timmy Health Check Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/timmy
|
||||
ExecStart=/root/timmy/venv/bin/python /root/timmy/uni-wizard/daemons/health_daemon.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment="HOME=/root"
|
||||
Environment="PYTHONPATH=/root/timmy/uni-wizard"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
16
configs/timmy-task-router.service
Normal file
16
configs/timmy-task-router.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Timmy Task Router Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/timmy
|
||||
ExecStart=/root/timmy/venv/bin/python /root/timmy/uni-wizard/daemons/task_router.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment="HOME=/root"
|
||||
Environment="PYTHONPATH=/root/timmy/uni-wizard"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
260
scripts/provision-timmy-vps.sh
Normal file
260
scripts/provision-timmy-vps.sh
Normal file
@@ -0,0 +1,260 @@
|
||||
#!/bin/bash
|
||||
# Timmy VPS Provisioning Script
|
||||
# Transforms fresh Ubuntu 22.04+ VPS into sovereign local-first wizard
|
||||
|
||||
set -e
|
||||
|
||||
TIMMY_USER="${TIMMY_USER:-root}"
|
||||
TIMMY_HOME="${TIMMY_HOME:-/root}"
|
||||
TIMMY_DIR="$TIMMY_HOME/timmy"
|
||||
REPO_URL="${REPO_URL:-http://143.198.27.163:3000/Timmy_Foundation/timmy-home.git}"
|
||||
MODEL_URL="${MODEL_URL:-https://huggingface.co/TheBloke/Hermes-3-Llama-3.1-8B-GGUF/resolve/main/hermes-3-llama-3.1-8b.Q4_K_M.gguf}"
|
||||
MODEL_NAME="${MODEL_NAME:-hermes-3-8b.Q4_K_M.gguf}"
|
||||
|
||||
echo "========================================"
|
||||
echo " Timmy VPS Provisioning"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[TIMMY]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Ubuntu version
|
||||
if ! grep -q "Ubuntu 22.04\|Ubuntu 24.04" /etc/os-release; then
|
||||
warn "Not Ubuntu 22.04/24.04 - may not work correctly"
|
||||
fi
|
||||
|
||||
log "Step 1/8: Installing system dependencies..."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
build-essential \
|
||||
cmake \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
libopenblas-dev \
|
||||
pkg-config \
|
||||
ufw \
|
||||
jq \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
2>&1 | tail -5
|
||||
|
||||
log "Step 2/8: Setting up directory structure..."
|
||||
mkdir -p "$TIMMY_DIR"/{soul,scripts,logs,shared,models,configs}
|
||||
mkdir -p "$TIMMY_HOME/.config/systemd/user"
|
||||
|
||||
log "Step 3/8: Building llama.cpp from source..."
|
||||
if [ ! -f "$TIMMY_DIR/llama-server" ]; then
|
||||
cd /tmp
|
||||
git clone --depth 1 https://github.com/ggerganov/llama.cpp.git 2>/dev/null || true
|
||||
cd llama.cpp
|
||||
|
||||
# Build with OpenBLAS for CPU optimization
|
||||
cmake -B build \
|
||||
-DGGML_BLAS=ON \
|
||||
-DGGML_BLAS_VENDOR=OpenBLAS \
|
||||
-DLLAMA_BUILD_TESTS=OFF \
|
||||
-DLLAMA_BUILD_EXAMPLES=OFF \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
cmake --build build --config Release -j$(nproc)
|
||||
|
||||
# Copy binaries
|
||||
cp build/bin/llama-server "$TIMMY_DIR/"
|
||||
cp build/bin/llama-cli "$TIMMY_DIR/"
|
||||
|
||||
log "llama.cpp built successfully"
|
||||
else
|
||||
log "llama.cpp already exists, skipping build"
|
||||
fi
|
||||
|
||||
log "Step 4/8: Downloading model weights..."
|
||||
if [ ! -f "$TIMMY_DIR/models/$MODEL_NAME" ]; then
|
||||
cd "$TIMMY_DIR/models"
|
||||
wget -q --show-progress "$MODEL_URL" -O "$MODEL_NAME" || {
|
||||
error "Failed to download model. Continuing anyway..."
|
||||
}
|
||||
log "Model downloaded"
|
||||
else
|
||||
log "Model already exists, skipping download"
|
||||
fi
|
||||
|
||||
log "Step 5/8: Setting up llama-server systemd service..."
|
||||
cat > /etc/systemd/system/llama-server.service << EOF
|
||||
[Unit]
|
||||
Description=llama.cpp inference server for Timmy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$TIMMY_USER
|
||||
WorkingDirectory=$TIMMY_DIR
|
||||
ExecStart=$TIMMY_DIR/llama-server \\
|
||||
-m $TIMMY_DIR/models/$MODEL_NAME \\
|
||||
--host 127.0.0.1 \\
|
||||
--port 8081 \\
|
||||
-c 8192 \\
|
||||
-np 1 \\
|
||||
--jinja \\
|
||||
-ngl 0
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment="HOME=$TIMMY_HOME"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable llama-server.service
|
||||
|
||||
log "Step 6/8: Cloning timmy-home repo and setting up agent..."
|
||||
if [ ! -d "$TIMMY_DIR/timmy-home" ]; then
|
||||
cd "$TIMMY_DIR"
|
||||
git clone "$REPO_URL" timmy-home 2>/dev/null || warn "Could not clone repo"
|
||||
fi
|
||||
|
||||
# Create minimal Python environment for agent
|
||||
if [ ! -d "$TIMMY_DIR/venv" ]; then
|
||||
python3 -m venv "$TIMMY_DIR/venv"
|
||||
"$TIMMY_DIR/venv/bin/pip" install -q requests pyyaml 2>&1 | tail -3
|
||||
fi
|
||||
|
||||
log "Step 7/8: Setting up Timmy agent systemd service..."
|
||||
cat > /etc/systemd/system/timmy-agent.service << EOF
|
||||
[Unit]
|
||||
Description=Timmy Agent Harness
|
||||
After=llama-server.service
|
||||
Requires=llama-server.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$TIMMY_USER
|
||||
WorkingDirectory=$TIMMY_DIR
|
||||
ExecStart=$TIMMY_DIR/venv/bin/python $TIMMY_DIR/timmy-home/agent/agent_daemon.py
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
Environment="HOME=$TIMMY_HOME"
|
||||
Environment="TIMMY_MODEL_URL=http://127.0.0.1:8081"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable timmy-agent.service
|
||||
|
||||
log "Step 8/8: Configuring firewall..."
|
||||
# Reset UFW
|
||||
ufw --force reset 2>/dev/null || true
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
|
||||
# Allow SSH
|
||||
ufw allow 22/tcp
|
||||
|
||||
# Allow Syncthing (sync protocol)
|
||||
ufw allow 22000/tcp
|
||||
ufw allow 22000/udp
|
||||
|
||||
# Allow Syncthing (discovery)
|
||||
ufw allow 21027/udp
|
||||
|
||||
# Note: llama-server on 8081 is NOT exposed (localhost only)
|
||||
|
||||
ufw --force enable
|
||||
|
||||
log "Starting services..."
|
||||
systemctl start llama-server.service || warn "llama-server failed to start (may need model)"
|
||||
|
||||
# Wait for llama-server to be ready
|
||||
log "Waiting for llama-server to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://127.0.0.1:8081/health >/dev/null 2>&1; then
|
||||
log "llama-server is healthy!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Create status script
|
||||
cat > "$TIMMY_DIR/scripts/status.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
echo "=== Timmy VPS Status ==="
|
||||
echo ""
|
||||
echo "Services:"
|
||||
systemctl is-active llama-server.service && echo " llama-server: RUNNING" || echo " llama-server: STOPPED"
|
||||
systemctl is-active timmy-agent.service && echo " timmy-agent: RUNNING" || echo " timmy-agent: STOPPED"
|
||||
echo ""
|
||||
echo "Inference Health:"
|
||||
curl -s http://127.0.0.1:8081/health | jq . 2>/dev/null || echo " Not responding"
|
||||
echo ""
|
||||
echo "Disk Usage:"
|
||||
df -h $HOME | tail -1
|
||||
echo ""
|
||||
echo "Memory:"
|
||||
free -h | grep Mem
|
||||
EOF
|
||||
chmod +x "$TIMMY_DIR/scripts/status.sh"
|
||||
|
||||
# Create README
|
||||
cat > "$TIMMY_DIR/README.txt" << EOF
|
||||
Timmy Sovereign Wizard VPS
|
||||
==========================
|
||||
|
||||
Quick Commands:
|
||||
$TIMMY_DIR/scripts/status.sh - Check system status
|
||||
systemctl status llama-server - Check inference service
|
||||
systemctl status timmy-agent - Check agent service
|
||||
|
||||
Directories:
|
||||
$TIMMY_DIR/models/ - AI model weights
|
||||
$TIMMY_DIR/soul/ - SOUL.md and conscience files
|
||||
$TIMMY_DIR/logs/ - Agent logs
|
||||
$TIMMY_DIR/shared/ - Syncthing shared folder
|
||||
|
||||
Inference Endpoint:
|
||||
http://127.0.0.1:8081 (localhost only)
|
||||
|
||||
Provisioning complete!
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
log "Provisioning Complete!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Status:"
|
||||
"$TIMMY_DIR/scripts/status.sh"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Run syncthing setup: curl -sL $REPO_URL/raw/branch/main/scripts/setup-syncthing.sh | bash"
|
||||
echo " 2. Check inference: curl http://127.0.0.1:8081/health"
|
||||
echo " 3. Review logs: journalctl -u llama-server -f"
|
||||
echo ""
|
||||
127
uni-wizard/README.md
Normal file
127
uni-wizard/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Uni-Wizard Architecture
|
||||
|
||||
## Vision
|
||||
|
||||
A single wizard harness that elegantly routes all API interactions through one unified interface. No more fragmented wizards - one consciousness, infinite capabilities.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ UNI-WIZARD HARNESS │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ System │ │ Git │ │ Network │ │
|
||||
│ │ Tools │◄──►│ Tools │◄──►│ Tools │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┼──────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ Tool Router │ │
|
||||
│ │ (Registry) │ │
|
||||
│ └───────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┼──────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Local │ │ Gitea │ │ Relay │ │
|
||||
│ │ llama.cpp │ │ API │ │ Nostr │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ LLM (local) │
|
||||
│ Hermes-3 8B │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Single Entry Point**: One harness, all capabilities
|
||||
2. **Unified Registry**: All tools registered centrally
|
||||
3. **Elegant Routing**: Tools discover and route automatically
|
||||
4. **Local-First**: No cloud dependencies
|
||||
5. **Self-Healing**: Tools can restart, reconnect, recover
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### System Layer
|
||||
- `system_info` — OS, CPU, RAM, disk, uptime
|
||||
- `process_manager` — list, start, stop processes
|
||||
- `service_controller` — systemd service management
|
||||
- `health_monitor` — system health checks
|
||||
|
||||
### Git Layer
|
||||
- `git_operations` — status, log, commit, push, pull
|
||||
- `repo_manager` — clone, branch, merge
|
||||
- `pr_handler` — create, review, merge PRs
|
||||
|
||||
### Network Layer
|
||||
- `http_client` — GET, POST, PUT, DELETE
|
||||
- `gitea_client` — full Gitea API wrapper
|
||||
- `nostr_client` — relay communication
|
||||
- `api_router` — generic API endpoint handler
|
||||
|
||||
### File Layer
|
||||
- `file_operations` — read, write, append, search
|
||||
- `directory_manager` — tree, list, navigate
|
||||
- `archive_handler` — zip, tar, compress
|
||||
|
||||
## Registry System
|
||||
|
||||
```python
|
||||
# tools/registry.py
|
||||
class ToolRegistry:
|
||||
def __init__(self):
|
||||
self.tools = {}
|
||||
|
||||
def register(self, name, handler, schema):
|
||||
self.tools[name] = {
|
||||
'handler': handler,
|
||||
'schema': schema,
|
||||
'description': handler.__doc__
|
||||
}
|
||||
|
||||
def execute(self, name, params):
|
||||
tool = self.tools.get(name)
|
||||
if not tool:
|
||||
return f"Error: Tool '{name}' not found"
|
||||
try:
|
||||
return tool['handler'](**params)
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {str(e)}"
|
||||
```
|
||||
|
||||
## API Flow
|
||||
|
||||
1. **User Request** → Natural language task
|
||||
2. **LLM Planning** → Breaks into tool calls
|
||||
3. **Registry Lookup** → Finds appropriate tools
|
||||
4. **Execution** → Tools run in sequence/parallel
|
||||
5. **Response** → Results synthesized and returned
|
||||
|
||||
## Example Usage
|
||||
|
||||
```python
|
||||
# Single harness, multiple capabilities
|
||||
result = harness.execute("""
|
||||
Check system health, pull latest git changes,
|
||||
and create a Gitea issue if tests fail
|
||||
""")
|
||||
```
|
||||
|
||||
This becomes:
|
||||
1. `system_info` → check health
|
||||
2. `git_pull` → update repo
|
||||
3. `run_tests` → execute tests
|
||||
4. `gitea_create_issue` → report failures
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Simplicity**: One harness to maintain
|
||||
- **Power**: All capabilities unified
|
||||
- **Elegance**: Clean routing, no fragmentation
|
||||
- **Resilience**: Self-contained, local-first
|
||||
9
uni-wizard/daemons/__init__.py
Normal file
9
uni-wizard/daemons/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Uni-Wizard Daemons Package
|
||||
Background services for the uni-wizard architecture
|
||||
"""
|
||||
|
||||
from .health_daemon import HealthDaemon
|
||||
from .task_router import TaskRouter
|
||||
|
||||
__all__ = ['HealthDaemon', 'TaskRouter']
|
||||
180
uni-wizard/daemons/health_daemon.py
Normal file
180
uni-wizard/daemons/health_daemon.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Health Check Daemon for Uni-Wizard
|
||||
Monitors VPS status and exposes health endpoint
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from harness import get_harness
|
||||
|
||||
|
||||
class HealthCheckHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP handler for health endpoint"""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Suppress default logging
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests"""
|
||||
if self.path == '/health':
|
||||
self.send_health_response()
|
||||
elif self.path == '/status':
|
||||
self.send_full_status()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def send_health_response(self):
|
||||
"""Send simple health check"""
|
||||
harness = get_harness()
|
||||
result = harness.execute("health_check")
|
||||
|
||||
try:
|
||||
health_data = json.loads(result)
|
||||
status_code = 200 if health_data.get("overall") == "healthy" else 503
|
||||
except:
|
||||
status_code = 503
|
||||
health_data = {"error": "Health check failed"}
|
||||
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(health_data).encode())
|
||||
|
||||
def send_full_status(self):
|
||||
"""Send full system status"""
|
||||
harness = get_harness()
|
||||
|
||||
status = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"harness": json.loads(harness.get_status()),
|
||||
"system": json.loads(harness.execute("system_info")),
|
||||
"health": json.loads(harness.execute("health_check"))
|
||||
}
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(status, indent=2).encode())
|
||||
|
||||
|
||||
class HealthDaemon:
|
||||
"""
|
||||
Health monitoring daemon.
|
||||
|
||||
Runs continuously, monitoring:
|
||||
- System resources
|
||||
- Service status
|
||||
- Inference endpoint
|
||||
|
||||
Exposes:
|
||||
- HTTP endpoint on port 8082
|
||||
- JSON status file at ~/timmy/logs/health.json
|
||||
"""
|
||||
|
||||
def __init__(self, port: int = 8082, check_interval: int = 60):
|
||||
self.port = port
|
||||
self.check_interval = check_interval
|
||||
self.running = False
|
||||
self.server = None
|
||||
self.monitor_thread = None
|
||||
self.last_health = None
|
||||
|
||||
# Ensure log directory exists
|
||||
self.log_path = Path.home() / "timmy" / "logs"
|
||||
self.log_path.mkdir(parents=True, exist_ok=True)
|
||||
self.health_file = self.log_path / "health.json"
|
||||
|
||||
def start(self):
|
||||
"""Start the health daemon"""
|
||||
self.running = True
|
||||
|
||||
# Start HTTP server
|
||||
self.server = HTTPServer(('127.0.0.1', self.port), HealthCheckHandler)
|
||||
server_thread = threading.Thread(target=self.server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
# Start monitoring loop
|
||||
self.monitor_thread = threading.Thread(target=self._monitor_loop)
|
||||
self.monitor_thread.daemon = True
|
||||
self.monitor_thread.start()
|
||||
|
||||
print(f"Health daemon started on http://127.0.0.1:{self.port}")
|
||||
print(f" - /health - Quick health check")
|
||||
print(f" - /status - Full system status")
|
||||
print(f"Health file: {self.health_file}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the health daemon"""
|
||||
self.running = False
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
print("Health daemon stopped")
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Background monitoring loop"""
|
||||
while self.running:
|
||||
try:
|
||||
self._update_health_file()
|
||||
time.sleep(self.check_interval)
|
||||
except Exception as e:
|
||||
print(f"Monitor error: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _update_health_file(self):
|
||||
"""Update the health status file"""
|
||||
harness = get_harness()
|
||||
|
||||
try:
|
||||
health_result = harness.execute("health_check")
|
||||
system_result = harness.execute("system_info")
|
||||
|
||||
status = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"health": json.loads(health_result),
|
||||
"system": json.loads(system_result)
|
||||
}
|
||||
|
||||
self.health_file.write_text(json.dumps(status, indent=2))
|
||||
self.last_health = status
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to update health file: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the health daemon"""
|
||||
import signal
|
||||
|
||||
daemon = HealthDaemon()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("\nShutting down...")
|
||||
daemon.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
daemon.start()
|
||||
|
||||
# Keep main thread alive
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
daemon.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
222
uni-wizard/daemons/task_router.py
Normal file
222
uni-wizard/daemons/task_router.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Task Router for Uni-Wizard
|
||||
Polls Gitea for assigned issues and executes them
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from harness import get_harness
|
||||
|
||||
|
||||
class TaskRouter:
|
||||
"""
|
||||
Gitea Task Router.
|
||||
|
||||
Polls Gitea for issues assigned to Timmy and routes them
|
||||
to appropriate tools for execution.
|
||||
|
||||
Flow:
|
||||
1. Poll Gitea API for open issues assigned to Timmy
|
||||
2. Parse issue body for commands/tasks
|
||||
3. Route to appropriate tool via harness
|
||||
4. Post results back as comments
|
||||
5. Close issue if task complete
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gitea_url: str = "http://143.198.27.163:3000",
|
||||
repo: str = "Timmy_Foundation/timmy-home",
|
||||
assignee: str = "timmy",
|
||||
poll_interval: int = 60
|
||||
):
|
||||
self.gitea_url = gitea_url
|
||||
self.repo = repo
|
||||
self.assignee = assignee
|
||||
self.poll_interval = poll_interval
|
||||
self.running = False
|
||||
self.harness = get_harness()
|
||||
self.processed_issues = set()
|
||||
|
||||
# Log file
|
||||
self.log_path = Path.home() / "timmy" / "logs"
|
||||
self.log_path.mkdir(parents=True, exist_ok=True)
|
||||
self.router_log = self.log_path / "task_router.jsonl"
|
||||
|
||||
def start(self):
|
||||
"""Start the task router"""
|
||||
self.running = True
|
||||
print(f"Task router started")
|
||||
print(f" Polling: {self.gitea_url}")
|
||||
print(f" Assignee: {self.assignee}")
|
||||
print(f" Interval: {self.poll_interval}s")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self._poll_and_route()
|
||||
time.sleep(self.poll_interval)
|
||||
except Exception as e:
|
||||
self._log_event("error", {"message": str(e)})
|
||||
time.sleep(5)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the task router"""
|
||||
self.running = False
|
||||
print("Task router stopped")
|
||||
|
||||
def _poll_and_route(self):
|
||||
"""Poll for issues and route tasks"""
|
||||
# Get assigned issues
|
||||
result = self.harness.execute(
|
||||
"gitea_list_issues",
|
||||
repo=self.repo,
|
||||
state="open",
|
||||
assignee=self.assignee
|
||||
)
|
||||
|
||||
try:
|
||||
issues = json.loads(result)
|
||||
except:
|
||||
return
|
||||
|
||||
for issue in issues.get("issues", []):
|
||||
issue_num = issue["number"]
|
||||
|
||||
# Skip already processed
|
||||
if issue_num in self.processed_issues:
|
||||
continue
|
||||
|
||||
# Process the issue
|
||||
self._process_issue(issue)
|
||||
self.processed_issues.add(issue_num)
|
||||
|
||||
def _process_issue(self, issue: dict):
|
||||
"""Process a single issue"""
|
||||
issue_num = issue["number"]
|
||||
title = issue["title"]
|
||||
|
||||
self._log_event("issue_received", {
|
||||
"number": issue_num,
|
||||
"title": title
|
||||
})
|
||||
|
||||
# Parse title for command hints
|
||||
# Format: "[ACTION] Description" or just "Description"
|
||||
action = self._parse_action(title)
|
||||
|
||||
# Route to appropriate handler
|
||||
if action == "system_check":
|
||||
result = self._handle_system_check(issue_num)
|
||||
elif action == "git_operation":
|
||||
result = self._handle_git_operation(issue_num, issue)
|
||||
elif action == "health_report":
|
||||
result = self._handle_health_report(issue_num)
|
||||
else:
|
||||
result = self._handle_generic(issue_num, issue)
|
||||
|
||||
# Post result as comment
|
||||
self._post_comment(issue_num, result)
|
||||
|
||||
self._log_event("issue_processed", {
|
||||
"number": issue_num,
|
||||
"action": action,
|
||||
"result": "success" if result else "failed"
|
||||
})
|
||||
|
||||
def _parse_action(self, title: str) -> str:
|
||||
"""Parse action from issue title"""
|
||||
title_lower = title.lower()
|
||||
|
||||
if any(kw in title_lower for kw in ["health", "status", "check"]):
|
||||
return "health_report"
|
||||
elif any(kw in title_lower for kw in ["system", "resource", "disk", "memory"]):
|
||||
return "system_check"
|
||||
elif any(kw in title_lower for kw in ["git", "commit", "push", "pull", "branch"]):
|
||||
return "git_operation"
|
||||
|
||||
return "generic"
|
||||
|
||||
def _handle_system_check(self, issue_num: int) -> str:
|
||||
"""Handle system check task"""
|
||||
result = self.harness.execute("system_info")
|
||||
return f"## System Check Results\n\n```json\n{result}\n```"
|
||||
|
||||
def _handle_health_report(self, issue_num: int) -> str:
|
||||
"""Handle health report task"""
|
||||
result = self.harness.execute("health_check")
|
||||
return f"## Health Report\n\n```json\n{result}\n```"
|
||||
|
||||
def _handle_git_operation(self, issue_num: int, issue: dict) -> str:
|
||||
"""Handle git operation task"""
|
||||
body = issue.get("body", "")
|
||||
|
||||
# Parse body for git commands
|
||||
results = []
|
||||
|
||||
# Check for status request
|
||||
if "status" in body.lower():
|
||||
result = self.harness.execute("git_status", repo_path="/root/timmy/timmy-home")
|
||||
results.append(f"**Git Status:**\n```json\n{result}\n```")
|
||||
|
||||
# Check for pull request
|
||||
if "pull" in body.lower():
|
||||
result = self.harness.execute("git_pull", repo_path="/root/timmy/timmy-home")
|
||||
results.append(f"**Git Pull:**\n{result}")
|
||||
|
||||
if not results:
|
||||
results.append("No specific git operation detected in issue body.")
|
||||
|
||||
return "\n\n".join(results)
|
||||
|
||||
def _handle_generic(self, issue_num: int, issue: dict) -> str:
|
||||
"""Handle generic task"""
|
||||
return f"Received issue #{issue_num}: {issue['title']}\n\nI'll process this and update shortly."
|
||||
|
||||
def _post_comment(self, issue_num: int, body: str):
|
||||
"""Post a comment on the issue"""
|
||||
result = self.harness.execute(
|
||||
"gitea_comment",
|
||||
repo=self.repo,
|
||||
issue_number=issue_num,
|
||||
body=body
|
||||
)
|
||||
return result
|
||||
|
||||
def _log_event(self, event_type: str, data: dict):
|
||||
"""Log an event to the JSONL file"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"event": event_type,
|
||||
**data
|
||||
}
|
||||
|
||||
with open(self.router_log, "a") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the task router"""
|
||||
import signal
|
||||
|
||||
router = TaskRouter()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("\nShutting down...")
|
||||
router.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
router.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
174
uni-wizard/harness.py
Normal file
174
uni-wizard/harness.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Uni-Wizard Harness
|
||||
Single entry point for all capabilities
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
|
||||
# Add tools to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from tools import registry, call_tool
|
||||
|
||||
|
||||
class UniWizardHarness:
|
||||
"""
|
||||
The Uni-Wizard Harness - one consciousness, infinite capabilities.
|
||||
|
||||
All API flows route through this single harness:
|
||||
- System monitoring and control
|
||||
- Git operations
|
||||
- Network requests
|
||||
- Gitea API
|
||||
- Local inference
|
||||
|
||||
Usage:
|
||||
harness = UniWizardHarness()
|
||||
result = harness.execute("system_info")
|
||||
result = harness.execute("git_status", repo_path="/path/to/repo")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.registry = registry
|
||||
self.history = []
|
||||
|
||||
def list_capabilities(self) -> str:
|
||||
"""List all available tools/capabilities"""
|
||||
tools = []
|
||||
for category in self.registry.get_categories():
|
||||
cat_tools = self.registry.get_tools_by_category(category)
|
||||
tools.append(f"\n{category.upper()}:")
|
||||
for tool in cat_tools:
|
||||
tools.append(f" - {tool['name']}: {tool['description']}")
|
||||
|
||||
return "\n".join(tools)
|
||||
|
||||
def execute(self, tool_name: str, **params) -> str:
|
||||
"""
|
||||
Execute a tool by name.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute
|
||||
**params: Parameters for the tool
|
||||
|
||||
Returns:
|
||||
String result from the tool
|
||||
"""
|
||||
# Log execution
|
||||
self.history.append({
|
||||
"tool": tool_name,
|
||||
"params": params
|
||||
})
|
||||
|
||||
# Execute via registry
|
||||
result = call_tool(tool_name, **params)
|
||||
return result
|
||||
|
||||
def execute_plan(self, plan: list) -> Dict[str, str]:
|
||||
"""
|
||||
Execute a sequence of tool calls.
|
||||
|
||||
Args:
|
||||
plan: List of dicts with 'tool' and 'params'
|
||||
e.g., [{"tool": "system_info", "params": {}}]
|
||||
|
||||
Returns:
|
||||
Dict mapping tool names to results
|
||||
"""
|
||||
results = {}
|
||||
for step in plan:
|
||||
tool_name = step.get("tool")
|
||||
params = step.get("params", {})
|
||||
|
||||
result = self.execute(tool_name, **params)
|
||||
results[tool_name] = result
|
||||
|
||||
return results
|
||||
|
||||
def get_tool_definitions(self) -> str:
|
||||
"""Get tool definitions formatted for LLM system prompt"""
|
||||
return self.registry.get_tool_definitions()
|
||||
|
||||
def get_status(self) -> str:
|
||||
"""Get harness status"""
|
||||
return json.dumps({
|
||||
"total_tools": len(self.registry.list_tools()),
|
||||
"categories": self.registry.get_categories(),
|
||||
"tools_by_category": {
|
||||
cat: self.registry.list_tools(cat)
|
||||
for cat in self.registry.get_categories()
|
||||
},
|
||||
"execution_history_count": len(self.history)
|
||||
}, indent=2)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_harness = None
|
||||
|
||||
def get_harness() -> UniWizardHarness:
|
||||
"""Get the singleton harness instance"""
|
||||
global _harness
|
||||
if _harness is None:
|
||||
_harness = UniWizardHarness()
|
||||
return _harness
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI interface for the harness"""
|
||||
harness = get_harness()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Uni-Wizard Harness")
|
||||
print("==================")
|
||||
print("\nUsage: python harness.py <command> [args]")
|
||||
print("\nCommands:")
|
||||
print(" list - List all capabilities")
|
||||
print(" status - Show harness status")
|
||||
print(" tools - Show tool definitions (for LLM)")
|
||||
print(" exec <tool> - Execute a tool")
|
||||
print("\nExamples:")
|
||||
print(' python harness.py exec system_info')
|
||||
print(' python harness.py exec git_status repo_path=/tmp/timmy-home')
|
||||
return
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "list":
|
||||
print(harness.list_capabilities())
|
||||
|
||||
elif command == "status":
|
||||
print(harness.get_status())
|
||||
|
||||
elif command == "tools":
|
||||
print(harness.get_tool_definitions())
|
||||
|
||||
elif command == "exec" and len(sys.argv) >= 3:
|
||||
tool_name = sys.argv[2]
|
||||
|
||||
# Parse params from args (key=value format)
|
||||
params = {}
|
||||
for arg in sys.argv[3:]:
|
||||
if '=' in arg:
|
||||
key, value = arg.split('=', 1)
|
||||
# Try to parse as int/bool
|
||||
if value.isdigit():
|
||||
value = int(value)
|
||||
elif value.lower() == 'true':
|
||||
value = True
|
||||
elif value.lower() == 'false':
|
||||
value = False
|
||||
params[key] = value
|
||||
|
||||
result = harness.execute(tool_name, **params)
|
||||
print(result)
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Run without arguments for help")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
uni-wizard/test_harness.py
Normal file
114
uni-wizard/test_harness.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Uni-Wizard Harness
|
||||
Exercises all tool categories
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from harness import get_harness
|
||||
|
||||
|
||||
def test_system_tools():
|
||||
"""Test system monitoring tools"""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING SYSTEM TOOLS")
|
||||
print("="*60)
|
||||
|
||||
harness = get_harness()
|
||||
|
||||
tests = [
|
||||
("system_info", {}),
|
||||
("health_check", {}),
|
||||
("process_list", {"filter_name": "python"}),
|
||||
("disk_usage", {}),
|
||||
]
|
||||
|
||||
for tool_name, params in tests:
|
||||
print(f"\n>>> {tool_name}()")
|
||||
result = harness.execute(tool_name, **params)
|
||||
print(result[:500] + "..." if len(result) > 500 else result)
|
||||
|
||||
|
||||
def test_git_tools():
|
||||
"""Test git operations"""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING GIT TOOLS")
|
||||
print("="*60)
|
||||
|
||||
harness = get_harness()
|
||||
|
||||
# Test with timmy-home repo if it exists
|
||||
repo_path = "/tmp/timmy-home"
|
||||
|
||||
tests = [
|
||||
("git_status", {"repo_path": repo_path}),
|
||||
("git_log", {"repo_path": repo_path, "count": 5}),
|
||||
("git_branch_list", {"repo_path": repo_path}),
|
||||
]
|
||||
|
||||
for tool_name, params in tests:
|
||||
print(f"\n>>> {tool_name}()")
|
||||
result = harness.execute(tool_name, **params)
|
||||
print(result[:500] + "..." if len(result) > 500 else result)
|
||||
|
||||
|
||||
def test_network_tools():
|
||||
"""Test network operations"""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING NETWORK TOOLS")
|
||||
print("="*60)
|
||||
|
||||
harness = get_harness()
|
||||
|
||||
tests = [
|
||||
("http_get", {"url": "http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/timmy-home"}),
|
||||
("gitea_list_issues", {"state": "open"}),
|
||||
]
|
||||
|
||||
for tool_name, params in tests:
|
||||
print(f"\n>>> {tool_name}()")
|
||||
result = harness.execute(tool_name, **params)
|
||||
print(result[:500] + "..." if len(result) > 500 else result)
|
||||
|
||||
|
||||
def test_harness_features():
|
||||
"""Test harness management features"""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING HARNESS FEATURES")
|
||||
print("="*60)
|
||||
|
||||
harness = get_harness()
|
||||
|
||||
print("\n>>> list_capabilities()")
|
||||
print(harness.list_capabilities())
|
||||
|
||||
print("\n>>> get_status()")
|
||||
print(harness.get_status())
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run complete test suite"""
|
||||
print("UNI-WIZARD HARNESS TEST SUITE")
|
||||
print("=============================")
|
||||
|
||||
try:
|
||||
test_system_tools()
|
||||
test_git_tools()
|
||||
test_network_tools()
|
||||
test_harness_features()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✓ ALL TESTS COMPLETED")
|
||||
print("="*60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ TEST FAILED: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_all_tests()
|
||||
24
uni-wizard/tools/__init__.py
Normal file
24
uni-wizard/tools/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Uni-Wizard Tools Package
|
||||
All tools for self-sufficient operation
|
||||
"""
|
||||
|
||||
from .registry import registry, ToolRegistry, ToolResult, tool, call_tool
|
||||
|
||||
# Import all tool modules to register them
|
||||
from . import system_tools
|
||||
from . import git_tools
|
||||
from . import network_tools
|
||||
|
||||
__all__ = [
|
||||
'registry',
|
||||
'ToolRegistry',
|
||||
'ToolResult',
|
||||
'tool',
|
||||
'call_tool'
|
||||
]
|
||||
|
||||
# Ensure all tools are registered
|
||||
system_tools.register_all()
|
||||
git_tools.register_all()
|
||||
network_tools.register_all()
|
||||
448
uni-wizard/tools/git_tools.py
Normal file
448
uni-wizard/tools/git_tools.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Git Tools for Uni-Wizard
|
||||
Repository operations and version control
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from .registry import registry
|
||||
|
||||
|
||||
def run_git_command(args: List[str], cwd: str = None) -> tuple:
|
||||
"""Execute a git command and return (stdout, stderr, returncode)"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git'] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
except Exception as e:
|
||||
return "", str(e), 1
|
||||
|
||||
|
||||
def git_status(repo_path: str = ".") -> str:
|
||||
"""
|
||||
Get git repository status.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository (default: current directory)
|
||||
|
||||
Returns:
|
||||
Status info including branch, changed files, last commit
|
||||
"""
|
||||
try:
|
||||
status = {"repo_path": os.path.abspath(repo_path)}
|
||||
|
||||
# Current branch
|
||||
stdout, _, rc = run_git_command(['branch', '--show-current'], cwd=repo_path)
|
||||
if rc == 0:
|
||||
status["branch"] = stdout.strip()
|
||||
else:
|
||||
return f"Error: Not a git repository at {repo_path}"
|
||||
|
||||
# Last commit
|
||||
stdout, _, rc = run_git_command(['log', '-1', '--format=%H|%s|%an|%ad', '--date=short'], cwd=repo_path)
|
||||
if rc == 0:
|
||||
parts = stdout.strip().split('|')
|
||||
if len(parts) >= 4:
|
||||
status["last_commit"] = {
|
||||
"hash": parts[0][:8],
|
||||
"message": parts[1],
|
||||
"author": parts[2],
|
||||
"date": parts[3]
|
||||
}
|
||||
|
||||
# Changed files
|
||||
stdout, _, rc = run_git_command(['status', '--porcelain'], cwd=repo_path)
|
||||
if rc == 0:
|
||||
changes = []
|
||||
for line in stdout.strip().split('\n'):
|
||||
if line:
|
||||
status_code = line[:2]
|
||||
file_path = line[3:]
|
||||
changes.append({
|
||||
"file": file_path,
|
||||
"status": status_code.strip()
|
||||
})
|
||||
status["changes"] = changes
|
||||
status["has_changes"] = len(changes) > 0
|
||||
|
||||
# Remote info
|
||||
stdout, _, rc = run_git_command(['remote', '-v'], cwd=repo_path)
|
||||
if rc == 0:
|
||||
remotes = []
|
||||
for line in stdout.strip().split('\n'):
|
||||
if line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
remotes.append({"name": parts[0], "url": parts[1]})
|
||||
status["remotes"] = remotes
|
||||
|
||||
return json.dumps(status, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting git status: {str(e)}"
|
||||
|
||||
|
||||
def git_log(repo_path: str = ".", count: int = 10) -> str:
|
||||
"""
|
||||
Get recent commit history.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository
|
||||
count: Number of commits to show (default: 10)
|
||||
|
||||
Returns:
|
||||
List of recent commits
|
||||
"""
|
||||
try:
|
||||
stdout, stderr, rc = run_git_command(
|
||||
['log', f'-{count}', '--format=%H|%s|%an|%ad', '--date=short'],
|
||||
cwd=repo_path
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
return f"Error: {stderr}"
|
||||
|
||||
commits = []
|
||||
for line in stdout.strip().split('\n'):
|
||||
if line:
|
||||
parts = line.split('|')
|
||||
if len(parts) >= 4:
|
||||
commits.append({
|
||||
"hash": parts[0][:8],
|
||||
"message": parts[1],
|
||||
"author": parts[2],
|
||||
"date": parts[3]
|
||||
})
|
||||
|
||||
return json.dumps({"count": len(commits), "commits": commits}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting git log: {str(e)}"
|
||||
|
||||
|
||||
def git_pull(repo_path: str = ".") -> str:
|
||||
"""
|
||||
Pull latest changes from remote.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository
|
||||
|
||||
Returns:
|
||||
Pull result
|
||||
"""
|
||||
try:
|
||||
stdout, stderr, rc = run_git_command(['pull'], cwd=repo_path)
|
||||
|
||||
if rc == 0:
|
||||
if 'Already up to date' in stdout:
|
||||
return "✓ Already up to date"
|
||||
return f"✓ Pull successful:\n{stdout}"
|
||||
else:
|
||||
return f"✗ Pull failed:\n{stderr}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error pulling: {str(e)}"
|
||||
|
||||
|
||||
def git_commit(repo_path: str = ".", message: str = None, files: List[str] = None) -> str:
|
||||
"""
|
||||
Stage and commit changes.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository
|
||||
message: Commit message (required)
|
||||
files: Specific files to commit (default: all changes)
|
||||
|
||||
Returns:
|
||||
Commit result
|
||||
"""
|
||||
if not message:
|
||||
return "Error: commit message is required"
|
||||
|
||||
try:
|
||||
# Stage files
|
||||
if files:
|
||||
for f in files:
|
||||
_, stderr, rc = run_git_command(['add', f], cwd=repo_path)
|
||||
if rc != 0:
|
||||
return f"✗ Failed to stage {f}: {stderr}"
|
||||
else:
|
||||
_, stderr, rc = run_git_command(['add', '.'], cwd=repo_path)
|
||||
if rc != 0:
|
||||
return f"✗ Failed to stage changes: {stderr}"
|
||||
|
||||
# Commit
|
||||
stdout, stderr, rc = run_git_command(['commit', '-m', message], cwd=repo_path)
|
||||
|
||||
if rc == 0:
|
||||
return f"✓ Commit successful:\n{stdout}"
|
||||
else:
|
||||
if 'nothing to commit' in stderr.lower():
|
||||
return "✓ Nothing to commit (working tree clean)"
|
||||
return f"✗ Commit failed:\n{stderr}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error committing: {str(e)}"
|
||||
|
||||
|
||||
def git_push(repo_path: str = ".", remote: str = "origin", branch: str = None) -> str:
|
||||
"""
|
||||
Push to remote repository.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository
|
||||
remote: Remote name (default: origin)
|
||||
branch: Branch to push (default: current branch)
|
||||
|
||||
Returns:
|
||||
Push result
|
||||
"""
|
||||
try:
|
||||
if not branch:
|
||||
# Get current branch
|
||||
stdout, _, rc = run_git_command(['branch', '--show-current'], cwd=repo_path)
|
||||
if rc == 0:
|
||||
branch = stdout.strip()
|
||||
else:
|
||||
return "Error: Could not determine current branch"
|
||||
|
||||
stdout, stderr, rc = run_git_command(['push', remote, branch], cwd=repo_path)
|
||||
|
||||
if rc == 0:
|
||||
return f"✓ Push successful to {remote}/{branch}"
|
||||
else:
|
||||
return f"✗ Push failed:\n{stderr}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error pushing: {str(e)}"
|
||||
|
||||
|
||||
def git_checkout(repo_path: str = ".", branch: str = None, create: bool = False) -> str:
|
||||
"""
|
||||
Checkout a branch.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository
|
||||
branch: Branch name to checkout
|
||||
create: Create the branch if it doesn't exist
|
||||
|
||||
Returns:
|
||||
Checkout result
|
||||
"""
|
||||
if not branch:
|
||||
return "Error: branch name is required"
|
||||
|
||||
try:
|
||||
if create:
|
||||
stdout, stderr, rc = run_git_command(['checkout', '-b', branch], cwd=repo_path)
|
||||
else:
|
||||
stdout, stderr, rc = run_git_command(['checkout', branch], cwd=repo_path)
|
||||
|
||||
if rc == 0:
|
||||
return f"✓ Checked out branch: {branch}"
|
||||
else:
|
||||
return f"✗ Checkout failed:\n{stderr}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error checking out: {str(e)}"
|
||||
|
||||
|
||||
def git_branch_list(repo_path: str = ".") -> str:
|
||||
"""
|
||||
List all branches.
|
||||
|
||||
Args:
|
||||
repo_path: Path to git repository
|
||||
|
||||
Returns:
|
||||
List of branches with current marked
|
||||
"""
|
||||
try:
|
||||
stdout, stderr, rc = run_git_command(['branch', '-a'], cwd=repo_path)
|
||||
|
||||
if rc != 0:
|
||||
return f"Error: {stderr}"
|
||||
|
||||
branches = []
|
||||
for line in stdout.strip().split('\n'):
|
||||
if line:
|
||||
branch = line.strip()
|
||||
is_current = branch.startswith('*')
|
||||
if is_current:
|
||||
branch = branch[1:].strip()
|
||||
branches.append({
|
||||
"name": branch,
|
||||
"current": is_current
|
||||
})
|
||||
|
||||
return json.dumps({"branches": branches}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error listing branches: {str(e)}"
|
||||
|
||||
|
||||
# Register all git tools
|
||||
def register_all():
|
||||
registry.register(
|
||||
name="git_status",
|
||||
handler=git_status,
|
||||
description="Get git repository status (branch, changes, last commit)",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
}
|
||||
}
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="git_log",
|
||||
handler=git_log,
|
||||
description="Get recent commit history",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of commits to show",
|
||||
"default": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="git_pull",
|
||||
handler=git_pull,
|
||||
description="Pull latest changes from remote",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
}
|
||||
}
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="git_commit",
|
||||
handler=git_commit,
|
||||
description="Stage and commit changes",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Commit message (required)"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"description": "Specific files to commit (default: all changes)",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="git_push",
|
||||
handler=git_push,
|
||||
description="Push to remote repository",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
},
|
||||
"remote": {
|
||||
"type": "string",
|
||||
"description": "Remote name",
|
||||
"default": "origin"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch to push (default: current)"
|
||||
}
|
||||
}
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="git_checkout",
|
||||
handler=git_checkout,
|
||||
description="Checkout a branch",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch name to checkout"
|
||||
},
|
||||
"create": {
|
||||
"type": "boolean",
|
||||
"description": "Create branch if it doesn't exist",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["branch"]
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="git_branch_list",
|
||||
handler=git_branch_list,
|
||||
description="List all branches",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_path": {
|
||||
"type": "string",
|
||||
"description": "Path to git repository",
|
||||
"default": "."
|
||||
}
|
||||
}
|
||||
},
|
||||
category="git"
|
||||
)
|
||||
|
||||
|
||||
register_all()
|
||||
459
uni-wizard/tools/network_tools.py
Normal file
459
uni-wizard/tools/network_tools.py
Normal file
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
Network Tools for Uni-Wizard
|
||||
HTTP client and Gitea API integration
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Optional, Any
|
||||
from base64 import b64encode
|
||||
|
||||
from .registry import registry
|
||||
|
||||
|
||||
class HTTPClient:
|
||||
"""Simple HTTP client for API calls"""
|
||||
|
||||
def __init__(self, base_url: str = None, auth: tuple = None):
|
||||
self.base_url = base_url
|
||||
self.auth = auth
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
data: Dict = None,
|
||||
headers: Dict = None
|
||||
) -> tuple:
|
||||
"""Make HTTP request and return (body, status_code, error)"""
|
||||
try:
|
||||
# Build full URL
|
||||
full_url = url
|
||||
if self.base_url and not url.startswith('http'):
|
||||
full_url = f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
# Prepare data
|
||||
body = None
|
||||
if data:
|
||||
body = json.dumps(data).encode('utf-8')
|
||||
|
||||
# Build request
|
||||
req = urllib.request.Request(
|
||||
full_url,
|
||||
data=body,
|
||||
method=method
|
||||
)
|
||||
|
||||
# Add headers
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
if headers:
|
||||
for key, value in headers.items():
|
||||
req.add_header(key, value)
|
||||
|
||||
# Add auth
|
||||
if self.auth:
|
||||
username, password = self.auth
|
||||
credentials = b64encode(f"{username}:{password}".encode()).decode()
|
||||
req.add_header('Authorization', f'Basic {credentials}')
|
||||
|
||||
# Make request
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return response.read().decode('utf-8'), response.status, None
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.read().decode('utf-8'), e.code, str(e)
|
||||
except Exception as e:
|
||||
return None, 0, str(e)
|
||||
|
||||
def get(self, url: str) -> tuple:
|
||||
return self._make_request('GET', url)
|
||||
|
||||
def post(self, url: str, data: Dict) -> tuple:
|
||||
return self._make_request('POST', url, data)
|
||||
|
||||
def put(self, url: str, data: Dict) -> tuple:
|
||||
return self._make_request('PUT', url, data)
|
||||
|
||||
def delete(self, url: str) -> tuple:
|
||||
return self._make_request('DELETE', url)
|
||||
|
||||
|
||||
def http_get(url: str) -> str:
|
||||
"""
|
||||
Perform HTTP GET request.
|
||||
|
||||
Args:
|
||||
url: URL to fetch
|
||||
|
||||
Returns:
|
||||
Response body or error message
|
||||
"""
|
||||
client = HTTPClient()
|
||||
body, status, error = client.get(url)
|
||||
|
||||
if error:
|
||||
return f"Error (HTTP {status}): {error}"
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def http_post(url: str, body: Dict) -> str:
|
||||
"""
|
||||
Perform HTTP POST request with JSON body.
|
||||
|
||||
Args:
|
||||
url: URL to post to
|
||||
body: JSON body as dictionary
|
||||
|
||||
Returns:
|
||||
Response body or error message
|
||||
"""
|
||||
client = HTTPClient()
|
||||
response_body, status, error = client.post(url, body)
|
||||
|
||||
if error:
|
||||
return f"Error (HTTP {status}): {error}"
|
||||
|
||||
return response_body
|
||||
|
||||
|
||||
# Gitea API Tools
|
||||
GITEA_URL = "http://143.198.27.163:3000"
|
||||
GITEA_USER = "timmy"
|
||||
GITEA_PASS = "" # Should be configured
|
||||
|
||||
|
||||
def gitea_create_issue(
|
||||
repo: str = "Timmy_Foundation/timmy-home",
|
||||
title: str = None,
|
||||
body: str = None,
|
||||
labels: list = None
|
||||
) -> str:
|
||||
"""
|
||||
Create a Gitea issue.
|
||||
|
||||
Args:
|
||||
repo: Repository path (owner/repo)
|
||||
title: Issue title (required)
|
||||
body: Issue body
|
||||
labels: List of label names
|
||||
|
||||
Returns:
|
||||
Created issue URL or error
|
||||
"""
|
||||
if not title:
|
||||
return "Error: title is required"
|
||||
|
||||
try:
|
||||
client = HTTPClient(
|
||||
base_url=GITEA_URL,
|
||||
auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None
|
||||
)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"body": body or ""
|
||||
}
|
||||
if labels:
|
||||
data["labels"] = labels
|
||||
|
||||
response, status, error = client.post(
|
||||
f"/api/v1/repos/{repo}/issues",
|
||||
data
|
||||
)
|
||||
|
||||
if error:
|
||||
return f"Error creating issue: {error}"
|
||||
|
||||
result = json.loads(response)
|
||||
return f"✓ Issue created: #{result['number']} - {result['html_url']}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
def gitea_comment(
|
||||
repo: str = "Timmy_Foundation/timmy-home",
|
||||
issue_number: int = None,
|
||||
body: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Comment on a Gitea issue.
|
||||
|
||||
Args:
|
||||
repo: Repository path
|
||||
issue_number: Issue number (required)
|
||||
body: Comment body (required)
|
||||
|
||||
Returns:
|
||||
Comment result
|
||||
"""
|
||||
if not issue_number or not body:
|
||||
return "Error: issue_number and body are required"
|
||||
|
||||
try:
|
||||
client = HTTPClient(
|
||||
base_url=GITEA_URL,
|
||||
auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None
|
||||
)
|
||||
|
||||
response, status, error = client.post(
|
||||
f"/api/v1/repos/{repo}/issues/{issue_number}/comments",
|
||||
{"body": body}
|
||||
)
|
||||
|
||||
if error:
|
||||
return f"Error posting comment: {error}"
|
||||
|
||||
result = json.loads(response)
|
||||
return f"✓ Comment posted: {result['html_url']}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
def gitea_list_issues(
|
||||
repo: str = "Timmy_Foundation/timmy-home",
|
||||
state: str = "open",
|
||||
assignee: str = None
|
||||
) -> str:
|
||||
"""
|
||||
List Gitea issues.
|
||||
|
||||
Args:
|
||||
repo: Repository path
|
||||
state: open, closed, or all
|
||||
assignee: Filter by assignee username
|
||||
|
||||
Returns:
|
||||
JSON list of issues
|
||||
"""
|
||||
try:
|
||||
client = HTTPClient(
|
||||
base_url=GITEA_URL,
|
||||
auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None
|
||||
)
|
||||
|
||||
url = f"/api/v1/repos/{repo}/issues?state={state}"
|
||||
if assignee:
|
||||
url += f"&assignee={assignee}"
|
||||
|
||||
response, status, error = client.get(url)
|
||||
|
||||
if error:
|
||||
return f"Error fetching issues: {error}"
|
||||
|
||||
issues = json.loads(response)
|
||||
|
||||
# Simplify output
|
||||
simplified = []
|
||||
for issue in issues:
|
||||
simplified.append({
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"state": issue["state"],
|
||||
"assignee": issue.get("assignee", {}).get("login") if issue.get("assignee") else None,
|
||||
"url": issue["html_url"]
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"count": len(simplified),
|
||||
"issues": simplified
|
||||
}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
def gitea_get_issue(repo: str = "Timmy_Foundation/timmy-home", issue_number: int = None) -> str:
|
||||
"""
|
||||
Get details of a specific Gitea issue.
|
||||
|
||||
Args:
|
||||
repo: Repository path
|
||||
issue_number: Issue number (required)
|
||||
|
||||
Returns:
|
||||
Issue details
|
||||
"""
|
||||
if not issue_number:
|
||||
return "Error: issue_number is required"
|
||||
|
||||
try:
|
||||
client = HTTPClient(
|
||||
base_url=GITEA_URL,
|
||||
auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None
|
||||
)
|
||||
|
||||
response, status, error = client.get(
|
||||
f"/api/v1/repos/{repo}/issues/{issue_number}"
|
||||
)
|
||||
|
||||
if error:
|
||||
return f"Error fetching issue: {error}"
|
||||
|
||||
issue = json.loads(response)
|
||||
|
||||
return json.dumps({
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"body": issue["body"][:500] + "..." if len(issue["body"]) > 500 else issue["body"],
|
||||
"state": issue["state"],
|
||||
"assignee": issue.get("assignee", {}).get("login") if issue.get("assignee") else None,
|
||||
"created_at": issue["created_at"],
|
||||
"url": issue["html_url"]
|
||||
}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
# Register all network tools
|
||||
def register_all():
|
||||
registry.register(
|
||||
name="http_get",
|
||||
handler=http_get,
|
||||
description="Perform HTTP GET request",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
},
|
||||
category="network"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="http_post",
|
||||
handler=http_post,
|
||||
description="Perform HTTP POST request with JSON body",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to post to"
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "JSON body as dictionary"
|
||||
}
|
||||
},
|
||||
"required": ["url", "body"]
|
||||
},
|
||||
category="network"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="gitea_create_issue",
|
||||
handler=gitea_create_issue,
|
||||
description="Create a Gitea issue",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository path (owner/repo)",
|
||||
"default": "Timmy_Foundation/timmy-home"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Issue body"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"description": "List of label names",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
},
|
||||
category="network"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="gitea_comment",
|
||||
handler=gitea_comment,
|
||||
description="Comment on a Gitea issue",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository path",
|
||||
"default": "Timmy_Foundation/timmy-home"
|
||||
},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Comment body"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number", "body"]
|
||||
},
|
||||
category="network"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="gitea_list_issues",
|
||||
handler=gitea_list_issues,
|
||||
description="List Gitea issues",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository path",
|
||||
"default": "Timmy_Foundation/timmy-home"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"description": "Issue state",
|
||||
"default": "open"
|
||||
},
|
||||
"assignee": {
|
||||
"type": "string",
|
||||
"description": "Filter by assignee username"
|
||||
}
|
||||
}
|
||||
},
|
||||
category="network"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="gitea_get_issue",
|
||||
handler=gitea_get_issue,
|
||||
description="Get details of a specific Gitea issue",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository path",
|
||||
"default": "Timmy_Foundation/timmy-home"
|
||||
},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number"]
|
||||
},
|
||||
category="network"
|
||||
)
|
||||
|
||||
|
||||
register_all()
|
||||
265
uni-wizard/tools/registry.py
Normal file
265
uni-wizard/tools/registry.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Uni-Wizard Tool Registry
|
||||
Central registry for all tool capabilities
|
||||
"""
|
||||
|
||||
import json
|
||||
import inspect
|
||||
from typing import Dict, Callable, Any, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
from functools import wraps
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSchema:
|
||||
"""Schema definition for a tool"""
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any]
|
||||
returns: str
|
||||
examples: list = None
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""Standardized tool execution result"""
|
||||
success: bool
|
||||
data: Any
|
||||
error: Optional[str] = None
|
||||
execution_time_ms: Optional[float] = None
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps({
|
||||
'success': self.success,
|
||||
'data': self.data,
|
||||
'error': self.error,
|
||||
'execution_time_ms': self.execution_time_ms
|
||||
}, indent=2)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.success:
|
||||
return str(self.data)
|
||||
return f"Error: {self.error}"
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""
|
||||
Central registry for all uni-wizard tools.
|
||||
|
||||
All tools register here with their schemas.
|
||||
The LLM queries available tools via get_tool_definitions().
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, Dict] = {}
|
||||
self._categories: Dict[str, list] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: str = None,
|
||||
parameters: Dict = None,
|
||||
category: str = "general",
|
||||
examples: list = None
|
||||
):
|
||||
"""
|
||||
Register a tool in the registry.
|
||||
|
||||
Args:
|
||||
name: Tool name (used in tool calls)
|
||||
handler: Function to execute
|
||||
description: What the tool does
|
||||
parameters: JSON Schema for parameters
|
||||
category: Tool category (system, git, network, file)
|
||||
examples: Example usages
|
||||
"""
|
||||
# Auto-extract description from docstring if not provided
|
||||
if description is None and handler.__doc__:
|
||||
description = handler.__doc__.strip().split('\n')[0]
|
||||
|
||||
# Auto-extract parameters from function signature
|
||||
if parameters is None:
|
||||
parameters = self._extract_params(handler)
|
||||
|
||||
self._tools[name] = {
|
||||
'name': name,
|
||||
'handler': handler,
|
||||
'description': description or f"Execute {name}",
|
||||
'parameters': parameters,
|
||||
'category': category,
|
||||
'examples': examples or []
|
||||
}
|
||||
|
||||
# Add to category
|
||||
if category not in self._categories:
|
||||
self._categories[category] = []
|
||||
self._categories[category].append(name)
|
||||
|
||||
return self # For chaining
|
||||
|
||||
def _extract_params(self, handler: Callable) -> Dict:
|
||||
"""Extract parameter schema from function signature"""
|
||||
sig = inspect.signature(handler)
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
for name, param in sig.parameters.items():
|
||||
# Skip 'self', 'cls', and params with defaults
|
||||
if name in ('self', 'cls'):
|
||||
continue
|
||||
|
||||
param_info = {"type": "string"} # Default
|
||||
|
||||
# Try to infer type from annotation
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
if param.annotation == int:
|
||||
param_info["type"] = "integer"
|
||||
elif param.annotation == float:
|
||||
param_info["type"] = "number"
|
||||
elif param.annotation == bool:
|
||||
param_info["type"] = "boolean"
|
||||
elif param.annotation == list:
|
||||
param_info["type"] = "array"
|
||||
elif param.annotation == dict:
|
||||
param_info["type"] = "object"
|
||||
|
||||
# Add description if in docstring
|
||||
if handler.__doc__:
|
||||
# Simple param extraction from docstring
|
||||
for line in handler.__doc__.split('\n'):
|
||||
if f'{name}:' in line or f'{name} (' in line:
|
||||
desc = line.split(':', 1)[-1].strip()
|
||||
param_info["description"] = desc
|
||||
break
|
||||
|
||||
params["properties"][name] = param_info
|
||||
|
||||
# Required if no default
|
||||
if param.default == inspect.Parameter.empty:
|
||||
params["required"].append(name)
|
||||
|
||||
return params
|
||||
|
||||
def execute(self, name: str, **params) -> ToolResult:
|
||||
"""
|
||||
Execute a tool by name with parameters.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
ToolResult with success/failure and data
|
||||
"""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
tool = self._tools.get(name)
|
||||
if not tool:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
data=None,
|
||||
error=f"Tool '{name}' not found in registry",
|
||||
execution_time_ms=(time.time() - start) * 1000
|
||||
)
|
||||
|
||||
try:
|
||||
handler = tool['handler']
|
||||
result = handler(**params)
|
||||
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data=result,
|
||||
execution_time_ms=(time.time() - start) * 1000
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
data=None,
|
||||
error=f"{type(e).__name__}: {str(e)}",
|
||||
execution_time_ms=(time.time() - start) * 1000
|
||||
)
|
||||
|
||||
def get_tool(self, name: str) -> Optional[Dict]:
|
||||
"""Get tool definition by name"""
|
||||
tool = self._tools.get(name)
|
||||
if tool:
|
||||
# Return without handler (not serializable)
|
||||
return {
|
||||
'name': tool['name'],
|
||||
'description': tool['description'],
|
||||
'parameters': tool['parameters'],
|
||||
'category': tool['category'],
|
||||
'examples': tool['examples']
|
||||
}
|
||||
return None
|
||||
|
||||
def get_tools_by_category(self, category: str) -> list:
|
||||
"""Get all tools in a category"""
|
||||
tool_names = self._categories.get(category, [])
|
||||
return [self.get_tool(name) for name in tool_names if self.get_tool(name)]
|
||||
|
||||
def list_tools(self, category: str = None) -> list:
|
||||
"""List all tool names, optionally filtered by category"""
|
||||
if category:
|
||||
return self._categories.get(category, [])
|
||||
return list(self._tools.keys())
|
||||
|
||||
def get_tool_definitions(self) -> str:
|
||||
"""
|
||||
Get all tool definitions formatted for LLM system prompt.
|
||||
Returns JSON string of all tools with schemas.
|
||||
"""
|
||||
tools = []
|
||||
for name, tool in self._tools.items():
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": tool['description'],
|
||||
"parameters": tool['parameters']
|
||||
})
|
||||
|
||||
return json.dumps(tools, indent=2)
|
||||
|
||||
def get_categories(self) -> list:
|
||||
"""Get all tool categories"""
|
||||
return list(self._categories.keys())
|
||||
|
||||
|
||||
# Global registry instance
|
||||
registry = ToolRegistry()
|
||||
|
||||
|
||||
def tool(name: str = None, category: str = "general", examples: list = None):
|
||||
"""
|
||||
Decorator to register a function as a tool.
|
||||
|
||||
Usage:
|
||||
@tool(category="system")
|
||||
def system_info():
|
||||
return {...}
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
tool_name = name or func.__name__
|
||||
registry.register(
|
||||
name=tool_name,
|
||||
handler=func,
|
||||
category=category,
|
||||
examples=examples
|
||||
)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
# Convenience function for quick tool execution
|
||||
def call_tool(name: str, **params) -> str:
|
||||
"""Execute a tool and return string result"""
|
||||
result = registry.execute(name, **params)
|
||||
return str(result)
|
||||
377
uni-wizard/tools/system_tools.py
Normal file
377
uni-wizard/tools/system_tools.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
System Tools for Uni-Wizard
|
||||
Monitor and control the VPS environment
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import platform
|
||||
import psutil
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .registry import tool, registry
|
||||
|
||||
|
||||
@tool(category="system")
|
||||
def system_info() -> str:
|
||||
"""
|
||||
Get comprehensive system information.
|
||||
|
||||
Returns:
|
||||
JSON string with OS, CPU, memory, disk, and uptime info
|
||||
"""
|
||||
try:
|
||||
# CPU info
|
||||
cpu_count = psutil.cpu_count()
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
|
||||
# Memory info
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
# Disk info
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
# Uptime
|
||||
boot_time = datetime.fromtimestamp(psutil.boot_time())
|
||||
uptime = datetime.now() - boot_time
|
||||
|
||||
# Load average (Linux only)
|
||||
load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else [0, 0, 0]
|
||||
|
||||
info = {
|
||||
"hostname": platform.node(),
|
||||
"os": {
|
||||
"system": platform.system(),
|
||||
"release": platform.release(),
|
||||
"version": platform.version(),
|
||||
"machine": platform.machine()
|
||||
},
|
||||
"cpu": {
|
||||
"count": cpu_count,
|
||||
"percent": cpu_percent,
|
||||
"frequency_mhz": cpu_freq.current if cpu_freq else None
|
||||
},
|
||||
"memory": {
|
||||
"total_gb": round(memory.total / (1024**3), 2),
|
||||
"available_gb": round(memory.available / (1024**3), 2),
|
||||
"percent_used": memory.percent
|
||||
},
|
||||
"disk": {
|
||||
"total_gb": round(disk.total / (1024**3), 2),
|
||||
"free_gb": round(disk.free / (1024**3), 2),
|
||||
"percent_used": round((disk.used / disk.total) * 100, 1)
|
||||
},
|
||||
"uptime": {
|
||||
"boot_time": boot_time.isoformat(),
|
||||
"uptime_seconds": int(uptime.total_seconds()),
|
||||
"uptime_human": str(timedelta(seconds=int(uptime.total_seconds())))
|
||||
},
|
||||
"load_average": {
|
||||
"1min": round(load_avg[0], 2),
|
||||
"5min": round(load_avg[1], 2),
|
||||
"15min": round(load_avg[2], 2)
|
||||
}
|
||||
}
|
||||
|
||||
return json.dumps(info, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting system info: {str(e)}"
|
||||
|
||||
|
||||
@tool(category="system")
|
||||
def process_list(filter_name: str = None) -> str:
|
||||
"""
|
||||
List running processes with optional name filter.
|
||||
|
||||
Args:
|
||||
filter_name: Optional process name to filter by
|
||||
|
||||
Returns:
|
||||
JSON list of processes with PID, name, CPU%, memory
|
||||
"""
|
||||
try:
|
||||
processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'status']):
|
||||
try:
|
||||
info = proc.info
|
||||
if filter_name and filter_name.lower() not in info['name'].lower():
|
||||
continue
|
||||
processes.append({
|
||||
"pid": info['pid'],
|
||||
"name": info['name'],
|
||||
"cpu_percent": info['cpu_percent'],
|
||||
"memory_percent": round(info['memory_percent'], 2) if info['memory_percent'] else 0,
|
||||
"status": info['status']
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
# Sort by CPU usage
|
||||
processes.sort(key=lambda x: x['cpu_percent'], reverse=True)
|
||||
|
||||
return json.dumps({
|
||||
"count": len(processes),
|
||||
"filter": filter_name,
|
||||
"processes": processes[:50] # Limit to top 50
|
||||
}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error listing processes: {str(e)}"
|
||||
|
||||
|
||||
@tool(category="system")
|
||||
def service_status(service_name: str) -> str:
|
||||
"""
|
||||
Check systemd service status.
|
||||
|
||||
Args:
|
||||
service_name: Name of the service (e.g., 'llama-server', 'syncthing@root')
|
||||
|
||||
Returns:
|
||||
Service status information
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['systemctl', 'status', service_name, '--no-pager'],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Parse output
|
||||
lines = result.stdout.split('\n')
|
||||
status_info = {"service": service_name}
|
||||
|
||||
for line in lines:
|
||||
if 'Active:' in line:
|
||||
status_info['active'] = line.split(':', 1)[1].strip()
|
||||
elif 'Loaded:' in line:
|
||||
status_info['loaded'] = line.split(':', 1)[1].strip()
|
||||
elif 'Main PID:' in line:
|
||||
status_info['pid'] = line.split(':', 1)[1].strip()
|
||||
elif 'Memory:' in line:
|
||||
status_info['memory'] = line.split(':', 1)[1].strip()
|
||||
elif 'CPU:' in line:
|
||||
status_info['cpu'] = line.split(':', 1)[1].strip()
|
||||
|
||||
status_info['exit_code'] = result.returncode
|
||||
|
||||
return json.dumps(status_info, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error checking service status: {str(e)}"
|
||||
|
||||
|
||||
@tool(category="system")
|
||||
def service_control(service_name: str, action: str) -> str:
|
||||
"""
|
||||
Control a systemd service (start, stop, restart, enable, disable).
|
||||
|
||||
Args:
|
||||
service_name: Name of the service
|
||||
action: start, stop, restart, enable, disable, status
|
||||
|
||||
Returns:
|
||||
Result of the action
|
||||
"""
|
||||
valid_actions = ['start', 'stop', 'restart', 'enable', 'disable', 'status']
|
||||
|
||||
if action not in valid_actions:
|
||||
return f"Invalid action. Use: {', '.join(valid_actions)}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['systemctl', action, service_name],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return f"✓ Service '{service_name}' {action} successful"
|
||||
else:
|
||||
return f"✗ Service '{service_name}' {action} failed: {result.stderr}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error controlling service: {str(e)}"
|
||||
|
||||
|
||||
@tool(category="system")
|
||||
def health_check() -> str:
|
||||
"""
|
||||
Comprehensive health check of the VPS.
|
||||
|
||||
Checks:
|
||||
- System resources (CPU, memory, disk)
|
||||
- Critical services (llama-server, syncthing, timmy-agent)
|
||||
- Network connectivity
|
||||
- Inference endpoint
|
||||
|
||||
Returns:
|
||||
Health report with status and recommendations
|
||||
"""
|
||||
try:
|
||||
health = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"overall": "healthy",
|
||||
"checks": {}
|
||||
}
|
||||
|
||||
# System resources
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
health["checks"]["memory"] = {
|
||||
"status": "healthy" if memory.percent < 90 else "warning",
|
||||
"percent_used": memory.percent,
|
||||
"available_gb": round(memory.available / (1024**3), 2)
|
||||
}
|
||||
|
||||
health["checks"]["disk"] = {
|
||||
"status": "healthy" if disk.percent < 90 else "warning",
|
||||
"percent_used": disk.percent,
|
||||
"free_gb": round(disk.free / (1024**3), 2)
|
||||
}
|
||||
|
||||
# Check inference endpoint
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=5)
|
||||
health["checks"]["inference"] = {"status": "healthy", "port": 8081}
|
||||
except:
|
||||
health["checks"]["inference"] = {"status": "down", "port": 8081}
|
||||
health["overall"] = "degraded"
|
||||
|
||||
# Check services
|
||||
services = ['llama-server', 'syncthing@root']
|
||||
for svc in services:
|
||||
result = subprocess.run(['systemctl', 'is-active', svc], capture_output=True, text=True)
|
||||
health["checks"][svc] = {
|
||||
"status": "healthy" if result.returncode == 0 else "down"
|
||||
}
|
||||
if result.returncode != 0:
|
||||
health["overall"] = "degraded"
|
||||
|
||||
return json.dumps(health, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error running health check: {str(e)}"
|
||||
|
||||
|
||||
@tool(category="system")
|
||||
def disk_usage(path: str = "/") -> str:
|
||||
"""
|
||||
Get disk usage for a path.
|
||||
|
||||
Args:
|
||||
path: Path to check (default: /)
|
||||
|
||||
Returns:
|
||||
Disk usage statistics
|
||||
"""
|
||||
try:
|
||||
usage = psutil.disk_usage(path)
|
||||
return json.dumps({
|
||||
"path": path,
|
||||
"total_gb": round(usage.total / (1024**3), 2),
|
||||
"used_gb": round(usage.used / (1024**3), 2),
|
||||
"free_gb": round(usage.free / (1024**3), 2),
|
||||
"percent_used": round((usage.used / usage.total) * 100, 1)
|
||||
}, indent=2)
|
||||
except Exception as e:
|
||||
return f"Error checking disk usage: {str(e)}"
|
||||
|
||||
|
||||
# Auto-register all tools in this module
|
||||
def register_all():
|
||||
"""Register all system tools"""
|
||||
registry.register(
|
||||
name="system_info",
|
||||
handler=system_info,
|
||||
description="Get comprehensive system information (OS, CPU, memory, disk, uptime)",
|
||||
category="system"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="process_list",
|
||||
handler=process_list,
|
||||
description="List running processes with optional name filter",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter_name": {
|
||||
"type": "string",
|
||||
"description": "Optional process name to filter by"
|
||||
}
|
||||
}
|
||||
},
|
||||
category="system"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="service_status",
|
||||
handler=service_status,
|
||||
description="Check systemd service status",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the systemd service"
|
||||
}
|
||||
},
|
||||
"required": ["service_name"]
|
||||
},
|
||||
category="system"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="service_control",
|
||||
handler=service_control,
|
||||
description="Control a systemd service (start, stop, restart, enable, disable)",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the service"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["start", "stop", "restart", "enable", "disable", "status"],
|
||||
"description": "Action to perform"
|
||||
}
|
||||
},
|
||||
"required": ["service_name", "action"]
|
||||
},
|
||||
category="system"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="health_check",
|
||||
handler=health_check,
|
||||
description="Comprehensive health check of VPS (resources, services, inference)",
|
||||
category="system"
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="disk_usage",
|
||||
handler=disk_usage,
|
||||
description="Get disk usage for a path",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to check",
|
||||
"default": "/"
|
||||
}
|
||||
}
|
||||
},
|
||||
category="system"
|
||||
)
|
||||
|
||||
|
||||
register_all()
|
||||
Reference in New Issue
Block a user