Files
tai/README.md
T
2026-10-12 03:10:25 +02:00

50 KiB

retoor retoor@molodetz.nl

tai

Zero dependencies. A real agent.

tai is an autonomous AI agent written in Python — the entire product lives in the standalone tai.py file (grouped into classes) and imports only the Python standard library. No pip install, no requirements.txt, no build step, no lockfile to rot. It runs as an interactive REPL or a one-shot command, reasons through an OpenAI-compatible backend, acts through 42 tools, keeps per-profile memory in a self-healing SQLite vault, seals that vault at rest, ranks every search with real BM25 (no vector database, no embeddings bill, no GPU), chunks and indexes anything worth remembering for retrieval, and can wall shell/file work off inside a disposable container sandbox.

Nothing here is decorative. Every mechanism below exists because something specific was expensive — context tokens, API calls, attacker surface, disk writes, or your attention — and got engineered out.

Requirements

  • Python 3.6 or newer (yes, really — see below), no third-party packages.
  • Any CPU word size. 32-bit is fine. Very well tested, fuck me.
  • Optional: podman or docker for the sandbox and Telegram voice notes.
  • Optional: tmux for terminal content capture and full-history export.
  • Optional: git only if you install a skill straight from a git URL — every other skill/install path is pure stdlib.

Why 3.6, specifically

There is deliberate, load-bearing Python 3.6 backward compatibility baked into tai.py, for a sick situation retoor does not want to talk about. Concretely: subprocess calls stick to universal_newlines plus explicit PIPEs instead of the text/capture_output kwargs (3.7+ only), and datetime.fromisoformat — also 3.7+ — is never called directly; everything routes through a hand-rolled _fromisoformat() backport (native when available, a regex-plus-strptime fallback when not, Z/offset-aware either way). No walrus operator, no match statements, no PEP 604 X | Y type hints, nowhere in the file — not because nobody thought of it, but because every single one of them would quietly break the 3.6 floor. Don't introduce one.

Why 32-bit doesn't matter

The whole thing is pure stdlib Python with zero ctypes, zero hand-packed struct pointer trickery, and zero code that assumes a 64-bit word anywhere. SQLite, subprocess, urllib, socket/ssl for the voice protocol — none of it cares about your CPU's register width. If it runs on your architecture's Python at all, it runs correctly, full stop.

Quick start

./tai.py
./tai.py --profile work
./tai.py --yes
./tai.py --yolo
./tai.py --auto
./tai.py --version
./tai.py what is 2+3, use the shell

Trailing arguments form a one-shot prompt: the agent answers once and exits with code 0. Without arguments, tai starts an interactive session.

REPL commands

Command Effect
/profile [name] Show the current profile or switch to it
/profiles List all profiles, current marked with *
/bots List profile bots, current marked with *
/bot [name] Switch to another bot, history resumes
/env [target] Show or switch execution environment
/skills List loaded skills with provenance (native/copied/symlinked/external)
/skills install ... Install an existing skill by copy or symlink, no scaffolding
/secret Manage sealed secrets (set|list|delete)
/sysinfo Show host environment checks
/schedule Schedule a prompt for later (at|every)
/schedules List scheduled prompts and outcomes
/unschedule Delete a scheduled prompt by id
/records Search saved records, optional query
/record <id> Read one record page by mem id
/graph <node> Show one vault node neighborhood
/tags [prefix] List tags with usage counts
/tools [name] Show core and lazy tools, respecting environment gates
/install ... Install status, install, upgrade, reinstall
/search <query> Ranked search over records, events, audit, and chunks
/audit [path] Show file audit trail
/restore <id> Restore a file from an audit row
/release Bump version, back up, log message
/fork <task> Spawn a background subagent, REPL stays free
/agents List background subagents
/agent <id> Show one subagent result
/agent clear Purge finished subagents
/compact Compress history into a summary
/clear Drop history, keep the system message
/demo Fullscreen TUI framework showcase
/help Show the command overview
/quit Exit

Any other input is sent to the agent.

Installation

./tai.py --install
/install status
/install upgrade scheduler-service

Eleven install targets exist side by side: binary (~/.local/bin/tai.py); a command-not-found hook per shell -- bash-hook (~/.bashrc), zsh-hook (~/.zshrc), fish-hook (~/.config/fish/config.fish, respecting $XDG_CONFIG_HOME) -- each a guarded block (backed up once to <rc file>.bak-tai before the first write) adding a tai alias/function so the bare tai command resolves to the REPL instead of falling through, plus the shell's own not-found mechanism (command_not_found_handle for bash, command_not_found_handler for zsh, a fish_command_not_found function for fish) so any other unknown shell command is answered by the agent instead; a plexer auto-wrap hook per shell -- plexer-hook (bash), zsh-plexer-hook, fish-plexer-hook -- that transparently re-execs a fresh interactive shell into a managed, scriptable plexer session (tai.py --plexer-wrap; skipped automatically inside tmux, inside another plexer-wrapped shell, or when ~/.tai-no-plexer exists) using whatever that shell can actually express ([/case "$-" for bash and zsh -- byte-identical content, since both are POSIX enough for it; status is-interactive/test/isatty for fish, which has neither); venv (~/.tai/venv, created whenever the venv module exists); scheduler-service and telegram-service (systemd user units that prefer the venv python); and container (the sandbox, whose image carries its own /box/venv). Installing a zsh/fish-specific target on a machine without that shell skips loudly ("zsh-hook: zsh not found, skipped") rather than writing a dangling hook into a config file nothing will ever source; uninstall/reinstall still act on the file regardless, so a hook installed before a shell was later removed still gets cleaned up. /install status (or the install tool) reports exactly which of these exist, with versions and service states. install adds missing pieces, upgrade refreshes in place, reinstall rebuilds artifacts from scratch, uninstall removes them; service changes restart or start units immediately. Data handling is explicit: no action ever touches the vault (memory.db, secrets, schedules, records, backups). ./tai.py --install (the one-shot CLI bootstrap) always installs the bash hook and, in addition, detects $SHELL and installs that shell's matching hook too when it's zsh or fish.

