Files
tai/tai_pkg/tools.py
T
retoor 8df5cf7a45 Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
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.
2026-10-10 19:25:02 +02:00

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)