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.
271 lines
13 KiB
Python
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
|