Plexer

A plexer session is a terminal tai can see into and type into, not just read a one-shot command's output from. There are two ways one gets active, and tai never cares which:

  • tmux — if the shell is already inside a tmux pane ($TMUX_PANE set), that pane is the session. tai drives it with plain tmux send-keys/capture-pane/list-panes; nothing is spawned.
  • native wrapper — if nothing already owns the terminal, the plexer-hook/zsh-plexer-hook/fish-plexer-hook installed above re-execs the fresh interactive shell into tai.py --plexer-wrap, which forks the user's real shell onto a pty (NativePlexerBackend) and relays bytes between the real terminal and that pty in both directions, forwarding resize (SIGWINCH), teeing everything to a log file under ~/.tai/plexer/, and serving control commands over a Unix socket (TAI_PLEXER_SOCKET) so a separate tai process — the agent the user is actually talking to — can drive the same session from outside.

Either way, four tools appear only while a session is actually active (plexer_send, plexer_read, plexer_type, plexer_status — hidden otherwise, not just disabled, via TOOL_ENV_GATES): plexer_send types literal keystrokes (optionally pressing Enter), plexer_read reads back the last N lines of real rendered output, plexer_type does the same as send but one character at a time with a delay for a visible typing effect, and plexer_status reports which backend is active and its details (pid/socket/log, or tmux pane id). Which backend answers a given call is autodetected from the environment (TAI_PLEXER_SOCKET vs $TMUX_PANE), never configured by hand.

