tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
1433 lines
68 KiB
Python
1433 lines
68 KiB
Python
"""Agent tool implementations and dispatch."""
|
|
|
|
import html
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import zipfile
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from tai_pkg import render
|
|
from tai_pkg import sysinfo
|
|
from tai_pkg.agents import REGISTRY
|
|
from tai_pkg.agents import default_runner
|
|
from tai_pkg.consts import Consts
|
|
from tai_pkg.edge import Audio
|
|
from tai_pkg.edge import EdgeTts
|
|
from tai_pkg.errors import BackendError
|
|
from tai_pkg.errors import Denied
|
|
from tai_pkg.install import Install
|
|
from tai_pkg.plexer import PlexerControllerFactory
|
|
from tai_pkg.plexer import PlexerError
|
|
from tai_pkg.release import Release
|
|
from tai_pkg.render import LiveStream
|
|
from tai_pkg.render import Render
|
|
from tai_pkg.sandbox import Sandbox
|
|
from tai_pkg.scheduler import Scheduler
|
|
from tai_pkg.shellsnap import ShellSnap
|
|
from tai_pkg.skills import Skills
|
|
from tai_pkg.sysinfo import SysInfo
|
|
from tai_pkg.text import Text
|
|
|
|
__all__ = ["Tools"]
|
|
|
|
|
|
class Tools:
|
|
def __init__(self, app):
|
|
self.app = app
|
|
self.handlers = {
|
|
"shell": self.run_shell,
|
|
"read_file": self.run_read_file,
|
|
"write_file": self.run_write_file,
|
|
"edit_file": self.run_edit_file,
|
|
"web_search": self.run_web_search,
|
|
"web_fetch": self.run_web_fetch,
|
|
"speak": self.run_speak,
|
|
"listen": self.run_listen,
|
|
"remember": self.run_remember,
|
|
"recall": self.run_recall,
|
|
"load_skill": self.run_load_skill,
|
|
"get_current_terminal_content": self.run_terminal_content,
|
|
"export_terminal_log": self.run_export_terminal_log,
|
|
"plexer_send": self.run_plexer_send,
|
|
"plexer_read": self.run_plexer_read,
|
|
"plexer_type": self.run_plexer_type,
|
|
"plexer_status": self.run_plexer_status,
|
|
"fork": self.run_fork,
|
|
"poll": self.run_poll,
|
|
"sysinfo": self.run_sysinfo,
|
|
"create_skill": self.run_create_skill,
|
|
"install_skill": self.run_install_skill,
|
|
"store_secret": self.run_store_secret,
|
|
"list_secrets": self.run_list_secrets,
|
|
"delete_secret": self.run_delete_secret,
|
|
"schedule": self.run_schedule,
|
|
"unschedule": self.run_unschedule,
|
|
"schedules": self.run_schedules,
|
|
"record_save": self.run_record_save,
|
|
"record_read": self.run_record_read,
|
|
"record_search": self.run_record_search,
|
|
"record_delete": self.run_record_delete,
|
|
"graph_link": self.run_graph_link,
|
|
"graph_query": self.run_graph_query,
|
|
"delete_file": self.run_delete_file,
|
|
"audit": self.run_audit,
|
|
"restore": self.run_restore,
|
|
"release": self.run_release,
|
|
"tags": self.run_tags,
|
|
"install": self.run_install,
|
|
"create_bot": self.run_create_bot,
|
|
"search": self.run_search,
|
|
}
|
|
self.secret_grants = set()
|
|
self.read_files = set()
|
|
self.live = False
|
|
self.diff_preview = None
|
|
|
|
def note_diff(self, path, old, new):
|
|
self.diff_preview = {"path": path, "old": old, "new": new}
|
|
|
|
def box_engine(self):
|
|
try:
|
|
return Sandbox.ensure_box(), ""
|
|
except BackendError as exc:
|
|
return "", "error: " + Text.short_error(exc)
|
|
|
|
def dispatch(self, name, raw_arguments):
|
|
try:
|
|
args = raw_arguments if isinstance(raw_arguments, dict) else json.loads(raw_arguments or "{}")
|
|
except (TypeError, ValueError):
|
|
return "error: arguments are not valid JSON"
|
|
handler = self.handlers.get(name)
|
|
if handler is None:
|
|
return "error: unknown tool " + name
|
|
try:
|
|
result = handler(args)
|
|
except Denied:
|
|
raise
|
|
except Exception as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if self.app.store is not None:
|
|
return self.app.store.redact(result, self.active_profile)
|
|
return result
|
|
|
|
def secret_grant(self, kind, key, names):
|
|
token = (kind, key, tuple(sorted(names)))
|
|
if token in self.secret_grants:
|
|
return True
|
|
if not self.app.ask_approval("share secrets (%s) with %s" % (", ".join(sorted(names)), kind)):
|
|
return False
|
|
self.secret_grants.add(token)
|
|
return True
|
|
|
|
def resolve_secrets(self, names):
|
|
vault = {}
|
|
for name in names:
|
|
value = self.app.store.load_secret(name, self.active_profile)
|
|
if value is None:
|
|
return None, "error: unknown secret '%s'" % name
|
|
vault[name] = value
|
|
return vault, ""
|
|
|
|
def shell_is_safe(self, command):
|
|
parts = command.split()
|
|
if not parts:
|
|
return False
|
|
if parts[0] == "git":
|
|
return len(parts) > 1 and parts[1] in ("status", "diff", "log", "show", "branch", "remote")
|
|
return parts[0] in Consts.SAFE_COMMANDS
|
|
|
|
def snapshot_shell_targets(self, command, workdir):
|
|
if self.app.store is None:
|
|
return 0
|
|
profile = self.active_profile
|
|
count = 0
|
|
for found in ShellSnap.shell_target_paths(command, workdir):
|
|
if not os.path.isfile(found):
|
|
continue
|
|
text, override = Text.read_capped(found)
|
|
if text is None:
|
|
continue
|
|
try:
|
|
self.app.store.audit_event(self.audit_actor(), "shell-snapshot", found, command[:200], old=text, tags=["shell"], old_size=override, profile=profile)
|
|
self.app.store.upsert_file_record(found, text, override, profile)
|
|
count += 1
|
|
except (sqlite3.Error, OSError):
|
|
continue
|
|
return count
|
|
|
|
def run_shell(self, args):
|
|
command = str(args.get("command") or "").strip()
|
|
if not command:
|
|
return "error: empty command"
|
|
secret_names = args.get("secrets") or []
|
|
if not isinstance(secret_names, list) or any(not isinstance(item, str) for item in secret_names):
|
|
return "error: secrets must be a list of names"
|
|
vault, failure = self.resolve_secrets(secret_names) if secret_names else ({}, "")
|
|
if failure:
|
|
return failure
|
|
if vault and not self.secret_grant("shell", "", sorted(vault)):
|
|
return "denied by user"
|
|
if self.app.env == "sandbox":
|
|
return self.run_shell_box(command, str(args.get("workdir") or "/"), vault)
|
|
workdir = str(args.get("workdir") or os.getcwd())
|
|
if not self.app.config.auto_approve and not self.shell_is_safe(command) and not self.app.ask_approval(command):
|
|
return "denied by user"
|
|
self.snapshot_shell_targets(command, workdir)
|
|
env = dict(os.environ)
|
|
for name, value in vault.items():
|
|
env[Text.secret_env_name(name)] = value
|
|
stream = LiveStream() if self.live and sys.stdout.isatty() else None
|
|
code, output, elapsed, timed_out = Render.run_live(command, shell=True, cwd=workdir, env=env, stream=stream)
|
|
if stream is not None:
|
|
stream.close(Render.stream_status(code, output, elapsed, timed_out))
|
|
if timed_out:
|
|
return "error: timed out after %d seconds" % Consts.SHELL_TIMEOUT
|
|
return "exit %d\n%s%s" % (code, ShellSnap.spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile), Text.curl_hint(command))
|
|
|
|
def run_shell_box(self, command, workdir, vault):
|
|
engine, failure = self.box_engine()
|
|
if not engine:
|
|
return failure
|
|
extra = ["--workdir", workdir]
|
|
for name, value in vault.items():
|
|
extra += ["-e", "%s=%s" % (Text.secret_env_name(name), value)]
|
|
try:
|
|
stream = LiveStream() if self.live and sys.stdout.isatty() else None
|
|
code, output, elapsed, timed_out = Render.run_live([engine, "exec", *extra, "-i", Consts.BOX_NAME, "sh", "-c", command], stream=stream)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if stream is not None:
|
|
stream.close(Render.stream_status(code, output, elapsed, timed_out))
|
|
if timed_out:
|
|
return "error: timed out after %d seconds" % Consts.SHELL_TIMEOUT
|
|
return "exit %d\n%s%s" % (code, ShellSnap.spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile), Text.curl_hint(command))
|
|
|
|
def run_read_file(self, args):
|
|
path = str(args.get("path") or "")
|
|
if self.app.env == "sandbox":
|
|
result = self.box_read(path)
|
|
if not result.startswith("error:"):
|
|
self.read_files.add(("box", path))
|
|
return result
|
|
if not path or not os.path.isfile(path):
|
|
return "error: no such file"
|
|
if os.path.getsize(path) > 200000:
|
|
return "error: file too large"
|
|
try:
|
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
result = Text.truncate(handle.read(), 8000, 2000)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
self.read_files.add(("home", os.path.abspath(path)))
|
|
return result
|
|
|
|
def audit_actor(self):
|
|
if getattr(self.app, "persist", True) and getattr(self.app, "depth", 0) == 0:
|
|
return "main"
|
|
return "worker"
|
|
|
|
@property
|
|
def active_profile(self):
|
|
profile = getattr(self.app, "profile", None)
|
|
if profile:
|
|
return profile
|
|
store = getattr(self.app, "store", None)
|
|
return getattr(store, "profile", None) or "default"
|
|
|
|
def audit_file(self, action, path, old, new, message="", tags=(), old_size=None, new_size=None):
|
|
if self.app.store is None:
|
|
return None
|
|
profile = self.active_profile
|
|
try:
|
|
row_id = self.app.store.audit_event(self.audit_actor(), action, path, message, old, new, ["file", action] + list(tags), old_size, new_size, profile)
|
|
except (sqlite3.Error, OSError):
|
|
return False
|
|
try:
|
|
if action == "delete":
|
|
self.app.store.delete_file_record(path, profile)
|
|
elif new is not None:
|
|
self.app.store.upsert_file_record(path, new, new_size, profile)
|
|
elif old is not None:
|
|
self.app.store.upsert_file_record(path, old, old_size, profile)
|
|
except (sqlite3.Error, OSError):
|
|
pass
|
|
return row_id
|
|
|
|
def box_read_raw(self, path, limit=200000):
|
|
engine, failure = self.box_engine()
|
|
if not engine:
|
|
return None, failure
|
|
try:
|
|
done = Sandbox.box_exec(engine, ["sh", "-c", "wc -c < \"$1\" && head -c \"$2\" \"$1\"", "box", path, str(limit + 1)], timeout=60)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return None, "error: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return None, "error: no such file"
|
|
head, _, rest = done.stdout.decode("utf-8", "replace").partition("\n")
|
|
try:
|
|
size = int(head.strip())
|
|
except ValueError:
|
|
return None, "error: unreadable file"
|
|
return size, rest
|
|
|
|
def box_read(self, path):
|
|
if not path:
|
|
return "error: empty path"
|
|
engine, failure = self.box_engine()
|
|
if not engine:
|
|
return failure
|
|
try:
|
|
done = Sandbox.box_exec(engine, ["head", "-c", "200000", path], timeout=60)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return "error: no such file"
|
|
return Text.truncate(done.stdout.decode("utf-8", "replace"), 8000, 2000)
|
|
|
|
def box_write(self, path, content, old=Consts._UNSET, action="write", message="", preview=True):
|
|
engine, failure = self.box_engine()
|
|
if not engine:
|
|
return failure
|
|
if ("box", path) not in self.read_files:
|
|
try:
|
|
probe = Sandbox.box_exec(engine, ["test", "-f", path], timeout=30)
|
|
except (OSError, subprocess.SubprocessError):
|
|
probe = None
|
|
if probe is not None and probe.returncode == 0:
|
|
return "error: write denied for '%s', read it first with read_file" % path
|
|
if old is Consts._UNSET:
|
|
old, old_size = self.box_old_image(path)
|
|
else:
|
|
old_size = None
|
|
parent = os.path.dirname(path) or "."
|
|
try:
|
|
done = Sandbox.box_exec(engine, ["sh", "-c", "mkdir -p \"$1\" && cat > \"$2\"", "box", parent, path], input_data=content.encode("utf-8"), timeout=60)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return "error: " + done.stderr.decode("utf-8", "replace")[:200]
|
|
self.read_files.add(("box", path))
|
|
audited = self.audit_file(action, "sandbox:" + path, old, content, message, (), old_size, None)
|
|
note = "" if audited is not False else " (audit failed)"
|
|
if preview and old_size is None:
|
|
self.note_diff(path, old, content)
|
|
return "wrote %s%s" % (path, note)
|
|
|
|
def box_old_image(self, path):
|
|
size, raw = self.box_read_raw(path, Consts.AUDIT_MAX_CHARS)
|
|
if size is None:
|
|
return None, None
|
|
if size <= Consts.AUDIT_MAX_CHARS:
|
|
return raw, None
|
|
return raw[:Consts.AUDIT_MAX_CHARS], size
|
|
|
|
def run_write_file(self, args):
|
|
path = str(args.get("path") or "")
|
|
if not path:
|
|
return "error: empty path"
|
|
if self.app.env == "sandbox":
|
|
return self.box_write(path, str(args.get("content") or ""))
|
|
key = ("home", os.path.abspath(path))
|
|
if os.path.isfile(path) and key not in self.read_files:
|
|
return "error: write denied for '%s', read it first with read_file" % path
|
|
content = str(args.get("content") or "")
|
|
if os.path.isfile(path):
|
|
old, old_size = Text.read_capped(path)
|
|
else:
|
|
old, old_size = None, None
|
|
try:
|
|
parent = os.path.dirname(os.path.abspath(path))
|
|
os.makedirs(parent, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(content)
|
|
self.read_files.add(key)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
audited = self.audit_file("write", os.path.abspath(path), old, content, "", (), old_size, None)
|
|
note = "" if audited is not False else " (audit failed)"
|
|
if old_size is None:
|
|
self.note_diff(path, old, content)
|
|
return "wrote %s%s" % (path, note)
|
|
|
|
def run_edit_file(self, args):
|
|
path = str(args.get("path") or "")
|
|
find = str(args.get("find") or "")
|
|
if self.app.env == "sandbox":
|
|
return self.box_edit(path, find, str(args.get("replace") or ""))
|
|
if not path or not os.path.isfile(path):
|
|
return "error: no such file"
|
|
if not find:
|
|
return "error: empty match"
|
|
if ("home", os.path.abspath(path)) not in self.read_files:
|
|
return "error: edit denied for '%s', read it first with read_file" % path
|
|
try:
|
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
content = handle.read()
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if content.count(find) != 1:
|
|
return "error: match is not unique (%d occurrences)" % content.count(find)
|
|
updated = content.replace(find, str(args.get("replace") or ""))
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(updated)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
audited = self.audit_file("edit", os.path.abspath(path), content, updated)
|
|
note = "" if audited is not False else " (audit failed)"
|
|
self.note_diff(path, content, updated)
|
|
return "edited %s%s" % (path, note)
|
|
|
|
def run_web_search(self, args):
|
|
query = str(args.get("query") or "").strip()
|
|
if not query:
|
|
return "error: empty query"
|
|
params = {"query": query}
|
|
if args.get("images"):
|
|
params["type"] = "images"
|
|
if args.get("content"):
|
|
params["content"] = "true"
|
|
url = Consts.RSEARCH_BASE + "/search?" + urllib.parse.urlencode(params)
|
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
data = json.loads(response.read().decode("utf-8", "replace"))
|
|
except (OSError, ValueError) as exc:
|
|
return "error: search failed: " + Text.short_error(exc)
|
|
lines = []
|
|
for pos, item in enumerate(data.get("results") or [], 1):
|
|
lines.append("%d. %s\n %s\n %s" % (pos, item.get("title"), item.get("url"), (item.get("description") or "")[:400]))
|
|
if item.get("content"):
|
|
lines.append(" content: " + Text.truncate(str(item["content"]), 2000, 500))
|
|
return "\n".join(lines) if lines else "no results"
|
|
|
|
def run_web_fetch(self, args):
|
|
url = str(args.get("url") or "").strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
return "error: url must start with http:// or https://"
|
|
method = str(args.get("method") or "GET").strip().upper()
|
|
if method not in Consts.FETCH_METHODS:
|
|
return "error: method must be one of %s" % ", ".join(Consts.FETCH_METHODS)
|
|
headers = {"User-Agent": "tai/%s" % Consts.VERSION}
|
|
custom = args.get("headers") or {}
|
|
if not isinstance(custom, dict) or any(not isinstance(name, str) or not isinstance(value, str) for name, value in custom.items()):
|
|
return "error: headers must be an object of string names to string values"
|
|
for name, value in custom.items():
|
|
if not re.fullmatch(r"[A-Za-z0-9-]+", name.strip()) or "\n" in value or "\r" in value:
|
|
return "error: invalid header '%s'" % name[:40]
|
|
headers[name.strip()] = value
|
|
data = None
|
|
if args.get("body") is not None:
|
|
if not isinstance(args.get("body"), str):
|
|
return "error: body must be a string"
|
|
data = args.get("body").encode("utf-8")
|
|
try:
|
|
timeout = int(args.get("timeout") or 30)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid timeout"
|
|
timeout = max(1, min(timeout, 120))
|
|
auth_name = str(args.get("auth_secret") or "").strip()
|
|
if auth_name:
|
|
value = self.app.store.load_secret(auth_name, self.active_profile)
|
|
if value is None:
|
|
return "error: unknown secret '%s'" % auth_name
|
|
header = str(args.get("auth_header") or "Authorization").strip() or "Authorization"
|
|
if not re.fullmatch(r"[A-Za-z0-9-]+", header):
|
|
return "error: invalid auth header name"
|
|
prefix = args.get("auth_prefix") if args.get("auth_prefix") is not None else "Bearer "
|
|
prefix = str(prefix)
|
|
if "\n" in prefix or "\r" in prefix:
|
|
return "error: invalid auth prefix"
|
|
host = urllib.parse.urlparse(url).hostname or ""
|
|
if not self.secret_grant("web_fetch", host, [auth_name]):
|
|
return "denied by user"
|
|
headers[header] = prefix + value
|
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
status = getattr(response, "status", 200)
|
|
fields = getattr(response, "headers", None)
|
|
content_type = fields.get("Content-Type", "") if fields is not None else ""
|
|
payload = response.read(200000)
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
detail = exc.read(2000).decode("utf-8", "replace").strip()
|
|
except OSError:
|
|
detail = ""
|
|
if detail:
|
|
return "error: HTTP %d: %s" % (exc.code, Text.truncate(detail, 1000, 300))
|
|
return "error: HTTP %d: %s" % (exc.code, Text.short_error(exc))
|
|
except OSError as exc:
|
|
return "error: fetch failed: " + Text.short_error(exc)
|
|
text = payload.decode("utf-8", "replace")
|
|
if not args.get("raw") and ("html" in content_type or not content_type):
|
|
stripped = re.sub(r"(?s)<script.*?</script>|<style.*?</style>", " ", text)
|
|
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
|
text = re.sub(r"\s+", " ", html.unescape(stripped)).strip()
|
|
text = Text.truncate(text.strip(), 5000, 1000) or "empty page"
|
|
head = "HTTP %d · %s · %s" % (status, content_type.split(";")[0].strip() or "unknown", Text.human_size(len(payload)))
|
|
return "%s\n%s" % (head, text)
|
|
|
|
def run_speak(self, args):
|
|
text = str(args.get("text") or "").strip()
|
|
if not text:
|
|
return "error: empty text"
|
|
voice = str(args.get("voice") or self.app.config.voice)
|
|
try:
|
|
audio = EdgeTts.edge_synthesize(text[:8000], voice)
|
|
except (BackendError, OSError) as exc:
|
|
return "error: speech failed: " + Text.short_error(exc)
|
|
if not audio:
|
|
return "error: speech returned no audio"
|
|
path = os.path.join(self.app.config.audio_dir, "speech-%d.mp3" % int(time.time()))
|
|
try:
|
|
with open(path, "wb") as handle:
|
|
handle.write(audio)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
return "saved %s (%d bytes), played: %s" % (path, len(audio), Audio.play_audio(path))
|
|
|
|
def run_listen(self, args):
|
|
try:
|
|
seconds = max(1, min(30, int(args.get("seconds") or 5)))
|
|
except (TypeError, ValueError):
|
|
seconds = 5
|
|
return Audio.listen_audio(seconds, self.app.config.audio_dir)
|
|
|
|
def run_remember(self, args):
|
|
instruction = str(args.get("instruction") or "").strip()
|
|
if not instruction:
|
|
return "error: empty instruction"
|
|
current = self.app.system_message
|
|
prompt = [
|
|
{"role": "system", "content": Consts.MERGE_SYSTEM},
|
|
{"role": "user", "content": "CURRENT SYSTEM PROMPT:\n" + current + "\n\nMEMORY INSTRUCTION:\n" + instruction},
|
|
]
|
|
spinner = render.Spinner("merging memory")
|
|
spinner.start()
|
|
try:
|
|
merged = self.app.chat.complete(prompt, ephemeral=True)
|
|
except BackendError as exc:
|
|
return "error: memory merge failed: " + Text.short_error(exc)
|
|
finally:
|
|
spinner.stop()
|
|
text = merged["content"].strip()
|
|
text = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", text).strip()
|
|
if len(text) < 20:
|
|
return "error: merged system message too short, rejected"
|
|
if len(text) > Consts.SYSTEM_MAX_CHARS:
|
|
return "error: merged system message exceeds %d chars, rejected" % Consts.SYSTEM_MAX_CHARS
|
|
self.app.update_system(text)
|
|
return "system message updated for profile '%s' (%d to %d chars)" % (self.app.profile, len(current), len(text))
|
|
|
|
def run_recall(self, args):
|
|
query = str(args.get("query") or "").strip()
|
|
if not query:
|
|
return "error: empty query"
|
|
tags = args.get("tags") or []
|
|
if not isinstance(tags, list) or any(not isinstance(item, str) for item in tags):
|
|
return "error: tags must be a list of names"
|
|
rows = self.app.store.search_events(self.active_profile, query, tags=tags)
|
|
if not rows:
|
|
return "no memories matching " + query
|
|
return "\n".join("[%s %s/%s] %s" % (stamp, role, kind, text[:300]) for stamp, role, kind, text in rows)
|
|
|
|
def box_edit(self, path, find, replace):
|
|
if not path:
|
|
return "error: empty path"
|
|
if not find:
|
|
return "error: empty match"
|
|
if ("box", path) not in self.read_files:
|
|
return "error: edit denied for '%s', read it first with read_file" % path
|
|
size, content = self.box_read_raw(path)
|
|
if size is None:
|
|
return content
|
|
if size > 200000:
|
|
return "error: file too large to edit safely (%d bytes)" % size
|
|
if content.count(find) != 1:
|
|
return "error: match is not unique (%d occurrences)" % content.count(find)
|
|
written = self.box_write(path, content.replace(find, replace), old=content, action="edit")
|
|
if written.startswith("wrote "):
|
|
return "edited %s%s" % (path, written[len("wrote " + path):])
|
|
return written
|
|
|
|
def run_load_skill(self, args):
|
|
name = str(args.get("name") or "").strip()
|
|
skill = self.app.skills.get(name)
|
|
if skill is None:
|
|
blueprint = Consts.SKILL_BLUEPRINTS.get(name)
|
|
if blueprint is None:
|
|
known = ", ".join(sorted(self.app.skills)) or "none"
|
|
return "error: unknown skill, known: " + known
|
|
return "building skill '%s' from blueprint, this runs deep research and takes a while:\n%s" % (name, self.run_create_skill({"name": name, "brief": blueprint["brief"], "scope": blueprint["scope"]}))
|
|
parts = [skill["body"].strip(), Skills.skill_provenance_note(skill)]
|
|
extras = Skills.skill_extra_files(skill["root"])
|
|
if extras:
|
|
parts.append("bundled files:\n" + "\n".join(extras))
|
|
return "\n\n".join(parts)
|
|
|
|
def run_terminal_content(self, args):
|
|
try:
|
|
lines = max(10, min(500, int(args.get("lines") or 100)))
|
|
except (TypeError, ValueError):
|
|
lines = 100
|
|
if not shutil.which("tmux"):
|
|
return "tmux not available"
|
|
pane_header = SysInfo.tmux_pane_header()
|
|
header = [pane_header] if pane_header else []
|
|
try:
|
|
done = subprocess.run(["tmux", "capture-pane", "-p", "-S", "-%d" % lines], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "terminal capture failed: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return "terminal capture failed: " + (done.stderr or "").strip()[:200]
|
|
if not done.stdout.strip():
|
|
return "terminal pane is empty"
|
|
return "\n".join(header + [Text.truncate(done.stdout, 6000, 2000)])
|
|
|
|
def run_export_terminal_log(self, args):
|
|
if not os.environ.get("TMUX"):
|
|
return "error: tmux is not active (this tool only works inside a live tmux session)"
|
|
if not shutil.which("tmux"):
|
|
return "error: tmux not available"
|
|
extra_tags = args.get("tags") or []
|
|
if not isinstance(extra_tags, list) or any(not isinstance(item, str) for item in extra_tags):
|
|
return "error: tags must be a list of strings"
|
|
try:
|
|
done = subprocess.run(["tmux", "capture-pane", "-p", "-S", "-"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return "error: tmux capture failed: " + (done.stderr or "").strip()[:200]
|
|
raw = done.stdout
|
|
if not raw.strip():
|
|
return "terminal pane history is empty"
|
|
profile = self.active_profile
|
|
pane_header = SysInfo.tmux_pane_header() or "pane"
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", pane_header).strip("-") or "pane"
|
|
folder = os.path.join(self.app.config.home, "termlogs", profile)
|
|
try:
|
|
os.makedirs(folder, exist_ok=True)
|
|
path = os.path.join(folder, "%s-%s.log" % (slug, stamp))
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(raw)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if self.app.store is None:
|
|
return "captured %s to %s (%d bytes); vault unavailable, not indexed for search" % (pane_header, path, len(raw.encode("utf-8")))
|
|
title = "tmux %s captured %s" % (pane_header, stamp)
|
|
record_id = self.app.store.add_record("termlog", title, raw, ["termlog"] + extra_tags, profile)
|
|
chunks = Text.chunk_lines(Text.collapse_repeated_lines(raw))
|
|
self.app.store.add_chunks("record", record_id, chunks, profile)
|
|
return "captured %s to %s (%d bytes), indexed as %s in %d chunks -- use search (kind chunk) to answer questions about this terminal session" % (pane_header, path, len(raw.encode("utf-8")), record_id, len(chunks))
|
|
|
|
def run_plexer_send(self, args):
|
|
controller = PlexerControllerFactory.detect()
|
|
if controller is None:
|
|
return "error: no plexer session active (neither tai's native wrapper nor tmux)"
|
|
text = str(args.get("text") or "")
|
|
try:
|
|
controller.send_keys(text, enter=bool(args.get("enter")))
|
|
except PlexerError as exc:
|
|
return "error: " + str(exc)
|
|
return "sent %d chars via %s backend%s" % (len(text), controller.kind, " + enter" if args.get("enter") else "")
|
|
|
|
def run_plexer_read(self, args):
|
|
controller = PlexerControllerFactory.detect()
|
|
if controller is None:
|
|
return "error: no plexer session active (neither tai's native wrapper nor tmux)"
|
|
try:
|
|
lines = max(1, min(int(args.get("lines") or 200), 5000))
|
|
except (TypeError, ValueError):
|
|
lines = 200
|
|
try:
|
|
content = controller.read_pane(lines)
|
|
except PlexerError as exc:
|
|
return "error: " + str(exc)
|
|
return Text.truncate(content or "(empty)", 6000, 2000)
|
|
|
|
def run_plexer_type(self, args):
|
|
controller = PlexerControllerFactory.detect()
|
|
if controller is None:
|
|
return "error: no plexer session active (neither tai's native wrapper nor tmux)"
|
|
text = str(args.get("text") or "")
|
|
if not text:
|
|
return "error: empty text"
|
|
try:
|
|
delay = max(0.0, min(float(args.get("delay") or 0.03), 2.0))
|
|
except (TypeError, ValueError):
|
|
delay = 0.03
|
|
try:
|
|
controller.type_text(text, delay=delay, enter=args.get("enter") is not False)
|
|
except PlexerError as exc:
|
|
return "error: " + str(exc)
|
|
return "typed %d chars via %s backend at %.2fs/char" % (len(text), controller.kind, delay)
|
|
|
|
def run_plexer_status(self, args):
|
|
controller = PlexerControllerFactory.detect()
|
|
if controller is None:
|
|
return "no plexer session active (neither tai's native wrapper nor tmux)"
|
|
try:
|
|
data = controller.status()
|
|
except PlexerError as exc:
|
|
return "error: " + str(exc)
|
|
return json.dumps(data)
|
|
|
|
def run_fork(self, args):
|
|
task = str(args.get("task") or "").strip()
|
|
if not task:
|
|
return "error: empty task"
|
|
if self.app.depth >= Consts.FORK_MAX_DEPTH:
|
|
return "error: fork depth limit reached"
|
|
try:
|
|
timeout = max(30, min(3600, int(args.get("timeout") or 600)))
|
|
except (TypeError, ValueError):
|
|
timeout = 600
|
|
asked = str(args.get("profile") or "").strip()
|
|
if asked and not Consts.PROFILE_RE.match(asked):
|
|
return "error: invalid profile name"
|
|
if asked and asked != self.active_profile:
|
|
return "error: cannot fork as another profile"
|
|
profile = self.active_profile
|
|
agent_id = REGISTRY.spawn_agent(task, profile, timeout, self.app.config, self.app.store.seal, self.app.depth + 1, self.app.runner_override)
|
|
return "agent %d started: %s. Collect its result with poll." % (agent_id, task[:80])
|
|
|
|
def run_poll(self, args):
|
|
try:
|
|
agent_id = int(args.get("id") or 0)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid agent id"
|
|
try:
|
|
wait = max(0, min(120, int(args.get("wait") or 0)))
|
|
except (TypeError, ValueError):
|
|
wait = 0
|
|
status, text = REGISTRY.poll_agent(agent_id, wait, self.active_profile)
|
|
if status in ("done", "timeout") and self.app.store is not None:
|
|
with REGISTRY.lock:
|
|
record = dict(REGISTRY.agents.get(agent_id) or {})
|
|
full = record.get("result") or ""
|
|
if len(full) > Consts.SPILL_LIMIT:
|
|
spilled = record.get("spilled")
|
|
if not spilled:
|
|
task = str(record.get("task") or "")[:80]
|
|
spilled = self.app.store.add_record("output", "agent %d: %s" % (agent_id, task), full, ["agent-output"], self.active_profile)
|
|
self.app.store.add_chunks("record", spilled, Text.chunk_lines(full), self.active_profile)
|
|
with REGISTRY.lock:
|
|
if agent_id in REGISTRY.agents:
|
|
REGISTRY.agents[agent_id]["spilled"] = spilled
|
|
return "[%s] %s" % (status, ShellSnap.spilled_view(full, spilled, len(full)))
|
|
return "[%s] %s" % (status, text)
|
|
|
|
def run_sysinfo(self, args):
|
|
names = args.get("checks")
|
|
if names is None:
|
|
return SysInfo.collect_sysinfo()
|
|
if not isinstance(names, list) or not names:
|
|
return "error: checks must be a non-empty list"
|
|
valid = [name for name, _func in sysinfo.SYSINFO_CHECKS]
|
|
unknown = [str(item) for item in names if str(item) not in valid]
|
|
if unknown:
|
|
return "error: unknown checks: %s (valid: %s)" % (", ".join(unknown), ", ".join(valid))
|
|
return SysInfo.collect_sysinfo([str(item) for item in names])
|
|
|
|
def run_create_skill(self, args):
|
|
name = str(args.get("name") or "").strip()
|
|
brief = str(args.get("brief") or "").strip()
|
|
if not name or not Consts.SKILL_NAME_RE.match(name):
|
|
return "error: invalid skill name, use lowercase letters, digits, and hyphens"
|
|
if not brief:
|
|
return "error: empty brief"
|
|
if self.app.depth >= Consts.FORK_MAX_DEPTH:
|
|
return "error: create_skill depth limit reached"
|
|
scope = str(args.get("scope") or "project").strip().lower()
|
|
if scope not in ("project", "home"):
|
|
return "error: scope must be project or home"
|
|
try:
|
|
timeout = max(60, min(3600, int(args.get("timeout") or 1200)))
|
|
except (TypeError, ValueError):
|
|
timeout = 1200
|
|
skill_dir = Skills.skill_scope_dir(scope, self.app.config.home, os.getcwd(), name)
|
|
skill_file = os.path.join(skill_dir, "SKILL.md")
|
|
existed = os.path.isfile(skill_file)
|
|
spinner = render.Spinner("creating skill %s" % name)
|
|
spinner.start()
|
|
try:
|
|
if self.app.runner_override is None:
|
|
result = default_runner(self.app.config, self.app.store.seal, self.app.profile, self.app.depth + 1, Skills.create_skill_prompt(name, brief, skill_dir), timeout, Consts.CREATE_SKILL_STEPS)
|
|
else:
|
|
result = self.app.runner_override(Skills.create_skill_prompt(name, brief, skill_dir), self.app.profile, timeout)
|
|
finally:
|
|
spinner.stop()
|
|
if os.path.isfile(skill_file):
|
|
self.app.skills = Skills.discover_skills(self.app.config.home, os.getcwd())
|
|
self.app.apply_system()
|
|
action = "replaced" if existed else "created"
|
|
return "skill '%s' %s at %s\n%s" % (name, action, skill_file, Text.truncate(result, 2000, 500))
|
|
status = "timeout" if "[time limit reached]" in result else "done"
|
|
return "skill '%s' was not created (%s), worker output:\n%s" % (name, status, Text.truncate(result, 3000, 1000))
|
|
|
|
@staticmethod
|
|
def find_skill_root(base):
|
|
"""A source dir qualifies if it's SKILL.md itself, or has exactly one child that is."""
|
|
if os.path.isfile(os.path.join(base, "SKILL.md")):
|
|
return base
|
|
try:
|
|
entries = sorted(os.listdir(base))
|
|
except OSError:
|
|
return None
|
|
candidates = [entry for entry in entries if os.path.isfile(os.path.join(base, entry, "SKILL.md"))]
|
|
return os.path.join(base, candidates[0]) if len(candidates) == 1 else None
|
|
|
|
def resolve_skill_source(self, source, workdir):
|
|
"""Returns ((path, ephemeral), '') on success, (None, 'error: ...') otherwise.
|
|
|
|
path is a directory containing SKILL.md, or a bare SKILL.md file. ephemeral
|
|
means the path lives under workdir (git clone / zip extraction) and symlinking
|
|
to it would dangle once workdir is cleaned up, so install must copy it.
|
|
"""
|
|
if source.endswith(".git") or source.startswith("git@") or source.startswith("git+"):
|
|
git_url = source[4:] if source.startswith("git+") else source
|
|
if not shutil.which("git"):
|
|
return None, "error: git is not installed, supply a local path or .zip instead"
|
|
clone_dir = os.path.join(workdir, "clone")
|
|
try:
|
|
done = subprocess.run(["git", "clone", "--depth", "1", git_url, clone_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return None, "error: git clone failed: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return None, "error: git clone failed: " + (done.stderr or "").strip()[:300]
|
|
found = self.find_skill_root(clone_dir)
|
|
if found is None:
|
|
return None, "error: no SKILL.md found in " + git_url
|
|
return (found, True), ""
|
|
path = os.path.expanduser(source)
|
|
if not os.path.exists(path):
|
|
return None, "error: no such file or directory: " + source
|
|
if os.path.isfile(path) and path.endswith(".zip"):
|
|
extract_dir = os.path.join(workdir, "zip")
|
|
try:
|
|
with zipfile.ZipFile(path) as archive:
|
|
archive.extractall(extract_dir)
|
|
except (OSError, zipfile.BadZipFile) as exc:
|
|
return None, "error: bad zip file: " + Text.short_error(exc)
|
|
found = self.find_skill_root(extract_dir)
|
|
if found is None:
|
|
return None, "error: no SKILL.md found in " + source
|
|
return (found, True), ""
|
|
if os.path.isfile(path) and path.endswith(".md"):
|
|
return (path, False), ""
|
|
if os.path.isdir(path):
|
|
found = self.find_skill_root(path)
|
|
if found is None:
|
|
return None, "error: no SKILL.md found under " + source
|
|
return (found, False), ""
|
|
return None, "error: source must be a directory, a SKILL.md file, a .zip, or a git URL"
|
|
|
|
def run_install_skill(self, args):
|
|
source = str(args.get("source") or "").strip()
|
|
if not source:
|
|
return "error: empty source"
|
|
scope = str(args.get("scope") or "").strip().lower() or Skills.default_skill_scope(os.getcwd())
|
|
if scope not in ("project", "home"):
|
|
return "error: scope must be project or home"
|
|
mode = str(args.get("mode") or "").strip().lower() or None
|
|
if mode is not None and mode not in ("copy", "symlink"):
|
|
return "error: mode must be copy or symlink"
|
|
force = bool(args.get("force"))
|
|
workdir = tempfile.mkdtemp(prefix="tai-skill-")
|
|
try:
|
|
spinner = render.Spinner("installing skill from %s" % source)
|
|
spinner.start()
|
|
try:
|
|
resolved, failure = self.resolve_skill_source(source, workdir)
|
|
finally:
|
|
spinner.stop()
|
|
if failure:
|
|
return failure
|
|
real_source, ephemeral = resolved
|
|
source_dir = real_source if os.path.isdir(real_source) else None
|
|
skill_file = os.path.join(real_source, "SKILL.md") if source_dir else real_source
|
|
try:
|
|
skill = Skills.parse_skill_file(skill_file)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if skill is None:
|
|
return "error: %s has no valid SKILL.md (needs name, description, and a name of lowercase letters, digits, hyphens)" % source
|
|
name = str(args.get("name") or "").strip() or skill["name"]
|
|
if not Consts.SKILL_NAME_RE.match(name):
|
|
return "error: invalid skill name, use lowercase letters, digits, and hyphens"
|
|
if mode is None:
|
|
mode = "symlink" if (source_dir and not ephemeral and Skills.is_external_skill_root(source_dir)) else "copy"
|
|
elif mode == "symlink" and (ephemeral or source_dir is None):
|
|
return "error: symlink mode needs a persistent source directory, not a git/zip source or a bare SKILL.md file"
|
|
scope_dir = Skills.skill_scope_dir(scope, self.app.config.home, os.getcwd())
|
|
dest = os.path.join(scope_dir, name)
|
|
existed = os.path.islink(dest) or os.path.exists(dest)
|
|
if existed and not force and not self.app.ask_approval("overwrite existing skill '%s' at %s" % (name, dest)):
|
|
return "denied by user"
|
|
if existed:
|
|
if os.path.islink(dest) or os.path.isfile(dest):
|
|
os.remove(dest)
|
|
else:
|
|
shutil.rmtree(dest)
|
|
os.makedirs(scope_dir, exist_ok=True)
|
|
if mode == "symlink":
|
|
os.symlink(os.path.abspath(source_dir), dest)
|
|
elif source_dir is not None:
|
|
shutil.copytree(source_dir, dest)
|
|
else:
|
|
os.makedirs(dest)
|
|
shutil.copy2(real_source, os.path.join(dest, "SKILL.md"))
|
|
Skills.save_skill_install_meta(scope_dir, name, {"source": source, "mode": mode, "scope": scope, "installed_at": datetime.now(timezone.utc).isoformat()})
|
|
if self.app.store is not None:
|
|
try:
|
|
self.app.store.audit_event(self.audit_actor(), "skill-install", dest, "installed skill '%s' from %s (%s, %s scope)" % (name, source, mode, scope), tags=["skill", "install"])
|
|
except (sqlite3.Error, OSError):
|
|
pass
|
|
self.app.skills = Skills.discover_skills(self.app.config.home, os.getcwd())
|
|
self.app.apply_system()
|
|
extras = Skills.skill_extra_files(dest)
|
|
tail = "\nbundled files: %s" % ", ".join(extras) if extras else ""
|
|
replaced = " (replacing the previous install)" if existed else ""
|
|
return "skill '%s' installed (%s, %s scope) at %s from %s%s\n%s%s" % (name, mode, scope, dest, source, replaced, skill["description"], tail)
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
def run_store_secret(self, args):
|
|
name = str(args.get("name") or "").strip()
|
|
value = str(args.get("value") or "")
|
|
if not Consts.SECRET_RE.match(name):
|
|
return "error: invalid secret name, use letters, digits, dot, underscore, hyphen"
|
|
if not value:
|
|
return "error: empty value"
|
|
username = str(args.get("username") or "").strip()[:128]
|
|
host = str(args.get("host") or "").strip()[:255]
|
|
port = 0
|
|
if args.get("port") is not None and str(args.get("port")).strip() != "":
|
|
try:
|
|
port = int(args.get("port"))
|
|
except (TypeError, ValueError):
|
|
return "error: port must be a number"
|
|
if not 1 <= port <= 65535:
|
|
return "error: port must be 1-65535"
|
|
notes = str(args.get("notes") or "").strip()[:2000]
|
|
expires = str(args.get("expires") or "").strip()
|
|
if expires:
|
|
try:
|
|
expires = Scheduler.parse_schedule_at(expires)
|
|
except ValueError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
raw_tags = args.get("tags") or []
|
|
if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags):
|
|
return "error: tags must be a list of names"
|
|
tags = Text.normalize_tags(raw_tags)
|
|
meta = {}
|
|
if username:
|
|
meta["username"] = username
|
|
if host:
|
|
meta["host"] = host
|
|
if port:
|
|
meta["port"] = port
|
|
if notes:
|
|
meta["notes"] = notes
|
|
if expires:
|
|
meta["expires"] = expires
|
|
self.app.store.save_secret(name, value, meta, tags, self.active_profile)
|
|
return "stored secret '%s' (sealed; tags: %s)" % (name, ", ".join(["secret"] + tags))
|
|
|
|
def run_list_secrets(self, args):
|
|
infos = self.app.store.list_secret_infos(self.active_profile)
|
|
if not infos:
|
|
return "secrets: none"
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
lines = []
|
|
for info in infos:
|
|
meta = info["meta"]
|
|
parts = ["- " + info["name"]]
|
|
if info["tags"]:
|
|
parts.append("[%s]" % " ".join(info["tags"]))
|
|
who = ""
|
|
if meta.get("username") and meta.get("host"):
|
|
who = "%s@%s" % (meta["username"], meta["host"])
|
|
elif meta.get("host"):
|
|
who = str(meta["host"])
|
|
elif meta.get("username"):
|
|
who = str(meta["username"])
|
|
if who and meta.get("port"):
|
|
who += ":%s" % meta["port"]
|
|
if who:
|
|
parts.append(who)
|
|
if meta.get("notes"):
|
|
parts.append(str(meta["notes"])[:60])
|
|
if meta.get("expires"):
|
|
parts.append("(expired)" if meta["expires"] <= now else "(expires %s)" % Scheduler.local_display(meta["expires"]))
|
|
lines.append(" ".join(parts))
|
|
return "\n".join(lines)
|
|
|
|
def run_delete_secret(self, args):
|
|
name = str(args.get("name") or "").strip()
|
|
if self.app.store.load_secret(name, self.active_profile) is None:
|
|
return "error: unknown secret '%s'" % name
|
|
if not self.app.ask_approval("delete secret '%s'" % name):
|
|
return "denied by user"
|
|
self.app.store.delete_secret(name, self.active_profile)
|
|
return "deleted secret '%s'" % name
|
|
|
|
def run_schedule(self, args):
|
|
prompt = str(args.get("prompt") or "").strip()
|
|
if not prompt:
|
|
return "error: empty prompt"
|
|
at_raw = args.get("at")
|
|
every_raw = args.get("every")
|
|
if (at_raw is None) == (every_raw is None):
|
|
return "error: pass exactly one of at or every"
|
|
if at_raw is not None:
|
|
try:
|
|
next_run = Scheduler.parse_schedule_at(str(at_raw))
|
|
except ValueError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
every_sec = 0
|
|
else:
|
|
try:
|
|
every_sec = int(every_raw)
|
|
except (TypeError, ValueError):
|
|
return "error: every must be seconds"
|
|
if every_sec < 60:
|
|
return "error: every must be at least 60 seconds"
|
|
next_run = (datetime.now(timezone.utc) + timedelta(seconds=every_sec)).isoformat()
|
|
name = str(args.get("name") or "").strip()[:80]
|
|
try:
|
|
timeout = max(60, min(3600, int(args.get("timeout") or 600)))
|
|
except (TypeError, ValueError):
|
|
timeout = 600
|
|
asked = str(args.get("profile") or "").strip()
|
|
if asked and not Consts.PROFILE_RE.match(asked):
|
|
return "error: invalid profile name"
|
|
if asked and asked != self.active_profile:
|
|
return "error: cannot schedule for another profile"
|
|
profile = self.active_profile
|
|
raw_tags = args.get("tags") or []
|
|
if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags):
|
|
return "error: tags must be a list of names"
|
|
if not self.app.ask_approval("schedule '%s'" % (name or prompt[:60])):
|
|
return "denied by user"
|
|
row_id = self.app.store.add_schedule(name, prompt, profile, every_sec, next_run, timeout, Text.normalize_tags(raw_tags))
|
|
when = "every %s" % Scheduler.format_delay(every_sec) if every_sec else "at %s" % Scheduler.local_display(next_run)
|
|
return "scheduled #%d (%s)" % (row_id, when)
|
|
|
|
def run_unschedule(self, args):
|
|
try:
|
|
row_id = int(args.get("id") or 0)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid schedule id"
|
|
if row_id not in [item["id"] for item in self.app.store.list_schedules(self.active_profile)]:
|
|
return "error: no schedule #%d" % row_id
|
|
if not self.app.ask_approval("delete schedule #%d" % row_id):
|
|
return "denied by user"
|
|
self.app.store.remove_schedule(row_id, self.active_profile)
|
|
return "deleted schedule #%d" % row_id
|
|
|
|
def run_schedules(self, args):
|
|
return Scheduler.format_schedule_lines(self.app.store.list_schedules(self.active_profile), datetime.now(timezone.utc))
|
|
|
|
def record_tag_args(self, args):
|
|
raw_tags = args.get("tags") or []
|
|
if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags):
|
|
return None
|
|
return Text.normalize_tags(raw_tags)
|
|
|
|
def run_record_save(self, args):
|
|
content = str(args.get("content") or "")
|
|
if not content:
|
|
return "error: content is empty"
|
|
title = str(args.get("title") or "").strip()[:200] or "(untitled)"
|
|
kind = str(args.get("kind") or "note").strip().lower() or "note"
|
|
if kind not in Consts.RECORD_KINDS:
|
|
return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(Consts.RECORD_KINDS))
|
|
tags = self.record_tag_args(args)
|
|
if tags is None:
|
|
return "error: tags must be a list of names"
|
|
record_id = self.app.store.add_record(kind, title, content, tags, self.active_profile)
|
|
chunks = Text.chunk_lines(content)
|
|
self.app.store.add_chunks("record", record_id, chunks, self.active_profile)
|
|
saved = "saved %s (%d chars, kind %s, %d chunks, tags: %s)" % (record_id, len(content), kind, len(chunks), ", ".join(self.app.store.item_tags(record_id, self.active_profile)))
|
|
links = sorted(edge["other"] for edge in self.app.store.edges_for(record_id, self.active_profile) if edge["direction"] == "out")
|
|
if links:
|
|
saved += " [linked: %s]" % ", ".join(links)
|
|
return saved
|
|
|
|
def run_create_bot(self, args):
|
|
name = str(args.get("name") or "").strip().lower()
|
|
if not Consts.BOT_RE.match(name):
|
|
return "error: invalid bot name (lowercase letters, digits, - and _)"
|
|
if name == "main":
|
|
return "error: 'main' is the default bot"
|
|
description = str(args.get("description") or "").strip()
|
|
rules = str(args.get("rules") or "").strip()
|
|
behavior = str(args.get("behavior") or "").strip()
|
|
if not description and not rules and not behavior:
|
|
return "error: give at least one of description, rules, or behavior"
|
|
raw_nicks = args.get("nicknames") or []
|
|
if not isinstance(raw_nicks, list) or any(not isinstance(item, str) for item in raw_nicks):
|
|
return "error: nicknames must be a list of names"
|
|
profile = self.active_profile
|
|
taken = {"main"}
|
|
for item in self.app.store.list_bots(profile):
|
|
taken.add(item["name"])
|
|
taken.update(item["nicknames"])
|
|
short = re.split(r"[-_]", name, 1)[0]
|
|
nicks = []
|
|
for cand in list(raw_nicks) + [short]:
|
|
clean = cand.strip().lower()
|
|
if clean and clean != name and Consts.BOT_RE.match(clean) and clean not in nicks:
|
|
nicks.append(clean)
|
|
for cand in list(raw_nicks):
|
|
clean = cand.strip().lower()
|
|
if clean and not Consts.BOT_RE.match(clean):
|
|
return "error: invalid nickname '%s'" % cand.strip()
|
|
if name in taken:
|
|
return "error: bot name '%s' is taken" % name
|
|
clashes = [nick for nick in nicks if nick in taken]
|
|
if clashes:
|
|
return "error: nickname '%s' is taken" % clashes[0]
|
|
parts = []
|
|
if description:
|
|
parts.append(description)
|
|
if rules:
|
|
parts.append("Rules:\n" + rules)
|
|
if behavior:
|
|
parts.append("Behavior:\n" + behavior)
|
|
self.app.store.save_bot_system(profile, name, "\n\n".join(parts))
|
|
bots = self.app.store.load_bots(profile)
|
|
stamp = Text.now_iso()
|
|
bots[name] = {"nicknames": nicks, "created": stamp, "updated": stamp}
|
|
self.app.store.save_bots(profile, bots)
|
|
extra = " (nicknames: %s)" % ", ".join(nicks) if nicks else ""
|
|
return "created bot '%s'%s. Switch with /bot %s or mention @%s." % (name, extra, name, name)
|
|
|
|
def run_install(self, args):
|
|
action = str(args.get("action") or "").strip().lower()
|
|
if action not in ("status", "install", "upgrade", "reinstall", "uninstall"):
|
|
return "error: action must be status, install, upgrade, reinstall, or uninstall"
|
|
targets = args.get("targets") or list(Consts.INSTALL_TARGETS)
|
|
if not isinstance(targets, list) or not targets or any(item not in Consts.INSTALL_TARGETS for item in targets):
|
|
return "error: unknown target, valid: %s" % ", ".join(Consts.INSTALL_TARGETS)
|
|
if action != "status" and not self.app.ask_approval("install %s: %s (vault always kept)" % (action, ", ".join(targets))):
|
|
return "denied by user"
|
|
return Install.install_report(action, targets, self.app.config.home, self.app.store)
|
|
|
|
def run_search(self, args):
|
|
query = str(args.get("query") or "").strip()
|
|
if not query:
|
|
return "error: empty query"
|
|
kinds = args.get("kinds") or ["record", "event", "audit", "chunk"]
|
|
if not isinstance(kinds, list) or not kinds or any(item not in ("record", "event", "audit", "chunk") for item in kinds):
|
|
return "error: kinds must be a list of record, event, audit, chunk"
|
|
try:
|
|
limit = int(args.get("limit") or 10)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid limit"
|
|
expand = bool(args.get("expand"))
|
|
profile = self.active_profile
|
|
hits = self.app.store.fts_search(query, kinds, profile, limit)
|
|
if self.app.store.seal.enabled and "event" in kinds and not any(hit["kind"] == "event" for hit in hits):
|
|
for ts, _role, kind, text in self.app.store.events_like(profile, query, limit):
|
|
hits.append({"kind": "event", "item": "event %s" % ts, "sub": kind, "title": text[:60], "snippet": text[:160], "rank": 99.0})
|
|
hits.sort(key=lambda hit: hit["rank"])
|
|
if not hits:
|
|
return "no matches for '%s'" % query[:80]
|
|
chunk_ids = []
|
|
for hit in hits:
|
|
if hit["kind"] == "chunk":
|
|
try:
|
|
chunk_ids.append(int(str(hit["item"]).split(":", 1)[1]))
|
|
except (IndexError, ValueError):
|
|
pass
|
|
chunk_rows = self.app.store.get_chunks_by_ids(chunk_ids, profile) if chunk_ids else {}
|
|
lines = []
|
|
for hit in hits:
|
|
if hit["kind"] == "record":
|
|
lines.append("[record/%s] %s :: %s -- %s" % (hit["sub"], hit["item"], hit["title"][:80], hit["snippet"]))
|
|
if expand:
|
|
neighbors = []
|
|
for edge in self.app.store.edges_for(hit["item"], profile):
|
|
if len(neighbors) < 3:
|
|
neighbors.append("%s (%s)" % (self.app.store.node_title(edge["other"], profile)[:60], edge["relation"]))
|
|
if neighbors:
|
|
lines.append(" linked: %s" % "; ".join(neighbors))
|
|
elif hit["kind"] == "event":
|
|
lines.append("[event/%s] #%s %s -- %s" % (hit["sub"], hit["item"], hit["title"][:60], hit["snippet"]))
|
|
elif hit["kind"] == "chunk":
|
|
try:
|
|
chunk_id = int(str(hit["item"]).split(":", 1)[1])
|
|
except (IndexError, ValueError):
|
|
chunk_id = None
|
|
chunk = chunk_rows.get(chunk_id)
|
|
if chunk is None:
|
|
lines.append("[chunk/%s] %s -- %s" % (hit["sub"], hit["title"][:80], hit["snippet"]))
|
|
else:
|
|
preview = chunk["text"][:500] + ("..." if len(chunk["text"]) > 500 else "")
|
|
lines.append("[chunk/%s] %s#%d (parent %s, read full with record_read) -- %s" % (chunk["parent_kind"], chunk["parent_id"][:60], chunk["idx"], chunk["parent_id"], preview))
|
|
else:
|
|
lines.append("[audit/%s] #%s %s -- %s" % (hit["sub"], str(hit["item"]).split(":", 1)[1], hit["title"][:80], hit["snippet"]))
|
|
return "\n".join(lines)
|
|
|
|
def run_tags(self, args):
|
|
prefix = str(args.get("prefix") or "").strip()
|
|
try:
|
|
limit = int(args.get("limit") or 50)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid limit"
|
|
counts = self.app.store.tag_counts(prefix, limit, self.active_profile)
|
|
if not counts:
|
|
return "no tags yet"
|
|
lines = ["%s (%d)" % (tag, count) for tag, count in counts]
|
|
return "%d tags:\n%s" % (len(counts), "\n".join(lines))
|
|
|
|
def run_record_read(self, args):
|
|
record_id = str(args.get("id") or "").strip()
|
|
try:
|
|
offset = max(0, int(args.get("offset") or 0))
|
|
except (TypeError, ValueError):
|
|
return "error: invalid offset"
|
|
try:
|
|
limit = int(args.get("limit") or 4000)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid limit"
|
|
page = self.app.store.read_record(record_id, offset, max(1, min(limit, 20000)), self.active_profile)
|
|
if page is None:
|
|
return "error: unknown record " + record_id
|
|
head = "%s chars %d-%d of %d :: %s" % (record_id, page["start"], page["end"], page["total"], self.app.store.node_title(record_id, self.active_profile))
|
|
if page["tags"]:
|
|
head += " [%s]" % ", ".join(page["tags"])
|
|
return head + "\n" + page["slice"]
|
|
|
|
def run_record_search(self, args):
|
|
query = str(args.get("query") or "")
|
|
kind = str(args.get("kind") or "").strip().lower() or None
|
|
if kind is not None and kind not in Consts.RECORD_KINDS:
|
|
return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(Consts.RECORD_KINDS))
|
|
tags = self.record_tag_args(args)
|
|
if tags is None:
|
|
return "error: tags must be a list of names"
|
|
try:
|
|
limit = int(args.get("limit") or 8)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid limit"
|
|
found = self.app.store.search_records(query, kind, tags, limit, self.active_profile)
|
|
if not found:
|
|
return "no records match"
|
|
lines = []
|
|
for item in found:
|
|
lines.append("%s [%s] %s (%d chars, %d reads) tags: %s" % (item["id"], item["kind"], item["title"], item["size"], item["reads"], ", ".join(item["tags"])))
|
|
return "\n".join(lines)
|
|
|
|
def run_record_delete(self, args):
|
|
record_id = str(args.get("id") or "").strip()
|
|
if not record_id:
|
|
return "error: record id is required"
|
|
if not self.app.ask_approval("delete record %s" % record_id):
|
|
return "denied by user"
|
|
if self.app.store.delete_record(record_id, self.active_profile):
|
|
return "deleted record " + record_id
|
|
return "error: unknown record " + record_id
|
|
|
|
def run_graph_link(self, args):
|
|
src = str(args.get("src") or "").strip()
|
|
dst = str(args.get("dst") or "").strip()
|
|
if not src or not dst:
|
|
return "error: src and dst are required"
|
|
try:
|
|
relation = self.app.store.add_edge(src, dst, args.get("relation"), self.active_profile)
|
|
except ValueError as exc:
|
|
return "error: " + str(exc)
|
|
return "linked %s -[%s]-> %s" % (src, relation, dst)
|
|
|
|
def run_delete_file(self, args):
|
|
path = str(args.get("path") or "")
|
|
if not path:
|
|
return "error: empty path"
|
|
if self.app.env == "sandbox":
|
|
return self.box_delete(path)
|
|
if not os.path.isfile(path):
|
|
return "error: no such file"
|
|
if ("home", os.path.abspath(path)) not in self.read_files:
|
|
return "error: delete denied for '%s', read it first with read_file" % path
|
|
if not self.app.ask_approval("delete file %s" % path):
|
|
return "denied by user"
|
|
old, old_size = Text.read_capped(path)
|
|
try:
|
|
os.remove(path)
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
self.read_files.discard(("home", os.path.abspath(path)))
|
|
audited = self.audit_file("delete", os.path.abspath(path), old, None, "", (), old_size, None)
|
|
note = "" if audited is not False else " (audit failed)"
|
|
if old_size is None:
|
|
self.note_diff(path, old, None)
|
|
return "deleted %s%s" % (path, note)
|
|
|
|
def box_delete(self, path):
|
|
engine, failure = self.box_engine()
|
|
if not engine:
|
|
return failure
|
|
if ("box", path) not in self.read_files:
|
|
try:
|
|
probe = Sandbox.box_exec(engine, ["test", "-f", path], timeout=30)
|
|
except (OSError, subprocess.SubprocessError):
|
|
probe = None
|
|
if probe is None or probe.returncode != 0:
|
|
return "error: no such file"
|
|
return "error: delete denied for '%s', read it first with read_file" % path
|
|
old, old_size = self.box_old_image(path)
|
|
if old is None:
|
|
return "error: no such file"
|
|
try:
|
|
done = Sandbox.box_exec(engine, ["rm", "-f", path], timeout=60)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if done.returncode != 0:
|
|
return "error: " + done.stderr.decode("utf-8", "replace")[:200]
|
|
self.read_files.discard(("box", path))
|
|
audited = self.audit_file("delete", "sandbox:" + path, old, None, "", (), old_size, None)
|
|
note = "" if audited is not False else " (audit failed)"
|
|
if old_size is None:
|
|
self.note_diff(path, old, None)
|
|
return "deleted %s%s" % (path, note)
|
|
|
|
def run_audit(self, args):
|
|
path = str(args.get("path") or "").strip() or None
|
|
tag = str(args.get("tag") or "").strip() or None
|
|
query = str(args.get("query") or "").strip() or None
|
|
try:
|
|
limit = int(args.get("limit") or 20)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid limit"
|
|
if query:
|
|
rows = self.app.store.audit_search(query, self.active_profile, limit)
|
|
else:
|
|
rows = self.app.store.audit_history(path, tag, limit, self.active_profile)
|
|
if not rows:
|
|
return "no audit rows match"
|
|
lines = []
|
|
for row in rows:
|
|
old_size = row["old_size"] if row["old_size"] is not None else "-"
|
|
new_size = row["new_size"] if row["new_size"] is not None else "-"
|
|
line = "#%d %s %s %s %s (%s->%s bytes)" % (row["id"], row["ts"][:19], row["actor"], row["action"], row["path"], old_size, new_size)
|
|
if row["message"]:
|
|
line += " :: " + row["message"][:120]
|
|
if row["tags"]:
|
|
line += " [%s]" % row["tags"]
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
def run_restore(self, args):
|
|
try:
|
|
row_id = int(args.get("id") or 0)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid audit id"
|
|
row = self.app.store.audit_get(row_id, self.active_profile)
|
|
if row is None:
|
|
return "error: unknown audit #%d" % row_id
|
|
if row["action"] in ("snapshot", "release"):
|
|
return "error: audit #%d is a %s row, restorable rows are write, edit, delete, shell-snapshot, restore" % (row_id, row["action"])
|
|
if row["new"] is not None:
|
|
target, which, full_size = row["new"], "post", row["new_size"]
|
|
elif row["old"] is not None:
|
|
target, which, full_size = row["old"], "pre", row["old_size"]
|
|
else:
|
|
return "error: audit #%d holds no content" % row_id
|
|
if "[truncated, full size" in target:
|
|
return "error: audit #%d %s-image is truncated (full size %s bytes), cannot restore safely" % (row_id, which, full_size)
|
|
if not self.app.ask_approval("restore %s from audit #%d (%s-image)" % (row["path"], row_id, which)):
|
|
return "denied by user"
|
|
message = "restored from audit #%d (%s-image)" % (row_id, which)
|
|
if row["path"].startswith("sandbox:"):
|
|
return self.box_restore(row["path"][len("sandbox:"):], target, message)
|
|
if os.path.isfile(row["path"]):
|
|
current, capped = Text.read_capped(row["path"])
|
|
else:
|
|
current, capped = None, None
|
|
try:
|
|
parent = os.path.dirname(row["path"])
|
|
if parent:
|
|
os.makedirs(parent, exist_ok=True)
|
|
with open(row["path"], "w", encoding="utf-8") as handle:
|
|
handle.write(target)
|
|
self.read_files.add(("home", os.path.abspath(row["path"])))
|
|
except OSError as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
self.audit_file("restore", row["path"], None, target, message)
|
|
self.app.store.upsert_file_record(row["path"], target, None, self.active_profile)
|
|
if capped is None:
|
|
self.note_diff(row["path"], current, target)
|
|
return "restored %s from audit #%d (%s-image)" % (row["path"], row_id, which)
|
|
|
|
def box_restore(self, path, target, message):
|
|
self.read_files.add(("box", path))
|
|
old, capped = self.box_old_image(path)
|
|
written = self.box_write(path, target, old=None, action="restore", message=message, preview=False)
|
|
if written.startswith("wrote "):
|
|
if capped is None:
|
|
self.note_diff(path, old, target)
|
|
return "restored sandbox:%s (%s)" % (path, message)
|
|
return written
|
|
|
|
def run_release(self, args):
|
|
part = str(args.get("part") or "").strip().lower()
|
|
message = str(args.get("message") or "").strip()
|
|
if part not in ("major", "minor", "patch"):
|
|
return "error: part must be major, minor, or patch"
|
|
if not message:
|
|
return "error: release message is required"
|
|
script_file = os.path.abspath(Consts.TAI_CONSTS)
|
|
try:
|
|
with open(script_file, "r", encoding="utf-8") as handle:
|
|
match = Consts.VERSION_RE.search(handle.read())
|
|
if match is None:
|
|
return "error: no VERSION line in running script"
|
|
current = ".".join(match.groups())
|
|
upcoming = Release.next_version(current, part)
|
|
except (OSError, ValueError) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
if not self.app.ask_approval("release %s (%s): %s" % (upcoming, part, message[:120])):
|
|
return "denied by user"
|
|
try:
|
|
old, new, dest, row_id = Release.do_release(self.app.store, script_file, Release.backups_dir(self.app.config.home), part, message, self.audit_actor())
|
|
except (OSError, ValueError, sqlite3.Error) as exc:
|
|
return "error: " + Text.short_error(exc)
|
|
return "released %s -> %s, backup %s, audit #%d" % (old, new, os.path.basename(dest), row_id)
|
|
|
|
def run_graph_query(self, args):
|
|
node = str(args.get("node") or "").strip()
|
|
parsed = Text.parse_node_id(node)
|
|
if parsed is None or not self.app.store.has_node(*parsed, self.active_profile):
|
|
return "error: unknown node " + node
|
|
try:
|
|
depth = int(args.get("depth") or 2)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid depth"
|
|
try:
|
|
limit = int(args.get("limit") or 30)
|
|
except (TypeError, ValueError):
|
|
return "error: invalid limit"
|
|
found = self.app.store.traverse(node, depth, limit, self.active_profile)
|
|
lines = []
|
|
for item in found:
|
|
if item["depth"] == 0:
|
|
lines.append("%s :: %s" % (item["node"], item["title"]))
|
|
else:
|
|
lines.append("%s%s :: %s" % (" " * item["depth"], item["via"], item["title"]))
|
|
return "\n".join(lines)
|