548 lines
15 KiB
Python
548 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Timmy Tool Commands
|
|
Issue #84 — Bridge Tools into Evennia
|
|
|
|
Converts Timmy's tool library into Evennia Command objects
|
|
so they can be invoked within the world.
|
|
"""
|
|
|
|
from evennia import Command
|
|
from evennia.utils import evtable
|
|
from typing import Optional, List
|
|
import json
|
|
import os
|
|
|
|
|
|
class CmdRead(Command):
|
|
"""
|
|
Read a file from the system.
|
|
|
|
Usage:
|
|
read <path>
|
|
|
|
Example:
|
|
read ~/.timmy/config.yaml
|
|
read /opt/timmy/logs/latest.log
|
|
"""
|
|
|
|
key = "read"
|
|
aliases = ["cat", "show"]
|
|
help_category = "Tools"
|
|
|
|
def func(self):
|
|
if not self.args:
|
|
self.caller.msg("Usage: read <path>")
|
|
return
|
|
|
|
path = self.args.strip()
|
|
path = os.path.expanduser(path)
|
|
|
|
try:
|
|
with open(path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Store for later use
|
|
self.caller.db.last_read_file = path
|
|
self.caller.db.last_read_content = content
|
|
|
|
# Limit display if too long
|
|
lines = content.split('\n')
|
|
if len(lines) > 50:
|
|
display = '\n'.join(lines[:50])
|
|
self.caller.msg(f"|w{path}|n (showing first 50 lines of {len(lines)}):")
|
|
self.caller.msg(display)
|
|
self.caller.msg(f"\n|y... {len(lines) - 50} more lines|n")
|
|
else:
|
|
self.caller.msg(f"|w{path}|n:")
|
|
self.caller.msg(content)
|
|
|
|
# Record in metrics
|
|
if hasattr(self.caller, 'update_metrics'):
|
|
self.caller.update_metrics(files_read=1)
|
|
|
|
except FileNotFoundError:
|
|
self.caller.msg(f"|rFile not found:|n {path}")
|
|
except PermissionError:
|
|
self.caller.msg(f"|rPermission denied:|n {path}")
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError reading file:|n {e}")
|
|
|
|
|
|
class CmdWrite(Command):
|
|
"""
|
|
Write content to a file.
|
|
|
|
Usage:
|
|
write <path> = <content>
|
|
|
|
Example:
|
|
write ~/.timmy/notes.txt = This is a note
|
|
"""
|
|
|
|
key = "write"
|
|
aliases = ["save"]
|
|
help_category = "Tools"
|
|
|
|
def func(self):
|
|
if not self.args or "=" not in self.args:
|
|
self.caller.msg("Usage: write <path> = <content>")
|
|
return
|
|
|
|
path, content = self.args.split("=", 1)
|
|
path = path.strip()
|
|
content = content.strip()
|
|
path = os.path.expanduser(path)
|
|
|
|
try:
|
|
# Create directory if needed
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
|
|
with open(path, 'w') as f:
|
|
f.write(content)
|
|
|
|
self.caller.msg(f"|gWritten:|n {path}")
|
|
|
|
# Update metrics
|
|
if hasattr(self.caller, 'update_metrics'):
|
|
self.caller.update_metrics(files_modified=1, lines_written=content.count('\n'))
|
|
|
|
except PermissionError:
|
|
self.caller.msg(f"|rPermission denied:|n {path}")
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError writing file:|n {e}")
|
|
|
|
|
|
class CmdSearch(Command):
|
|
"""
|
|
Search file contents for a pattern.
|
|
|
|
Usage:
|
|
search <pattern> [in <path>]
|
|
|
|
Example:
|
|
search "def main" in ~/code/
|
|
search "TODO"
|
|
"""
|
|
|
|
key = "search"
|
|
aliases = ["grep", "find"]
|
|
help_category = "Tools"
|
|
|
|
def func(self):
|
|
if not self.args:
|
|
self.caller.msg("Usage: search <pattern> [in <path>]")
|
|
return
|
|
|
|
args = self.args.strip()
|
|
|
|
# Parse path if specified
|
|
if " in " in args:
|
|
pattern, path = args.split(" in ", 1)
|
|
pattern = pattern.strip()
|
|
path = path.strip()
|
|
else:
|
|
pattern = args
|
|
path = "."
|
|
|
|
path = os.path.expanduser(path)
|
|
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
["grep", "-r", "-n", pattern, path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
lines = result.stdout.strip().split('\n')
|
|
self.caller.msg(f"|gFound {len(lines)} matches for '|n{pattern}|g':|n")
|
|
for line in lines[:20]: # Limit output
|
|
self.caller.msg(f" {line}")
|
|
if len(lines) > 20:
|
|
self.caller.msg(f"\n|y... and {len(lines) - 20} more|n")
|
|
else:
|
|
self.caller.msg(f"|yNo matches found for '|n{pattern}|y'|n")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
self.caller.msg("|rSearch timed out|n")
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError searching:|n {e}")
|
|
|
|
|
|
class CmdGitStatus(Command):
|
|
"""
|
|
Check git status of a repository.
|
|
|
|
Usage:
|
|
git status [path]
|
|
|
|
Example:
|
|
git status
|
|
git status ~/projects/timmy
|
|
"""
|
|
|
|
key = "git_status"
|
|
aliases = ["git status"]
|
|
help_category = "Git"
|
|
|
|
def func(self):
|
|
path = self.args.strip() if self.args else "."
|
|
path = os.path.expanduser(path)
|
|
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
["git", "-C", path, "status", "-sb"],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.caller.msg(f"|wGit status ({path}):|n")
|
|
self.caller.msg(result.stdout)
|
|
else:
|
|
self.caller.msg(f"|rNot a git repository:|n {path}")
|
|
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError:|n {e}")
|
|
|
|
|
|
class CmdGitLog(Command):
|
|
"""
|
|
Show git commit history.
|
|
|
|
Usage:
|
|
git log [n] [path]
|
|
|
|
Example:
|
|
git log
|
|
git log 10
|
|
git log 5 ~/projects/timmy
|
|
"""
|
|
|
|
key = "git_log"
|
|
aliases = ["git log"]
|
|
help_category = "Git"
|
|
|
|
def func(self):
|
|
args = self.args.strip().split() if self.args else []
|
|
|
|
# Parse args
|
|
path = "."
|
|
n = 10
|
|
|
|
for arg in args:
|
|
if arg.isdigit():
|
|
n = int(arg)
|
|
else:
|
|
path = arg
|
|
|
|
path = os.path.expanduser(path)
|
|
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
["git", "-C", path, "log", f"--oneline", f"-{n}"],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.caller.msg(f"|wRecent commits ({path}):|n")
|
|
self.caller.msg(result.stdout)
|
|
else:
|
|
self.caller.msg(f"|rNot a git repository:|n {path}")
|
|
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError:|n {e}")
|
|
|
|
|
|
class CmdGitPull(Command):
|
|
"""
|
|
Pull latest changes from git remote.
|
|
|
|
Usage:
|
|
git pull [path]
|
|
"""
|
|
|
|
key = "git_pull"
|
|
aliases = ["git pull"]
|
|
help_category = "Git"
|
|
|
|
def func(self):
|
|
path = self.args.strip() if self.args else "."
|
|
path = os.path.expanduser(path)
|
|
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
["git", "-C", path, "pull"],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.caller.msg(f"|gPulled ({path}):|n")
|
|
self.caller.msg(result.stdout)
|
|
else:
|
|
self.caller.msg(f"|rPull failed:|n {result.stderr}")
|
|
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError:|n {e}")
|
|
|
|
|
|
class CmdSysInfo(Command):
|
|
"""
|
|
Display system information.
|
|
|
|
Usage:
|
|
sysinfo
|
|
"""
|
|
|
|
key = "sysinfo"
|
|
aliases = ["system_info", "status"]
|
|
help_category = "System"
|
|
|
|
def func(self):
|
|
import platform
|
|
import psutil
|
|
|
|
# Gather info
|
|
info = {
|
|
"Platform": platform.platform(),
|
|
"CPU": f"{psutil.cpu_count()} cores, {psutil.cpu_percent()}% used",
|
|
"Memory": f"{psutil.virtual_memory().percent}% used "
|
|
f"({psutil.virtual_memory().used // (1024**3)}GB / "
|
|
f"{psutil.virtual_memory().total // (1024**3)}GB)",
|
|
"Disk": f"{psutil.disk_usage('/').percent}% used "
|
|
f"({psutil.disk_usage('/').free // (1024**3)}GB free)",
|
|
"Uptime": f"{psutil.boot_time()}" # Simplified
|
|
}
|
|
|
|
self.caller.msg("|wSystem Information:|n")
|
|
for key, value in info.items():
|
|
self.caller.msg(f" |c{key}|n: {value}")
|
|
|
|
|
|
class CmdHealth(Command):
|
|
"""
|
|
Check health of Timmy services.
|
|
|
|
Usage:
|
|
health
|
|
"""
|
|
|
|
key = "health"
|
|
aliases = ["check"]
|
|
help_category = "System"
|
|
|
|
def func(self):
|
|
import subprocess
|
|
|
|
services = [
|
|
"timmy-overnight-loop",
|
|
"timmy-health",
|
|
"llama-server",
|
|
"gitea"
|
|
]
|
|
|
|
self.caller.msg("|wService Health:|n")
|
|
|
|
for service in services:
|
|
try:
|
|
result = subprocess.run(
|
|
["systemctl", "is-active", service],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
status = result.stdout.strip()
|
|
icon = "|g●|n" if status == "active" else "|r●|n"
|
|
self.caller.msg(f" {icon} {service}: {status}")
|
|
except:
|
|
self.caller.msg(f" |y?|n {service}: unknown")
|
|
|
|
|
|
class CmdThink(Command):
|
|
"""
|
|
Send a prompt to the local LLM and return the response.
|
|
|
|
Usage:
|
|
think <prompt>
|
|
|
|
Example:
|
|
think What should I focus on today?
|
|
think Summarize the last git commit
|
|
"""
|
|
|
|
key = "think"
|
|
aliases = ["reason", "ponder"]
|
|
help_category = "Inference"
|
|
|
|
def func(self):
|
|
if not self.args:
|
|
self.caller.msg("Usage: think <prompt>")
|
|
return
|
|
|
|
prompt = self.args.strip()
|
|
|
|
self.caller.msg(f"|wThinking about:|n {prompt[:50]}...")
|
|
|
|
try:
|
|
import requests
|
|
|
|
response = requests.post(
|
|
"http://localhost:8080/v1/chat/completions",
|
|
json={
|
|
"model": "hermes4",
|
|
"messages": [
|
|
{"role": "user", "content": prompt}
|
|
],
|
|
"max_tokens": 500
|
|
},
|
|
timeout=60
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
result = response.json()
|
|
content = result["choices"][0]["message"]["content"]
|
|
self.caller.msg(f"\n|cResponse:|n\n{content}")
|
|
else:
|
|
self.caller.msg(f"|rError:|n HTTP {response.status_code}")
|
|
|
|
except requests.exceptions.ConnectionError:
|
|
self.caller.msg("|rError:|n llama-server not running on localhost:8080")
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError:|n {e}")
|
|
|
|
|
|
class CmdGiteaIssues(Command):
|
|
"""
|
|
List open issues from Gitea.
|
|
|
|
Usage:
|
|
gitea issues
|
|
gitea issues --limit 5
|
|
"""
|
|
|
|
key = "gitea_issues"
|
|
aliases = ["issues"]
|
|
help_category = "Gitea"
|
|
|
|
def func(self):
|
|
args = self.args.strip().split() if self.args else []
|
|
limit = 10
|
|
|
|
for i, arg in enumerate(args):
|
|
if arg == "--limit" and i + 1 < len(args):
|
|
limit = int(args[i + 1])
|
|
|
|
try:
|
|
import requests
|
|
|
|
# Get issues from Gitea API
|
|
response = requests.get(
|
|
"http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/timmy-home/issues",
|
|
params={"state": "open", "limit": limit},
|
|
timeout=10
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
issues = response.json()
|
|
self.caller.msg(f"|wOpen Issues ({len(issues)}):|n\n")
|
|
|
|
for issue in issues:
|
|
num = issue["number"]
|
|
title = issue["title"][:60]
|
|
assignee = issue.get("assignee", {}).get("login", "unassigned")
|
|
self.caller.msg(f" |y#{num}|n: {title} (|c{assignee}|n)")
|
|
else:
|
|
self.caller.msg(f"|rError:|n HTTP {response.status_code}")
|
|
|
|
except Exception as e:
|
|
self.caller.msg(f"|rError:|n {e}")
|
|
|
|
|
|
class CmdWorkshop(Command):
|
|
"""
|
|
Enter the Workshop room.
|
|
|
|
Usage:
|
|
workshop
|
|
"""
|
|
|
|
key = "workshop"
|
|
help_category = "Navigation"
|
|
|
|
def func(self):
|
|
# Find workshop
|
|
workshop = self.caller.search("Workshop", global_search=True)
|
|
if workshop:
|
|
self.caller.move_to(workshop)
|
|
|
|
|
|
class CmdLibrary(Command):
|
|
"""
|
|
Enter the Library room.
|
|
|
|
Usage:
|
|
library
|
|
"""
|
|
|
|
key = "library"
|
|
help_category = "Navigation"
|
|
|
|
def func(self):
|
|
library = self.caller.search("Library", global_search=True)
|
|
if library:
|
|
self.caller.move_to(library)
|
|
|
|
|
|
class CmdObservatory(Command):
|
|
"""
|
|
Enter the Observatory room.
|
|
|
|
Usage:
|
|
observatory
|
|
"""
|
|
|
|
key = "observatory"
|
|
help_category = "Navigation"
|
|
|
|
def func(self):
|
|
obs = self.caller.search("Observatory", global_search=True)
|
|
if obs:
|
|
self.caller.move_to(obs)
|
|
|
|
|
|
class CmdStatus(Command):
|
|
"""
|
|
Show Timmy's current status.
|
|
|
|
Usage:
|
|
status
|
|
"""
|
|
|
|
key = "status"
|
|
help_category = "Info"
|
|
|
|
def func(self):
|
|
if hasattr(self.caller, 'get_status'):
|
|
status = self.caller.get_status()
|
|
|
|
self.caller.msg("|wTimmy Status:|n\n")
|
|
|
|
if status.get('current_task'):
|
|
self.caller.msg(f"|yCurrent Task:|n {status['current_task']['description']}")
|
|
else:
|
|
self.caller.msg("|gNo active task|n")
|
|
|
|
self.caller.msg(f"Tasks Completed: {status['tasks_completed']}")
|
|
self.caller.msg(f"Knowledge Items: {status['knowledge_items']}")
|
|
self.caller.msg(f"Tools Available: {status['tools_available']}")
|
|
self.caller.msg(f"Location: {status['location']}")
|
|
else:
|
|
self.caller.msg("Status not available.")
|