Files
tai/tai_pkg/commands.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

271 lines
13 KiB
Python

"""REPL slash-command completion and dispatch."""
import getpass
import json
import os
from datetime import datetime, timedelta, timezone
from tai_pkg import toolschema
from tai_pkg.agent import Agent
from tai_pkg.agents import REGISTRY
from tai_pkg.consts import Consts
from tai_pkg.errors import BackendError
from tai_pkg.models import ModelScoring
from tai_pkg.render import Ansi
from tai_pkg.render import Render
from tai_pkg.sandbox import Sandbox
from tai_pkg.scheduler import Scheduler
from tai_pkg.skills import Skills
from tai_pkg.sysinfo import SysInfo
from tai_pkg.toolschema import ToolRegistry
__all__ = ["Commands"]
class Commands:
"""Commands."""
@staticmethod
def complete_command(text, state):
options = ["/" + name for name in Consts.COMMANDS if name.startswith(text.lstrip("/"))]
return options[state] if state < len(options) else None
@staticmethod
def handle_command(agent, text):
parts = text[1:].split(None, 1)
name = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
if name in ("quit", "exit"):
return False
if name == "help":
print(Consts.HELP_TEXT)
elif name == "profiles":
names = agent.store.list_profiles()
for item in names:
print("%s %s" % ("*" if item == agent.profile else " ", item))
if not names:
print("(no profiles)")
elif name == "bots":
for item in agent.store.list_bots(agent.profile):
nicks = " aka %s" % ", ".join(item["nicknames"]) if item["nicknames"] else ""
print("%s %s%s" % ("*" if item["name"] == agent.bot else " ", item["name"], nicks))
elif name == "bot":
if not arg:
print(Render.paint("use /bot <name>, see /bots", Ansi.RED))
else:
print(agent.switch_bot(arg))
elif name == "profile":
if not arg:
print("current profile: %s (%d chars)" % (agent.profile, len(agent.system_message)))
elif not Consts.PROFILE_RE.match(arg):
print(Render.paint("invalid profile name", Ansi.RED))
else:
agent.switch_profile(arg)
elif name == "env":
if not arg:
print("current environment: %s" % agent.env)
elif arg not in ("home", "sandbox"):
print(Render.paint("use /env home or /env sandbox", Ansi.RED))
elif arg == "sandbox":
try:
Sandbox.ensure_box()
except BackendError as exc:
print(Render.paint("sandbox unavailable: %s" % exc, Ansi.RED))
else:
agent.env = "sandbox"
print("environment: sandbox (isolated container)")
else:
agent.env = "home"
print("environment: home")
elif name == "skills":
bits = arg.split()
if bits and bits[0] == "install":
if len(bits) < 2:
print(Render.paint("use /skills install <source> [name=x] [scope=project|home] [mode=copy|symlink] [force]", Ansi.RED))
return True
call_args = {"source": bits[1]}
for token in bits[2:]:
if token == "force":
call_args["force"] = True
elif "=" in token:
key, _, value = token.partition("=")
if key in ("name", "scope", "mode"):
call_args[key] = value
print(agent.tools.dispatch("install_skill", json.dumps(call_args)))
return True
agent.skills = Skills.discover_skills(agent.config.home, os.getcwd())
agent.apply_system()
if not agent.skills:
print("(no skills)")
for skill_name in sorted(agent.skills):
skill = agent.skills[skill_name]
print("- %s: %s %s" % (skill_name, skill["description"][:200], Skills.skill_provenance_note(skill)))
wanted = sorted(item for item in Consts.SKILL_BLUEPRINTS if item not in agent.skills)
if wanted:
print("blueprints, built on first load:")
for skill_name in wanted:
print("- %s: %s" % (skill_name, Consts.SKILL_BLUEPRINTS[skill_name]["hint"]))
elif name == "sysinfo":
print(SysInfo.collect_sysinfo())
elif name == "models":
print(ModelScoring.models_report(agent.chat))
elif name == "secret":
sub = arg.split(None, 1)
action = sub[0].lower() if sub else ""
rest = sub[1].strip() if len(sub) > 1 else ""
if action == "set" and rest:
if not Consts.SECRET_RE.match(rest):
print(Render.paint("invalid secret name, use letters, digits, dot, underscore, hyphen", Ansi.RED))
else:
try:
value = getpass.getpass("value for '%s': " % rest)
except (EOFError, KeyboardInterrupt):
print("\naborted")
return True
if not value:
print("empty value, aborted")
else:
agent.store.save_secret(rest, value, None, None, agent.profile)
print("stored secret '%s'" % rest)
elif action in ("list", "") and not rest:
print(agent.tools.dispatch("list_secrets", "{}"))
elif action == "delete" and rest:
if agent.store.load_secret(rest, agent.profile) is None:
print(Render.paint("unknown secret '%s'" % rest, Ansi.RED))
elif agent.ask_approval("delete secret '%s'" % rest, guidance=False):
agent.store.delete_secret(rest, agent.profile)
print("deleted secret '%s'" % rest)
else:
print("aborted")
else:
print(Render.paint("use /secret set <name> or /secret list or /secret delete <name>", Ansi.RED))
elif name == "schedule":
parts = arg.split(None, 2)
if len(parts) != 3 or parts[0] not in ("at", "every"):
print(Render.paint("use /schedule at <ISO datetime> <prompt> or /schedule every <30s|10m|2h|1d> <prompt>", Ansi.RED))
else:
mode, when, prompt = parts
if mode == "at":
try:
next_run = Scheduler.parse_schedule_at(when)
except ValueError as exc:
print(Render.paint("error: %s" % exc, Ansi.RED))
return True
every_sec = 0
else:
every_sec = Scheduler.parse_duration(when)
if every_sec is None or every_sec < 60:
print(Render.paint("error: every must be a duration of at least 60s", Ansi.RED))
return True
next_run = (datetime.now(timezone.utc) + timedelta(seconds=every_sec)).isoformat()
row_id = agent.store.add_schedule("", prompt, agent.profile, every_sec, next_run, 600)
print("scheduled #%d" % row_id)
elif name == "schedules":
print(Scheduler.format_schedule_lines(agent.store.list_schedules(agent.profile), datetime.now(timezone.utc)))
elif name == "unschedule":
try:
row_id = int(arg or "0")
except ValueError:
print(Render.paint("use /unschedule <id>", Ansi.RED))
return True
if agent.store.remove_schedule(row_id, agent.profile):
print("deleted schedule #%d" % row_id)
else:
print(Render.paint("no schedule #%d" % row_id, Ansi.RED))
elif name == "records":
print(agent.tools.dispatch("record_search", json.dumps({"query": arg})))
elif name == "record":
bits = arg.split()
if not bits:
print(Render.paint("use /record <mem:id> [offset]", Ansi.RED))
else:
try:
offset = int(bits[1]) if len(bits) > 1 else 0
except ValueError:
print(Render.paint("offset must be a number", Ansi.RED))
return True
print(agent.tools.dispatch("record_read", json.dumps({"id": bits[0], "offset": offset})))
elif name == "graph":
if not arg:
print(Render.paint("use /graph <mem:id|secret:name|sched:id>", Ansi.RED))
else:
print(agent.tools.dispatch("graph_query", json.dumps({"node": arg})))
elif name == "tools":
if arg:
match = [schema["function"] for schema in toolschema.TOOL_SCHEMAS if schema["function"]["name"] == arg]
if not match:
print(Render.paint("unknown tool '%s'" % arg, Ansi.RED))
return True
print("%s [%s]" % (match[0]["name"], "core" if match[0]["name"] in Consts.CORE_TOOLS else "lazy"))
print(match[0]["description"])
print("trigger tags: %s" % ", ".join(Consts.TOOL_TAGS.get(match[0]["name"], ())))
return True
names = [schema["function"]["name"] for schema in toolschema.TOOL_SCHEMAS if ToolRegistry.tool_env_ok(schema["function"]["name"])]
cores = sorted(tool for tool in names if tool in Consts.CORE_TOOLS)
lazy = sorted(tool for tool in names if tool not in Consts.CORE_TOOLS)
print("core, always loaded:\n %s" % "\n ".join(cores))
print("lazy, loads when named:\n %s" % "\n ".join(lazy))
elif name == "search":
if not arg:
print(Render.paint("use /search <query>", Ansi.RED))
else:
print(agent.tools.dispatch("search", json.dumps({"query": arg})))
elif name == "install":
bits = arg.split()
action = bits[0] if bits else "status"
if action not in ("status", "install", "upgrade", "reinstall", "uninstall"):
print(Render.paint("use /install [status|install|upgrade|reinstall|uninstall] [targets...]", Ansi.RED))
else:
print(agent.tools.dispatch("install", json.dumps({"action": action, "targets": bits[1:] or list(Consts.INSTALL_TARGETS)})))
elif name == "tags":
print(agent.tools.dispatch("tags", json.dumps({"prefix": arg} if arg else {})))
elif name == "audit":
print(agent.tools.dispatch("audit", json.dumps({"path": arg} if arg else {})))
elif name == "restore":
try:
row_id = int(arg or "0")
except ValueError:
print(Render.paint("use /restore <audit id>", Ansi.RED))
return True
print(agent.tools.dispatch("restore", json.dumps({"id": row_id})))
agent.show_diff()
elif name == "release":
bits = arg.split(None, 1)
if len(bits) != 2 or bits[0] not in ("major", "minor", "patch"):
print(Render.paint("use /release <major|minor|patch> <message>", Ansi.RED))
else:
print(agent.tools.dispatch("release", json.dumps({"part": bits[0], "message": bits[1]})))
elif name == "fork":
if not arg:
print(Render.paint("use /fork <task>", Ansi.RED))
else:
agent_id = REGISTRY.spawn_agent(arg, agent.profile, 600, agent.config, agent.store.seal, agent.depth + 1, agent.runner_override)
print("agent %d started, REPL stays free" % agent_id)
elif name == "agents":
records = REGISTRY.list_agents(agent.profile)
if not records:
print("(no agents)")
for record in records:
print("#%d [%s] %ds %s" % (record["id"], record["status"], record["elapsed"], record["task"][:60]))
elif name == "agent":
if arg == "clear":
print("purged %d finished agents" % REGISTRY.clear_agents(agent.profile))
else:
try:
agent_id = int(arg)
except ValueError:
print(Render.paint("use /agent <id> or /agent clear", Ansi.RED))
return True
status, text = REGISTRY.poll_agent(agent_id, 0, agent.profile)
print("[%s]\n%s" % (status, text))
elif name == "compact":
agent.messages, changed = Agent.compact_messages(agent.messages, agent.chat)
agent.apply_system()
print("compacted" if changed else "nothing to compact")
elif name == "clear":
agent.reset_history()
print("history cleared")
else:
print(Render.paint("unknown command, try /help", Ansi.RED))
return True