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.
225 lines
8.8 KiB
Python
225 lines
8.8 KiB
Python
"""Startup, CLI parsing, banner, REPL, and main entry."""
|
||
|
||
import argparse
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import threading
|
||
|
||
from tai_pkg.agent import Agent
|
||
from tai_pkg.agents import REGISTRY
|
||
from tai_pkg.commands import Commands
|
||
from tai_pkg.compat import readline
|
||
from tai_pkg.config import Config
|
||
from tai_pkg.consts import Consts
|
||
from tai_pkg.errors import BackendError
|
||
from tai_pkg.install import Install
|
||
from tai_pkg.models import ModelScoring
|
||
from tai_pkg.plexer import NativePlexerBackend
|
||
from tai_pkg.release import Release
|
||
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.seal import Seal
|
||
from tai_pkg.seal import SealError
|
||
from tai_pkg.seal import SealManager
|
||
from tai_pkg.store import Store
|
||
from tai_pkg.telegram import TelegramBot
|
||
from tai_pkg.text import Text
|
||
|
||
__all__ = ["App"]
|
||
|
||
|
||
class App:
|
||
"""App."""
|
||
|
||
@staticmethod
|
||
def boot(args):
|
||
args.yes = args.yes or args.yolo or args.auto
|
||
config = Config(args)
|
||
passphrase, default_key = SealManager.resolve_passphrase()
|
||
try:
|
||
seal = Seal(config.home, passphrase)
|
||
except SealError as exc:
|
||
if default_key:
|
||
raise
|
||
try:
|
||
old = Seal(config.home, Consts.DEFAULT_PASSPHRASE)
|
||
except SealError:
|
||
raise exc
|
||
try:
|
||
seal = SealManager.rotate_seal(config, old, passphrase)
|
||
except (OSError, sqlite3.Error) as fail:
|
||
raise SealError(str(fail))
|
||
print("seal upgraded from default key to TAI_PASSPHRASE")
|
||
seal.default_key = default_key and seal.enabled
|
||
store = Store(config, seal)
|
||
if store.schema_notes:
|
||
print("vault schema upgraded: %s" % ", ".join(store.schema_notes))
|
||
try:
|
||
backed_up = Release.ensure_self_backup(config, store)
|
||
except (OSError, sqlite3.Error) as exc:
|
||
print("warning: self-backup failed: %s" % Text.short_error(exc))
|
||
backed_up = None
|
||
if backed_up is not None:
|
||
print("self-backup: %s" % os.path.basename(backed_up))
|
||
return Agent(config, store, yolo=args.yolo or args.auto, auto=args.auto)
|
||
|
||
@staticmethod
|
||
def build_parser():
|
||
parser = argparse.ArgumentParser(prog="tai", description="single-file autonomous agent, standard library only")
|
||
parser.add_argument("--profile", default=None)
|
||
parser.add_argument("--yes", action="store_true")
|
||
parser.add_argument("--yolo", action="store_true")
|
||
parser.add_argument("--auto", action="store_true")
|
||
parser.add_argument("--continue", dest="resume", action="store_true")
|
||
parser.add_argument("--env", default=None)
|
||
parser.add_argument("--version", action="store_true")
|
||
parser.add_argument("--telegram", action="store_true")
|
||
parser.add_argument("--scheduler", action="store_true")
|
||
parser.add_argument("--install", action="store_true")
|
||
parser.add_argument("--install-telegram", action="store_true")
|
||
parser.add_argument("--uninstall-telegram", action="store_true")
|
||
parser.add_argument("--plexer-wrap", action="store_true", help="internal: invoked by the bashrc plexer hook, not meant to be run by hand")
|
||
parser.add_argument("prompt", nargs="*")
|
||
return parser
|
||
|
||
@staticmethod
|
||
def banner(agent):
|
||
print(Render.paint("tai %s · profile %s" % (Consts.VERSION, agent.profile), Ansi.BOLD, Ansi.CYAN))
|
||
if agent.yolo:
|
||
print(Render.paint("YOLO MODE: all approvals off", Ansi.BOLD, Ansi.RED))
|
||
if agent.auto:
|
||
print(Render.paint("AUTO MODE: never asks, researches instead", Ansi.BOLD, Ansi.YELLOW))
|
||
seal_state = "off"
|
||
if agent.store.seal.enabled:
|
||
seal_state = "on (default key)" if agent.store.seal.default_key else "on (TAI_PASSPHRASE)"
|
||
print(" seal: %s" % seal_state)
|
||
for label, _chat_url, models_url, key, _model in agent.chat.backends():
|
||
print(" %s: %s" % (label, ModelScoring.probe_backend(models_url, key)))
|
||
if agent.store.load_turn_state(agent.profile, agent.bot).get("open"):
|
||
print(Render.paint("interrupted turn available, restart with --continue to pick it up", Ansi.YELLOW))
|
||
print(Render.paint("type /help for commands", Ansi.DIM))
|
||
|
||
@staticmethod
|
||
def repl(agent):
|
||
if readline is not None:
|
||
try:
|
||
readline.read_history_file(agent.config.history_path)
|
||
except OSError:
|
||
pass
|
||
readline.set_completer(Commands.complete_command)
|
||
readline.parse_and_bind("tab: complete")
|
||
scheduler_stop = Scheduler.start_scheduler(agent.config, agent.store.seal)
|
||
try:
|
||
while True:
|
||
try:
|
||
busy = REGISTRY.running_agents()
|
||
base = agent.profile + ("!" if agent.yolo else "")
|
||
tag = "%s+%d" % (base, busy) if busy else base
|
||
line = input(Render.paint("tai[%s]› " % tag, Ansi.BOLD, Ansi.GREEN))
|
||
except EOFError:
|
||
print()
|
||
break
|
||
except KeyboardInterrupt:
|
||
print()
|
||
continue
|
||
text = line.strip()
|
||
if not text:
|
||
continue
|
||
if text.startswith("/"):
|
||
if not Commands.handle_command(agent, text):
|
||
break
|
||
continue
|
||
try:
|
||
agent.run_turn(text)
|
||
except KeyboardInterrupt:
|
||
print(Render.paint("\ninterrupted", Ansi.YELLOW))
|
||
agent.checkpoint()
|
||
finally:
|
||
scheduler_stop.set()
|
||
if readline is not None:
|
||
try:
|
||
readline.write_history_file(agent.config.history_path)
|
||
except OSError:
|
||
pass
|
||
agent.store.save_session(agent.profile, agent.messages, agent.bot)
|
||
|
||
@staticmethod
|
||
def main(argv=None):
|
||
args = App.build_parser().parse_args(argv)
|
||
if args.install:
|
||
return Install.install_self()
|
||
if args.install_telegram:
|
||
return Install.install_telegram()
|
||
if args.uninstall_telegram:
|
||
return Install.uninstall_telegram()
|
||
if args.version:
|
||
print("tai %s" % Consts.VERSION)
|
||
return 0
|
||
if args.plexer_wrap:
|
||
home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai")
|
||
profile = args.profile or "default"
|
||
backend = NativePlexerBackend(home, profile)
|
||
return backend.run()
|
||
if args.profile is None:
|
||
args.profile = "telegram" if args.telegram else "default"
|
||
if not Consts.PROFILE_RE.match(args.profile):
|
||
print("invalid profile name", file=sys.stderr)
|
||
return 1
|
||
try:
|
||
agent = App.boot(args)
|
||
except KeyboardInterrupt:
|
||
print(Render.paint("\ninterrupted", Ansi.YELLOW))
|
||
return 130
|
||
except SealError as exc:
|
||
print("seal error: %s" % exc, file=sys.stderr)
|
||
return 2
|
||
if args.env is not None:
|
||
if args.env not in ("home", "sandbox"):
|
||
print("use --env home or --env sandbox", file=sys.stderr)
|
||
return 1
|
||
if args.env == "sandbox":
|
||
try:
|
||
Sandbox.ensure_box()
|
||
except BackendError as exc:
|
||
print("sandbox unavailable: %s" % exc, file=sys.stderr)
|
||
return 2
|
||
agent.env = args.env
|
||
if args.scheduler:
|
||
print("scheduler online, press Ctrl-C to stop")
|
||
try:
|
||
Scheduler.run_scheduler_loop(agent.config, agent.store.seal, threading.Event())
|
||
except KeyboardInterrupt:
|
||
print()
|
||
finally:
|
||
agent.store.close()
|
||
return 0
|
||
if args.telegram:
|
||
return TelegramBot.run_telegram_bot(agent)
|
||
if args.prompt:
|
||
try:
|
||
agent.run_turn(" ".join(args.prompt))
|
||
except KeyboardInterrupt:
|
||
print(Render.paint("\ninterrupted", Ansi.YELLOW))
|
||
return 130
|
||
finally:
|
||
try:
|
||
agent.checkpoint()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
agent.store.close()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
if args.resume and not args.telegram and not args.scheduler:
|
||
Agent.resume_turn(agent)
|
||
App.banner(agent)
|
||
try:
|
||
App.repl(agent)
|
||
finally:
|
||
agent.store.close()
|
||
return 0
|