Inside a natively-wrapped session, Ctrl+A (deliberately not tmux's Ctrl+B, so the two never collide if one is somehow nested in the other) is reserved as tai's own prefix key; Ctrl+A q kills the wrapped shell outright (SIGKILL to its process group — this is a clean kill, not a tmux-style detach-and-reattach, since there is no session persistence to reattach to). Everything else typed passes through untouched.

The auto-wrap checks three conditions, in the shell hook itself, before ever invoking tai.py --plexer-wrap: never wrap if a plexer is already active ($TAI_PLEXER_ACTIVE), never wrap if tmux already is ($TMUX), and never wrap if ~/.tai-no-plexer exists — the hand brake. That file is the one thing to touch if the auto-wrap is ever doing the wrong thing on a given machine; it needs no flag or argument, just existing is enough, and it's rechecked on the Python side too (plexer_should_wrap()) as defense in depth, not trusted to the shell guard alone.

Backends

tai never picks one backend and hopes. ChatClient.complete() builds an ordered list of every backend it can find any evidence of (candidates()), tries each one in a round, and on total failure sleeps on a capped backoff (RETRY_BACKOFF = (2, 5, 10, 20, 30, 60) seconds) and tries the entire list again — forever, by default. retry_seconds (env TAI_RETRY_SECONDS) defaults to 0, which means no give-up deadline at all: a one-shot tai.py "prompt" with every backend down will sit there retrying every backend, every round, until one works or you kill it. This is deliberate, not an oversight: next model, next provider, next everything, until the bitter end.

What's actually in the candidate pool, roughly in the order a fresh round tries them:

  • openrouter — OPENROUTER_BASE, :free-suffixed models, works with or without OPENROUTER_API_KEY for most models but some free-tier models require one; a missing key surfaces as 401: No cookie auth credentials found, not a quieter failure.
  • pollinations — POLLINATIONS_BASE, no key needed, rate-limited per IP (429: Queue full for IP: ...: 1 requests already queued) — on a shared/NAT'd network this can mean genuinely never succeeding, not flakiness, since the queue slot stays permanently occupied by other traffic.
  • ollama — localhost:11434, only if something's actually listening there (3-second probe timeout, so a missing local Ollama costs almost nothing).
  • devplace — DEVPLACE_API_KEY against devplace.net/openai/v1, opt-in.
  • zen — opencode.ai/zen/v1, opt-in via TAI_ZEN=1, spoofs the real opencode CLI's client headers to reach its free tier without the CLI installed. Currently dead: the server now returns 403 FreeTierError: "OpenCode's free tier can only be used from within OpenCode" for every request, confirmed by direct test — it checks for more than headers now. Left in place (off by default) rather than ripped out, in case that check ever loosens again.
  • muse, grok, claude, codex, gemini — each wraps a locally installed CLI tool (shutil.which() or an env override: MUSE_BIN, GROK_BIN, CLAUDE_BIN, CODEX_BIN, GEMINI_BIN) and shells out to it per turn rather than speaking HTTP. grok/claude/codex/gemini are only added to the pool if their binary is actually found; muse is tai's own default model and is always added regardless, so a machine without the muse CLI at all will see muse/muse: muse CLI not found fail every single round — harmless (the retry loop just moves on), but not a sign anything is broken.
  • opencode — same idea, but if there's no local opencode CLI and the sandbox box (see "Sandbox" below) is already running, this is the one candidate that doesn't just get skipped: see "Last-resort box backend" below.

What the startup banner's status column actually means

Every backend prints a one-line health status at startup and in /models. These are not all the same kind of claim, and conflating them was a real bug (fixed in this codebase, not theoretical): codex_health()/gemini_health()/claude_health()/grok_health()/muse_health() used to run nothing but <cli> --version and print "ok, subscription" on any zero exit — which proves the binary runs, and proves nothing about whether it's logged in. A codex binary that was installed once and never authenticated reported ok, subscription right alongside the ones that actually worked.

Now the status line only claims what it actually checked:

Backend What's checked What ok means
claude claude auth status (real JSON, loggedIn field) genuinely logged in
codex codex login status (real exit code) genuinely logged in; a missing login shows as not logged in: Not logged in, not ok
grok, gemini, muse --version only — no CLI exposes a cheaper, read-only login check (checked gemini --help / muse --help, no status/whoami-style command exists for either) cli found (<version>), login unverified — explicitly not a subscription claim, because this check cannot back one up

A CLI being installed and a CLI being logged in are two separate facts. codex's own login status is the only thing that can tell them apart; --version can't, and the status line doesn't pretend otherwise anymore.

Last-resort box backend: installing opencode where it's missing

If there's no local opencode CLI at all, ChatClient.box_opencode_candidates() checks whether the sandbox box (see "Sandbox" below) is already running, and if so, installs the real opencode CLI inside that box — official Node.js tarball from nodejs.org, not apt's nodejs/npm packages, which pull in webpack/babel/jest as transitive dependencies for no reason relevant here (measured: 1.49GB vs. 971MB for the identical end result) — then routes completions through it via box_exec(). The real opencode CLI needs no API key or login at all for its own opencode/* free-tier models (confirmed: opencode auth list reports zero stored credentials even on a machine where it works), unlike spoofing its HTTP headers (see zen above, which the server now blocks).

This only ever activates as a genuine last resort: it's wired into all_candidates(), which complete() only reaches once the first, fast round (ordered_candidates()) has already failed everything — so a normal turn that succeeds through a real backend never touches the box, never pays an install cost, and never even calls container_engine(). It never creates the box itself; that stays the explicit, approval-gated action it already was (ensure_box(), via tai.py --env sandbox or the install tool). A successful reply through this path is labeled opencode (box), not plain opencode, so it's visible exactly when this path fired. The install, when it happens, streams real apt/curl/npm output live rather than running silently.


The context problem, and how tai solves it

Every tool schema you hand an LLM costs real tokens on every single turn, whether or not it gets used, and a model drowning in fifty tool definitions picks worse than one offered the five that actually matter. tai treats this as a first-class engineering problem, not an afterthought, with three independent layers stacked on top of each other:

1. Core vs. lazy. Of the 42 tools, only CORE_TOOLS — shell, read_file, write_file, remember, recall, load_skill, fork, poll — are sent to the model on every turn. The other 34 are lazy: their full JSON schema only enters the request payload when they're judged relevant to what's actually being discussed.

2. Relevance by three independent signals, not a fixed list. select_tools() scans the last 6000 characters of conversation (user, assistant, and tool-result turns — conversation_text()) and pulls in a lazy tool's full schema the moment any one of three things matches:

  • a name token — record_search offers itself up the moment "record" or "search" appears, filtered through LAZY_NAME_STOP (get, set, list, add) so generic verbs don't trigger everything;
  • a trigger tag — every tool carries a hand-picked synonym list in TOOL_TAGS (cron loads schedule, undo loads restore, password or apikey loads store_secret, symlink loads install_skill), so the model never has to know a tool's literal function name to reach for it;
  • a description word (6+ characters) — a last-resort fallback so nothing is ever truly unreachable just because its tags happened to miss the exact phrasing.

Tool results feed this loop too: a spill pointer that mentions record_read loads that tool for the very next step, so the model is never stuck knowing a capability exists but unable to reach it.

3. Environment gates, orthogonal to relevance. TOOL_ENV_GATES is a second filter that runs before relevance is even checked. export_terminal_log is gated on bool(os.environ.get("TMUX")) — outside a live tmux session it is invisible in the system prompt, invisible in select_tools(), invisible in /tools's no-arg listing, full stop, regardless of how insistently you ask for it. The handler re-checks the same condition at call time anyway, so even a stale tool-call from earlier context can't slip through. This is the general mechanism for "this capability is nonsensical outside environment X" — a second axis entirely separate from keyword relevance.

A compact name-plus-one-line-summary catalog of every lazy tool (tool_catalog()) still rides in the system prompt at all times, so nothing is ever hidden — only its verbose, multi-field JSON schema is deferred. /tools shows the core/lazy split live (respecting the same environment gates a real turn would see), /tools <name> shows one tool with its full description and trigger tags.

Net effect: the model sees 8 schemas by default instead of 42, picks up exactly the ones a conversation actually needs, and never has a tool pushed on it that can't possibly apply in the current environment.

Context budget and compaction

Raw history isn't free either. Token count is estimated every turn (estimate_tokens, 4 chars ≈ 1 token) against a CONTEXT_CAP of 32,000. The instant usage crosses 80% of that (COMPACT_RATIO), compact_messages() fires automatically: it keeps the system message, keeps the last 6 turns verbatim (KEEP_TURNS, snapped backward to the nearest user turn so a conversation never resumes mid-answer), and fires everything in between — including a readable digest of which tool calls happened, not just their text — at a dedicated, separate LLM call whose only job is lossy compression (COMPACT_SYSTEM). The summary replaces the gutted middle. /compact runs the exact same path on demand. The system message itself is independently capped at SYSTEM_MAX_CHARS (12,000) so remember can't let it grow without bound either.

Spill-to-vault: the other half of context control

Tool output is the other place context explodes. Any shell command or subagent poll result over SPILL_LIMIT (6000 characters) never reaches the model whole: spill_or_truncate() writes it to the vault as a tagged record instead and hands back a head/tail preview plus a mem:<id> pointer (spilled_view()). The model pages the rest back deliberately with record_read, offset and all, instead of having 40KB of build log forced into every subsequent turn of the conversation. And — see the RAG section below — that spilled record doesn't just sit there as a cold blob: it gets chunked and indexed the instant it's written, so "what was that error again" can be answered by search instead of by re-reading the whole thing.


Keyword storage, tagging, and the knowledge graph

Every record, secret, and schedule in the vault carries normalized tags, and tai is deliberately strict about vocabulary so it doesn't fragment into a thousand near-duplicate labels:

  • tags are lowercased, hyphenated, capped at 32 characters (normalize_tag), and a single item never carries more than 20 raw / effectively 5 auto-detected tags;
  • a small set of genuinely irregular plurals (news, means, series, species, physics) is hard-excluded from singularization (SINGULAR_KEEP); everything else runs through a real little inflection engine (singular_noun): -ies → -y, -ses/-xes/-zes/-ches/-shes → drop "es", trailing -s dropped unless it's -ss/-us/-is/-os;
  • canonical_tag() always prefers whatever form already exists in that profile's vocabulary over whatever form you typed, so "skills" and "skill" collapse to whichever one got there first, instead of silently forking the taxonomy;
  • the tags tool lists every known tag with usage counts — the explicit, correct way to reuse vocabulary instead of inventing synonyms, and the one both humans and the model are told to consult first.

Auto-tagging is not keyword extraction, it's vocabulary matching. auto_tags_for() scans a new record's title-plus-content for words (and adjacent word-pairs, so multi-word tags like api-client still fire) that are already real tags in that profile — up to TAG_AUTO_MAX (5) — and attaches them automatically. A brand-new vocabulary word never gets invented this way; only previously-established meaning spreads to new content, which is what keeps the tag space from drifting into noise over time.

The graph builds itself. Every new record automatically links to the TAG_LINK_MAX (3) most recently updated records sharing a real tag (tag_neighbors(), explicitly excluding the structural record/file tags so it's never self-referential noise) — an edge with relation shares-<tag> lands in the edges table with zero manual curation. graph_link lets you add arbitrary typed edges between any two vault nodes (mem:<hash> records, secret:<name>, sched:<id>), and graph_query walks the neighborhood breadth-first, capped at depth 4 so a query can never explode into the whole vault. search's expand flag appends each record hit's nearest neighbors inline, for free.


Search and RAG — BM25 and chunking, deliberately no vectors

This is the part worth being loud about: tai does retrieval-augmented generation with zero embeddings, zero vector database, and zero extra dependency, on purpose.

One unified lexical index. fts_docs is a single SQLite FTS5 virtual table covering records, events, audit, and chunks at once, kept in sync not by any application-level reindex job but by SQL triggers fired directly on insert/update/delete (setup_fts()). Query terms are tokenized safely server-side — no raw FTS MATCH syntax ever reaches SQLite from user input — stemmed by the Porter tokenizer when available, and ranked with SQLite's own built-in bm25(). search, recall, record_search, and audit all rank through this one index instead of each reinventing matching, falling back to plain LIKE only when a query has no tokens FTS can use.

Chunking, for retrieval-grade granularity, without inventing a second storage system. A document-level FTS hit tells you a 40KB record contains your answer somewhere; it doesn't hand you the answer. chunk_lines() splits any text into bounded, overlapping windows — up to 60 lines or 4000 characters, whichever comes first, with a 3-line overlap carried into the next chunk — and critically never splits mid-line, so a chunk boundary never severs a command from the middle of its own output. Every chunk lands in the chunks table and gets its own fts_docs row (kind='chunk'), ranked by the exact same BM25 engine as everything else — one index, one ranking function, no separate "vector similarity vs. keyword score" fusion problem to solve, because there's only ever one kind of score. collapse_repeated_lines() runs first on anything log-shaped, folding 3+ identical consecutive lines (progress-bar spam, retry loops) into one line plus a count note, so pathological repetition doesn't burn chunk budget on noise.

A chunk hit from search returns the actual chunk text, not just FTS's highlighted snippet — the whole point of chunk-level retrieval is to hand back something directly usable as context, and the parent record id rides along so record_read can pull more surrounding material on demand (hierarchical retrieval: chunk for precision, parent for context, one hop away, never both loaded by default).

Why no vectors. Embeddings mean a model call (cost, latency, an API dependency) or a local model (a real dependency, a real download, real RAM) for every single thing written — directly incompatible with the stdlib-only, zero-install rule this whole project is built on. BM25 over SQLite FTS5 costs nothing to build, nothing to maintain, and handles exactly what this vault actually needs to search well: command output, notes, transcripts, terminal history — content where the literal words (an error string, a flag name, a path) are the signal. This was an explicit, considered tradeoff, not a missing feature.

Chunking is wired into the system, not bolted onto one feature. record_save, the shell/subagent output spill path (spill_output), and terminal log export all chunk through the identical chunk_lines() call — "RAG" here means a property of the vault, not a special case for logs. It is deliberately not wired into add_record's lower-level, highest-frequency caller — upsert_file_record, which fires on every single file write and edit for audit bookkeeping — because exploding the chunk table on every keystroke-adjacent save would be pure waste for content read_file/grep already serves perfectly well. And deleting a record deletes its chunks and their index rows with it (Store.delete_record); there is no orphan path to worry about.

Sealed search stays ranked without ever touching plaintext to disk. Encrypted-at-rest events can't be FTS-indexed in place — SQLite can only full-text-search what it can read as text. Every boot decrypts events into a :memory:-only FTS5 mirror (open_mem_events), capped at the newest MEM_EVENTS_CAP (10,000) rows and synced incrementally as new ones arrive, so sealed vaults get exactly the same BM25 ranking and [marked] snippets as plaintext ones — and nothing decrypted ever becomes a temp file, a spill table, or a persistent index on disk. Alternatives were researched and rejected on purpose, not just not-gotten-to: page-level encryption (SQLCipher) is a non-standard C extension, incompatible with the stdlib-only rule; blind indexes (deterministic HMACs of tokens, à la CipherStash) would persist in the vault file and leak term frequency and access patterns to anyone who steals it; academic searchable-encryption schemes leak access patterns too and are far heavier than this threat model needs. Decrypting into RAM, once, where the key and plaintext already live during normal operation anyway, is the only option that keeps the at-rest file fully opaque and the search fully ranked.

/search deploy           ranked hits across records, events, audit, and chunks
/records deploy          search records for "deploy"
/record mem:9f2c41aa77c3e5d1
/graph secret:db         show what links to the db secret
/tags                    all tags by usage count
/tags de                 tags starting with "de"

Terminal log export

export_terminal_log is only ever offered inside a live tmux session (see the environment-gating section above — this is the tool TOOL_ENV_GATES exists for). When it's available, it runs tmux capture-pane -p -S - — the entire pane scrollback, not just what's currently visible — writes it verbatim to ~/.tai/termlogs/<profile>/<pane>-<timestamp>.log, saves it as a termlog-kind vault record for provenance and full-text paging, and immediately chunks and indexes it exactly like everything else above. From that point on, "what was that error three hundred lines back" is a search call, not a scrollback hunt — the parent record stays for full context, the chunks stay for precision, and the whole thing cost one tmux subprocess call and zero network requests.

Its quieter cousin, get_current_terminal_content, grabs just the visible pane (configurable line count) for a quick glance without writing or indexing anything, the difference between a peek and a capture is deliberate. It also appends recent shell history, takes a target pane to peek at other terminals, and has two siblings: list_terminals for counts and ids of every open terminal, tail_terminals for the last 3 lines of each at once.


Skills

Standard agent skill files (SKILL.md with name plus description frontmatter, optional scripts/, references/, assets/, per the open Agent Skills format) are discovered from four places, in ascending priority so the most tai-specific location always wins a name collision: read-only interop with ~/.claude/skills and ./.agents/skills first (so a skill another tool already dropped next to your project is visible with zero install step), then tai's own ~/.tai/skills, then ./.tai/skills. Descriptions stay in the system prompt catalog at all times; full instructions load only through load_skill, on demand — the same lazy-loading philosophy as tools, applied to skills.

Two independent, complementary ways to get a skill:

  • create_skill authors a brand-new one from a brief: a dedicated deep-research subagent runs sysinfo first so the skill matches the actual machine, then researches with web search and shell until the material is independently verified, then writes SKILL.md plus supporting files — project-scoped by default. Six blueprints ship built in (bot-creator, api-client, web-researcher, pdf-forms, data-wrangler, home-sysadmin); load_skill builds a missing one through this exact same path the first time it's actually needed, so a skill finalizes lazily at the moment of use, not at install time.
  • install_skill adopts a skill that already exists somewhere else instead of building one from scratch — a local directory, a bare SKILL.md file, a .zip, or a git URL (the only path in this entire feature that touches a non-stdlib binary, and it's checked for and refused cleanly if absent). It copies by default, vendoring a private snapshot — except when the source already sits inside one of the recognized external skill folders (.claude/skills, .agents/skills), in which case it symlinks instead, so you get one canonical copy instead of a silently drifting duplicate. Scope (project vs. home) defaults the same way create_skill does, and a sidecar .installs/<name>.json records exactly where a skill came from and how, which /skills and load_skill both surface ([installed copy, project scope, from ...], [native], or [external, not managed by tai: ...]) — every skill in the catalog is honest about its own provenance.

Both share one scope-resolution helper and one path-construction helper under the hood, so "where does a project-scoped vs. home-scoped skill actually live" has exactly one definition in the entire codebase.

The sysinfo tool itself runs os, python, venv, root, container, binaries, cpu, and disk checks in parallel, each with its own timing; /sysinfo prints the identical report in the REPL.


Orchestration

/fork <task> spawns a background subagent with its own context while the REPL stays free (the prompt shows a +N counter). /agents lists workers, /agent <id> shows a result, /agent clear purges finished ones. The model orchestrates through the exact same primitives: the fork tool (task, timeout up to one hour, profile) and the poll tool (id, wait up to two minutes). Workers get WORKER_STEPS (12) steps, a cooperative deadline, no session writes, and no interactive approval prompts — nesting caps at FORK_MAX_DEPTH (2) levels. Timeouts and errors always surface as an explicit status, never silently.

The execution policy is one ordered rule, not a vibe:

  1. Future work is scheduled, never forked and waited on.
  2. Quick, interactive, or memory-changing work runs on the main agent.
  3. Independent, long, or context-heavy work goes to a forked subagent.

Forking buys parallelism and context isolation, not security isolation — workers share the same machine, tools, and approval setting. Sandbox mode is the separate axis that decides where untrusted or destructive commands may actually execute. Workers cannot schedule, cannot prompt for approval, and stop nesting after two levels; scheduled prompts fire as subagents that nobody waits on, with outcomes kept in the vault.

Turns run on a progress budget, not a step counter. A step counts as productive the moment it tries an unseen action, returns an unseen result, or answers the user — and any productive step resets the stall counter, so a turn doing genuinely varied work can run as long as it keeps moving forward. Repeating one action with identical results earns a loop warning at LOOP_NUDGE_AT (3) repeats and a hard stop at LOOP_STOP_AT (5); wider stalls — alternating actions that produce no new information — stop after the patience budget runs out (MAX_STEPS 25 for the main agent, WORKER_STEPS 12 for a subagent, CREATE_SKILL_STEPS 40 for the skill-building worker, which legitimately needs the room); a TOTAL_STEP_CAP of 500 backstops absolutely everything regardless of how the patience math plays out. Every user approval or typed guidance resets the stall counter outright, on the theory that a supervised agent is a safe agent — a human steering is never treated as "no progress."


Execution environment / sandbox

/env shows the execution environment, /env sandbox switches shell and file tools into an isolated tai-box container (podman or docker, no mounts, no shared filesystem), /env home switches back. The image builds automatically on first use from an embedded Containerfile; every pip requirement the agent's own features need (faster-whisper, edge-tts) lives inside that image, while tai.py itself stays dependency-free — the sandbox is where "needs a real package" problems go, never the host process. Sandbox commands need no approval because the container is disposable; this is deliberately a different axis from the --yes/--yolo/--auto approval settings, not a replacement for them.

Profiles and bots

A profile is a complete identity: system message plus session history under ~/.tai/profiles (mode 0600), and its own slice of every single vault table — records, secrets, tags, graph edges, audit rows, schedules, episodic events, all keyed by profile. Switching profiles is total amnesia; the only cross-profile knowledge that exists is the identity list itself (/profiles). Read permissions and secret grants reset on every switch, subagents can't be polled across profiles, and tools flatly refuse to fork, schedule, or restore for another identity. Vaults from before this rule existed migrate automatically — unscoped rows join default.

A bot is a lighter-weight thing inside one profile: just a system message plus its own history, with the whole vault shared. Every profile starts with main; create_bot adds named bots from a description, rules, behavior, and optional nicknames (lowercased, with a short prefix auto-registering). /bot switches roles and resumes exactly where that bot left off; @name routes a single turn to another bot and files the question and answer into both histories, so the bot you're actually talking to stays aware a detour even happened.

Leading @tags also select the backend: @provider pins a provider (muse, opencode, ollama, ...), @model-id pins a model, and tags combine freely (@opencode @coder fix this). A bare tag with no prompt just switches (@coder moves to that bot). Every selection is saved as the default for the current directory in the vault (dir_defaults table) and re-applied on the next boot there; @auto clears the pins. @path anywhere in the text references a file by absolute path for the agent to read. All of it Tab-completes in the REPL and in TUI boxes.

/profile          show current profile
/profile [name]   switch profile, creating it when missing
/profiles         list all profiles
/bots             list bots with nicknames
/bot coder        switch to the coder bot
@coder fix this   one turn via coder, logged in both
@opencode @coder fix this   same turn pinned to opencode

remember merges an instruction into the current profile's system message through the model itself — adding facts, updating behavior, or removing forgotten items while leaving the rest intact — and fires by default on new passwords or behavior changes. recall searches the per-profile episodic log in ~/.tai/memory.db. Credentials never enter memory this way: secrets go through store_secret (or /secret set), and any profile written before that rule existed gets migrated to it automatically on load.


Sealed storage

Storage is sealed by default with a built-in key — this stops casual reads, not a determined attacker, since that key ships in the source. Set TAI_PASSPHRASE for real protection: a home sealed with the default key is re-sealed to your passphrase automatically on first boot, with a notice.

The real key derives from PBKDF2-SHA256 (200,000 rounds) over a random salt in ~/.tai/.seal; values use a per-value nonce with HMAC-SHA256 in encrypt-then-MAC order. SQLite access goes through custom tai_enc/tai_dec functions registered with create_function, so inserts encrypt inline and reads decrypt before matching ever happens — there is no separate encrypt/decrypt pass bolted around the database. Existing plaintext data is sealed automatically on first sealed start. A wrong passphrase refuses to boot at all, exit code 2. Set the variable empty for deliberate plaintext storage.

This construction uses only the standard library and is honest file-theft protection, not audited cryptography — high-value secrets still belong in a dedicated manager.

The vault heals its own schema. Every boot adds any missing table or column automatically and reports exactly what changed (vault schema upgraded: added secrets.meta, ...). The entire schema — tables, columns, primary keys, indexes — is declared once as a plain data structure (SCHEMA / SCHEMA_INDEXES) and reconciled against whatever's actually on disk (ensure_schema()); adding a feature that needs a new column is a one-line diff to that structure, never a hand-written migration script, and SQLite's dynamic typing means historic type drift in a column never blocks a boot either. Vaults from any previous version open with zero manual steps.

Secrets

Passwords, tokens, and secrets live in a sealed secrets table inside the same vault: encrypted at rest, migrated automatically on passphrase rotation, and never revealed by any tool once stored. The model only ever handles names. shell exposes chosen secrets as TAI_SECRET_<NAME> environment variables for exactly one command invocation, web_fetch can send one as an auth header, and every tool result is scrubbed of known secret values (Store.redact) before it reaches context, memory, or display — so a secret can't leak sideways through some unrelated tool echoing it back. Using or deleting a secret asks the user once per session and scope; workers and the Telegram bot are denied outright unless the whole session was started with --yes, --yolo, or --auto, so unattended secret use is always a deliberate, explicit choice, never an accident of automation.

/secret set wifi     store a value typed invisibly, never entering context
/secret list         show names only
/secret delete wifi  remove a value after confirmation

Scheduler

Schedules persist in the same vault with sealed prompts: one-shot appointments (at an ISO datetime, naive means local time) or repeating work (every 60 seconds or more). A background thread ticks every SCHEDULER_INTERVAL (30) seconds in the REPL, the Telegram service, and --scheduler mode alike, and claims due rows atomically so multiple processes running at once (your REPL plus the systemd service, say) can never double-fire the same prompt. Repeats advance past any missed windows instead of backfilling a queue of stale ones; outcomes land right in the row and stay visible through /schedules, /agents, and /agent <id>.

/schedule at 2026-10-08T09:00 water the plants
/schedule every 1h check the inbox
/schedules
/unschedule 2
./tai.py --scheduler

Records and graph

Large or durable text lives in the vault as tagged records, each addressed by a mem:<16 hex> id: research notes, command output, transcripts, terminal captures. record_read pages slices back without ever loading the whole thing, record_search finds by text, kind, or tags (ranked through the same unified FTS5 index everything else uses), and record_delete removes a record — tags, graph edges, and chunks — after confirmation. Records are working memory in cleartext, exactly like session events; actual credentials belong in store_secret, never here.

Audit and time travel

Every file mutation lands in an append-only audit table: tool writes, edits, and deletes with full before/after images, plus pre-execution snapshots of risky shell targets (rm, mv, cp, tee, dd, truncate, shred, and > redirections, globs expanded, best-effort heuristic). Each row carries actor, timestamp, message, true byte sizes, and tags, so history queries time-travel cleanly by path or by tag. Images cap at AUDIT_MAX_CHARS (20,000) with an explicit truncation marker rather than silently growing the vault forever, and restore flatly refuses to write back a truncated image rather than restore partial content and call it done. Every audited path also keeps a file-kind record with its absolute path and latest contents (FILE_RECORD_MAX, 50,000 chars, marked when cut).

/audit /etc/hosts        history of one path
/audit                   latest rows across all paths
/restore 41              restore that row's image after confirmation

Releases and self-backup

The very first thing every boot does — before anything else — is back the running script up to ~/.tai/backups/ as tai-<version>-<utcstamp>-<sha8>.py, skipping the write entirely when the content hash already has a backup on disk, and pruning down to the newest BACKUP_KEEP (10). /release <major|minor|patch> <message> (or the release tool) cuts an actual release: it rewrites the VERSION line atomically (temp file plus rename, never a partial write), snapshots the new script, and logs the message into the audit trail tagged release and v<version>. The rule stays constant regardless of who's cutting the release: patch for fixes with no interface change, minor for backwards-compatible features, major for breaking changes.

Voice

speak synthesizes free neural speech via the Microsoft Edge Read Aloud protocol, implemented with nothing but socket and ssl from the standard library — no key, no package, no third-party TTS SDK. MP3 files land in ~/.tai/audio and play automatically when an OS player exists. listen records and transcribes when a recorder (arecord, sox, ffmpeg) and a transcriber (whisper-cpp, whisper) are both installed, and reports exactly what's missing when they aren't, instead of failing opaquely.

Terminal output

Assistant replies render as formatted markdown on color terminals: aligned tables with left/center/right columns, verbatim fenced code blocks, nested bullet and numbered lists with checkboxes, headings, blockquotes, rules, and inline bold/italic/code/strikethrough/links. Long lines wrap to terminal width without ever breaking a style mid-sequence. Piped output and NO_COLOR fall back to raw markdown, deliberately.

Shell output streams live while a command runs — a rolling 4-line window inside the call box, then an exit line with return code, elapsed time, line count, and byte size. Long lines trim to terminal width without breaking color codes, progress-style output keeps only its latest segment on screen, and the model still receives the full text underneath for real feedback. Workers, pipes, and capture mode stay silent, on purpose.

File changes render as a unified diff right inside the call box: line numbers, green +/red - rows on tinted backgrounds, Python syntax coloring, and ··· separators between hunks, capped at 120 rows with an explicit hidden-line note rather than a wall of scroll. New files show all green, deletes show all red, restores diff against whatever's currently on disk.

TUI framework

The interactive UI toolkit lives in tai.py itself (stdlib-only, no dependencies): a retained, DOM-like panel tree with rows/cols flow, percent/fr/auto sizing, docking, named z-layers, a cuts/chops occlusion compositor, SGR mouse, bubbling events, Tab/Shift+Tab focus with modal trap, tick animations, and live pty sub-terminals behind a reimplemented VT screen. /demo (or ./tai.py --demo) tours every widget; ./tai.py --demo-shot prints one composed demo frame as plain text. ./tai.py --tui runs the fullscreen Telegram-style chat UI (bots sidebar, transcript, input, status bar) with REPL parity; ./tai.py --theme NAME picks a theme (dark, light, nord, gruvbox, ansi, or TAI_THEME). Full reference: tui.md; design background: research/tui-framework-design/conclusions/retained-dom-compositor-tui.md, chat layout, theming.

Configuration

Variable Purpose Default
TAI_HOME State directory ~/.tai
TAI_MODEL Model id, ignored by primary gateway openrouter/free
DEVPLACE_API_KEY Fallback backend credential Empty, fallback disabled
TAI_VOICE Edge voice name en-US-EmmaMultilingualNeural
TAI_PASSPHRASE Personal seal key Unset: built-in key; empty: plaintext
TELEGRAM_BOT_TOKEN Bot token, or set during install Empty

Telegram

./tai.py --install-telegram
./tai.py --uninstall-telegram

Install asks for the bot token up front, verifies it against getMe, stores it in ~/.tai/telegram.env (mode 0600), builds the sandbox container (used for voice transcription), and registers a tai-telegram.service systemd user unit with linger enabled (failures ignored). The bot long-polls, answers text, transcribes voice notes, and understands /new. It runs without --yes, so destructive shell commands are denied outright. Uninstall stops and removes the service and purges the container and image; the token file and data stay put.

Testing

python3 test_tai.py               # full agent/tools/vault/scheduler suite
python3 test_seal.py              # seal/encryption regression suite

# a single test, either suite, standard unittest addressing:
python3 -m unittest test_tai.CreateSkillTests
python3 -m unittest test_tai.CreateSkillTests.test_creates_and_refreshes

python3 tests/<topic>.py        # integration suite: real binary, real LLM calls

Unit tests live at the top level (test_tai.py, test_seal.py); integration tests live in tests/ on the tai_test.py framework and must drive real LLM calls through the real binary. Conventions: AGENTS.md, tests/README.md.

Layout

tai.py         the entire agent in one standalone file: CLI entry + stable
               import surface; agent loop, tools, vault, backends
               (muse/opencode/grok/claude/codex/gemini), search, skills,
               scheduler, telegram, plexer, sandbox, install/release,
               config, rendering — grouped into classes
test_seal.py   seal regression tests
test_tai.py    agent, tools, records, graph, search/RAG, skills, scheduler tests
tai_test.py    integration-test framework (process spawn, pty, cleanup)
tests/         integration tests, one file per topic (see tests/README.md)
tui.md         TUI framework reference (see "TUI framework" above)
research/      archived design research, registry at research/INDEX.md
AGENTS.md      contributor conventions (tests, research rule)