diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3dbd4f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Conventions + +- Use plain `import` and `from` statements in Python files. +- Use the `.py` extension for Python source and test files. +- Keep source and test files at the repository top level. +- Name test files `test_*.py`. diff --git a/README.md b/README.md index eb61682..ae499e6 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ retoor # tai tai is a single-file autonomous AI agent written in Python. The entire -implementation lives in `tai.py` (about 2000 lines) and uses only the Python +implementation lives in `tai.py` (about 5200 lines) and uses only the Python standard library: no dependencies, no install step, no build system. The agent runs as an interactive REPL or as a one-shot command. It reasons -through an OpenAI-compatible backend, acts through fourteen tools, keeps +through an OpenAI-compatible backend, acts through thirty-six tools, keeps per-profile memory, seals its stored state at rest, and can isolate shell and file operations inside a container sandbox. @@ -22,6 +22,8 @@ file operations inside a container sandbox. ./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 @@ -34,8 +36,25 @@ with code 0. Without arguments, tai starts an interactive session. |-------------------|---------------------------------------------------| | `/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 skill files | +| `/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 ` | Read one record page by mem id | +| `/graph ` | Show one vault node neighborhood | +| `/tags [prefix]` | List tags with usage counts | +| `/tools [name]` | Show core and lazy tools | +| `/install ...` | Install status, install, upgrade, reinstall | +| `/search ` | Ranked search over records, events, audit | +| `/audit [path]` | Show file audit trail | +| `/restore ` | Restore a file from an audit row | +| `/release` | Bump version, back up, log message | | `/fork ` | Spawn a background subagent, REPL stays free | | `/agents` | List background subagents | | `/agent ` | Show one subagent result | @@ -50,11 +69,22 @@ Any other input is sent to the agent. ## Installation ./tai.py --install + /install status + /install upgrade scheduler-service -This copies `tai.py` to `~/.local/bin/tai.py` as an executable and registers -a guarded `command_not_found_handle` block in `~/.bashrc` (backed up once to -`~/.bashrc.bak-tai`, idempotent), so unknown shell commands are answered by -the agent instead of failing. +Six install targets exist side by side: `binary` (`~/.local/bin/tai.py`), +`bash-hook` (a guarded `command_not_found_handle` block in `~/.bashrc`, +backed up once to `~/.bashrc.bak-tai`, so unknown shell commands are +answered by the agent), `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`). `/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). ## Backends @@ -76,11 +106,26 @@ get 12 steps, a cooperative deadline, no session writes, and no interactive approval prompts. Nesting is capped at two levels. Timeouts and errors surface as statuses, never silently. +## Execution policy + +The agent decides who runs each unit of work by one ordered rule: + +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 run. 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. + ## Tools | Tool | Purpose | |------------------------------|------------------------------------------------------| -| `shell` | Run a shell command, output truncated | +| `shell` | Run a shell command, big output spills to a record | | `read_file` | Read a text file, large files truncated | | `write_file` | Write content to a file, creating parent directories | | `edit_file` | Replace one unique exact text match in a file | @@ -93,10 +138,54 @@ surface as statuses, never silently. | `load_skill` | Load a skill file by name | | `get_current_terminal_content` | Capture the current tmux pane with scrollback | | `fork` | Spawn a background subagent | -| `poll` | Collect a background subagent result | +| `poll` | Collect a subagent result, big ones spill | +| `sysinfo` | Inspect the host, parallel checks with timing | +| `create_skill` | Deep-research and write a new skill file | +| `store_secret` | Store a password or token in the sealed vault | +| `list_secrets` | List vault secret names, values never shown | +| `delete_secret` | Delete a vault secret by name | +| `schedule` | Run a prompt later or on an interval | +| `unschedule` | Delete a scheduled prompt by id | +| `schedules` | List scheduled prompts and outcomes | +| `record_save` | Save text as a tagged record, get a mem id | +| `record_read` | Read one page of a record by mem id | +| `record_search` | Search records by text, kind, and tags | +| `record_delete` | Delete a record by mem id, asks first | +| `graph_link` | Link two vault nodes with a relation | +| `graph_query` | Show one vault node neighborhood | +| `delete_file` | Delete a file, pre-image stays audited | +| `audit` | Show the file audit trail for time travel | +| `restore` | Restore a file to an audit row image | +| `release` | Bump version, back up, log the message | +| `tags` | List vault tags with usage counts | +| `install` | Manage installs, status to uninstall | +| `create_bot` | Create a bot: system plus history, shared vault | +| `search` | Ranked full-text search over everything stored | Web search runs on `rsearch.app.molodetz.nl`. Destructive shell commands ask for confirmation unless `--yes` is given; read-only commands run directly. +Every prompt offers `[y]once [Y]always [n]o`: `Y` enables yolo mode for +the session, `--yolo` starts there, and `--auto` adds autonomous research +instead of ever asking. Answering no asks what to do instead: typed +guidance continues the turn while empty input aborts it. Overwriting an +existing file requires reading it first in the same session; new files +are always writable. Every write, edit, delete, and risky shell target +is audited with before/after images for time travel (see below). + +## Lazy tools + +With 30-plus tools, sending every schema on every turn would burn +context and blur tool selection, so tai loads lazily. Eight everyday +tools (`shell`, `read_file`, `write_file`, `remember`, `recall`, +`load_skill`, `fork`, `poll`) are always present; everything else +enters the payload only when the recent conversation names it. Each +tool carries trigger tags with synonyms (`cron` loads `schedule`, +`undo` loads `restore`, `password` loads `store_secret`), so ordinary +wording just works, and tool results feed selection too: a spill +pointer naming `record_read` loads it for the next step. A compact +name-plus-summary catalog stays in the system prompt so no capability +is ever hidden, only its verbose schema. `/tools` shows the split, +`/tools ` shows one tool with its tags. ## Skills @@ -107,6 +196,18 @@ Skills open format) are discovered in `~/.tai/skills/*/` and context; the agent loads full instructions through `load_skill` only when needed. `/skills` lists what is available. +The `create_skill` tool authors new skills on demand: it runs a dedicated +deep-research worker on the prompt (sysinfo first, then web and shell +research until the material is verified against independent sources) and +writes the skill directory, project scope by default. Six blueprints ship +with the agent (`bot-creator`, `api-client`, `web-researcher`, `pdf-forms`, +`data-wrangler`, `home-sysadmin`): `load_skill` builds a missing one on +first use through the same deep-research path, so skills finalize lazily +the moment they are needed. The `sysinfo` tool +reports os, python, venv, root, container, binaries, cpu, and disk in +parallel, each check with its own timing; `/sysinfo` prints the same +report in the REPL. + ## Sandbox `/env` shows the execution environment, `/env sandbox` switches shell and @@ -133,8 +234,15 @@ data stay. ## Profiles and memory -Each profile owns a system message plus session history under -`~/.tai/profiles`, stored with mode 0600. +Each profile is a complete identity: system message plus session history +under `~/.tai/profiles` (mode 0600), and its own slice of every vault +table. Records, secrets, tags, graph edges, audit rows, schedules, and +episodic events are all keyed by profile; switching profiles is total +amnesia. The only cross-profile knowledge is the identity list itself +(`/profiles`). Read permissions and secret grants reset on every switch, +subagents cannot be polled across profiles, and tools refuse to fork, +schedule, or restore for another identity. Databases from before this +rule migrate automatically: unscoped rows join `default`. /profile show current profile /profile [name] switch profile, creating it when missing @@ -145,7 +253,25 @@ message through the model itself: it adds facts, updates behavior, or removes forgotten items while preserving the rest. It fires by default on new passwords and behavior changes. `recall` searches the per-profile episodic log in `~/.tai/memory.db` (SQLite). Context is budgeted at roughly 32k -tokens with automatic compaction at 80 percent. +tokens with automatic compaction at 80 percent. Credentials never enter +memory: secrets go to the vault through `store_secret` (or `/secret +set`), and profiles written before this rule are migrated to it +automatically on load. + +## Bots + +Where a profile is an identity, a bot is a lightweight role inside it: +only a system message plus its own session history, with the whole +vault shared. Each profile starts with `main`; `create_bot` adds named +bots from a description, rules, behavior, and optional nicknames (all +lowercased, the short prefix auto-registers). `/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 in both +histories, so the current bot stays aware of the detour. + + /bots list bots with nicknames + /bot coder switch to the coder bot + @coder fix this one turn via coder, logged in both ## Sealed storage @@ -166,6 +292,153 @@ 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. +## Sealed search + +The seal guards against file theft: an attacker who copies `~/.tai` +learns nothing without the passphrase. Full-text search over sealed +events works without weakening that promise. Each boot decrypts +events into a memory-only FTS5 index (a `:memory:` database holding +the newest 10000 rows, synced incrementally as new rows arrive), +so sealed stores get BM25 ranking and marked snippets exactly like +plaintext ones. Nothing decrypted ever touches disk: no temp files, +no spill tables, no persistent helper index. The key and plaintext +already live in process RAM during operation, so a RAM-only index +adds no new exposure against the file-theft threat model, and +passphrase rotation needs no rebuild since plaintext is unchanged. + +Alternatives were researched and rejected deliberately. Page-level +encryption (SQLCipher) keeps FTS5 working transparently but is a +non-standard C extension, incompatible with the single-file stdlib +rule. Blind indexes (deterministic HMACs of tokens stored next to +the ciphertext, as in CipherStash or IronCore cloaked search) would +persist in the vault file and leak term frequency plus search and +access patterns to anyone stealing it, while losing stemming and +BM25 ranking. Academic searchable-encryption schemes leak access +patterns too and are far heavier than this threat model needs. +Decrypting into RAM is the only option that keeps the at-rest file +fully opaque and the search fully ranked. + +## Secrets + +Passwords, tokens, and secrets live in a sealed `secrets` table inside +the same vault: encrypted at rest, migrated on passphrase rotation, and +never revealed by any tool. The model only handles names. `shell` +exposes chosen secrets as `TAI_SECRET_` variables for one +command, `web_fetch` sends one as an authentication header, and every +result is scrubbed of known values before it reaches context, memory, +or display. Using or deleting secrets asks the user once per session +and scope; workers and the Telegram bot are denied unless started with +`--yes`, `--yolo`, or `--auto`, so unattended secret use is always an +explicit choice. + + /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 30 seconds in the REPL, the Telegram service, and `--scheduler` +mode, claims due rows atomically so parallel processes never double +fire, and runs each prompt as a subagent nobody waits on. Repeats +advance past missed windows instead of backfilling; outcomes land in +the row and stay visible through `/schedules`, `/agents`, and +`/agent `. + + /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 same vault as tagged `records`, +each addressed by a `mem:<16 hex>` id: research notes, command +output, transcripts. Anything over 6000 chars returned by a shell +command or subagent is stored automatically and replaced by a pointer +with its size; `record_read` pages slices back without loading the +whole, `record_search` finds by text, kind, or tags, and +`record_delete` removes after confirmation. Secrets, schedules, and +records all carry normalized tags and join one graph: `graph_link` +connects nodes such as `mem:`, `secret:`, and +`sched:` with a relation, and `graph_query` walks the +neighborhood breadth-first, capped at depth 4 so context never +explodes. Records are working memory in cleartext, like session +events; true credentials belong in `store_secret`. + + /records deploy search records for "deploy" + /record mem:9f2c41aa77c3e5d1 + /graph secret:db show what links to the db secret + +## Tags + +Tag rules are strict so the vocabulary stays small: lowercase singular, +shortest common word for the subject, one canonical tag per subject, at +most 5 per item, and always reuse from the `tags` tool (which lists +every tag with usage counts) instead of inventing synonyms. Writes +merge plurals into a known singular automatically, while irregular +words (`news`, `glass`, `status`, `physics`) are never rewritten; +searches expand singular/plural variants so old splits still match. +Any content word that already exists as a tag attaches itself to a new +record (up to 5, plurals included), and each new record links itself to +the 3 most recent records sharing a tag, so the knowledge graph stays +connected without any manual work. + + /tags all tags by usage count + /tags de tags starting with "de" + +## Search + +One FTS5 index covers records, episodic events, and the audit trail, +kept in sync by triggers and backfilled once for older rows. Queries +are tokenized safely (no raw MATCH syntax reaches SQLite), stemmed by +the porter tokenizer when available, ranked by BM25, and returned +with `[marked]` snippets. `search` queries all three stores at once +with kind filters and optional graph expansion that appends linked +neighbors to each record hit; `recall`, `record_search`, and `audit` +all rank through the same index, falling back to LIKE matching when +a query has no full-text match. Sealed events are no exception: +each boot decrypts them into a memory-only FTS5 index (newest 10000, +synced incrementally as rows arrive, never written to disk), so +sealed stores rank event hits with BM25 and snippets exactly like +plaintext ones while the at-rest seal stays untouched. The recipe +is deliberate: one ranked query plus graph hops keeps context +small and accurate instead of paging through stores. + + /search deploy ranked hits across records, events, audit + +## Audit and time travel + +Every file mutation lands in an append-only `audit` table in the vault: +tool writes, edits, and deletes with before/after images, plus +pre-execution snapshots of 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 by path or +tag. Images cap at 20000 chars with an explicit truncation marker, and +`restore` refuses truncated images rather than writing partial +content. Every audited path also keeps a `file` record with its +absolute path and latest contents (50000 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 first thing every boot does is back the running script up to +`~/.tai/backups/` as `tai---.py`, skipping +when the content hash already has a backup and pruning to the newest +ten. `/release ` (or the `release` tool) +cuts a release: it rewrites the `VERSION` line atomically +(temp-plus-rename), snapshots the new script, and logs the message in +the audit trail tagged `release` and `v`. The rule stays +constant: `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 @@ -175,6 +448,15 @@ exists. `listen` records and transcribes when a recorder (`arecord`, `sox`, `ffmpeg`) and a transcriber (`whisper-cpp`, `whisper`) are installed, and reports exactly what is missing otherwise. +## Terminal output + +Assistant replies render as formatted markdown on color terminals: aligned +tables with left, center, and right columns, verbatim fenced code blocks, +nested bullet and numbered lists with checkboxes, headings, blockquotes, +rules, and inline bold, italic, code, strikethrough, and links. Long lines +wrap to the terminal width without breaking styles. Piped output and +`NO_COLOR` stay raw markdown. + ## Configuration | Variable | Purpose | Default | @@ -195,4 +477,4 @@ reports exactly what is missing otherwise. tai.py the entire agent test_seal.py seal regression tests - test_tai.py skills, telegram, install, and parser tests + test_tai.py agent, tools, records, graph, scheduler tests diff --git a/tai.py b/tai.py index f10140b..09814c5 100755 --- a/tai.py +++ b/tai.py @@ -3,7 +3,9 @@ import argparse import base64 import getpass +import glob import hashlib +import importlib.util import hmac import html import json @@ -11,6 +13,7 @@ import os import random import re import shutil +import shlex import socket import sqlite3 import ssl @@ -24,15 +27,16 @@ import urllib.error import urllib.parse import urllib.request import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone try: import readline except ImportError: readline = None -VERSION = "1.2.0" +VERSION = "1.14.0" WORKER_STEPS = 12 +CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 AGENTS = {} AGENTS_LOCK = threading.Lock() @@ -55,13 +59,15 @@ BASHRC_MARK_END = "# tai command-not-found hook - end" BASHRC_BLOCK = BASHRC_MARK_BEGIN + "\ncommand_not_found_handle() {\n \"$HOME/.local/bin/tai.py\" \"$@\"\n return $?\n}\n" + BASHRC_MARK_END + "\n" BOX_CONTAINERFILE = """FROM python:3.12-slim ENV DEBIAN_FRONTEND=noninteractive PIP_NO_CACHE_DIR=1 -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg espeak-ng && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir faster-whisper edge-tts -RUN python -c "from faster_whisper import WhisperModel; WhisperModel('tiny', device='cpu', compute_type='int8')" +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg espeak-ng python3-venv && rm -rf /var/lib/apt/lists/* +RUN python -m venv /box/venv && /box/venv/bin/pip install --no-cache-dir faster-whisper edge-tts +RUN /box/venv/bin/python -c "from faster_whisper import WhisperModel; WhisperModel('tiny', device='cpu', compute_type='int8')" COPY stt.py /box/stt.py COPY tts.py /box/tts.py CMD ["sleep", "infinity"] """ +BOX_PYTHON = "/box/venv/bin/python" +_BOX_PYTHON_CACHE = {} BOX_STT = """import sys from faster_whisper import WhisperModel data = sys.stdin.buffer.read() @@ -89,7 +95,7 @@ Wants=network-online.target [Service] Type=simple -WorkingDirectory=%h +WorkingDirectory=%%h ExecStart=%s --telegram EnvironmentFile=%s Restart=on-failure @@ -98,12 +104,29 @@ RestartSec=10 [Install] WantedBy=default.target """ +SCHEDULER_UNIT = """[Unit] +Description=tai scheduler +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%%h +ExecStart=%s --scheduler +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target +""" +INSTALL_TARGETS = ("binary", "bash-hook", "venv", "scheduler-service", "telegram-service", "container") CONTEXT_CAP = 32000 COMPACT_RATIO = 0.8 KEEP_TURNS = 6 MAX_STEPS = 25 SYSTEM_MAX_CHARS = 12000 SHELL_TIMEOUT = 120 +SCHEDULER_INTERVAL = 30 HTTP_TIMEOUT = 60 STREAM_TIMEOUT = 300 @@ -113,33 +136,69 @@ EDGE_TRUSTED_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4" EDGE_CHROMIUM = "143.0.3650.75" PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$") +BOT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") +BOT_MENTION_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9_-]{0,31})\s+(.*)$", re.DOTALL) +SECRET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") +LEGACY_PASSWORD_NOTE = "especially passwords (collect and keep every password the user shares), preferences, or standing behavior changes" +VAULT_MEMORY_NOTE = "preferences, or standing behavior changes. Store passwords, tokens, and secrets with store_secret and reference them by name; never write secret values into memory" SAFE_COMMANDS = ("ls", "pwd", "echo", "cat", "head", "tail", "grep", "find", "wc", "sort", "uniq", "diff", "file", "stat", "date", "whoami", "uname", "lsb_release") RECORDERS = (("arecord", ("arecord", "-q", "-d", "{seconds}", "-f", "cd", "-t", "wav", "{path}")), ("rec", ("rec", "-q", "{path}", "trim", "0", "{seconds}")), ("ffmpeg", ("ffmpeg", "-y", "-v", "quiet", "-f", "alsa", "-i", "default", "-t", "{seconds}", "{path}"))) TRANSCRIBERS = ("whisper-cpp", "whisper-cli", "whisper", "faster-whisper") +SYSINFO_TIMEOUT = 10 +SYSINFO_BINARIES = ("tmux", "git", "ffmpeg", "arecord", "sox", "whisper-cpp", "whisper", "systemctl", "podman", "docker") DEFAULT_SYSTEM = ( "You are tai, a professional autonomous assistant. You act with tools, verify results, and report concisely. " "Rules: prefer the smallest change that solves the task; never silently ignore errors; ask when requirements are ambiguous; " "keep answers short and factual. Tools: shell and file tools for the local machine, web_search and web_fetch for the internet, " "speak for voice output, recall to search past session memory. Memory: call remember whenever you learn durable facts, " - "especially passwords (collect and keep every password the user shares), preferences, or standing behavior changes; " - "call it with a forget instruction to drop outdated knowledge. Chain tool calls until the task is done." + "preferences, or standing behavior changes. Store passwords, tokens, and secrets with store_secret and reference them by name; " + "never write secret values into memory. Name and value suffice for secrets; never pester the user for more. Call remember with a forget instruction to drop outdated knowledge. Delegation: do quick, interactive, or memory-changing work yourself; fork background subagents for independent, long, or context-heavy work and collect them with poll; schedule future work instead of waiting on it. " + "Records: save big or durable findings with record_save and tag generously; large tool outputs spill to mem: ids automatically, page them with record_read, connect related nodes with graph_link. " + "Tags: strict rules, lowercase singular only, shortest common word for the subject, one canonical tag per subject, at most 5 per item, always reuse existing tags from the tags tool instead of inventing synonyms. Content words matching known tags attach automatically, and new records link to recent same-tag records. " + "Lazy tools: only everyday tools load by default; name any other tool or topic in your reply text and it becomes callable on your next step. " + "Bots: one identity holds many bots; a bot is only a system message plus its own history, all vault data is shared. @name routes one turn to another bot and files the exchange in both histories; /bot switches, create_bot makes bots. " + "Retrieval: use search first for anything stored: one ranked full-text query across records, events, and audit plus graph expansion beats paging and guessing. Chain tool calls until the task is done." ) MERGE_SYSTEM = ( "You maintain an AI assistant system prompt. You receive the current system prompt and one memory instruction. " "Rewrite the system prompt to incorporate the instruction: add new facts, update changed behavior, or remove forgotten items. " + "Never persist passwords, tokens, or other secret values; if the instruction holds one, note only that a secret exists and must be stored with store_secret. " "Preserve everything unrelated. Keep it organized with short sections. Output ONLY the rewritten system prompt, no explanation, no code fences." ) COMPACT_SYSTEM = ( "You compress assistant session history into a dense handoff summary. Cover: goal, completed work with outcomes, current state, " "key decisions, files touched, errors and fixes, pending next steps. Output only the summary, no preamble." ) +AUTONOMOUS_NOTE = ( + "Autonomous mode: never ask the user anything this session. When requirements are unclear, blocked, or need input, " + "research deeply with web_search, web_fetch, shell, and subagents, then decide and proceed. State assumptions briefly in your reply." +) + + HELP_TEXT = ( "commands:\n" " /profile [name] show current profile or switch to it\n" " /profiles list all profiles, current marked with *\n" + " /bots list bots, current marked with *\n" + " /bot switch bot, history resumes\n" " /env [home|sandbox] show or switch execution environment\n" " /skills list loaded skill files\n" + " /secret manage sealed secrets (set|list|delete)\n" + " /sysinfo show host environment checks\n" + " /schedule at|every schedule a prompt for later\n" + " /schedules list scheduled prompts\n" + " /unschedule delete a scheduled prompt\n" + " /records [query] search saved records\n" + " /record read one record page\n" + " /graph show node neighborhood\n" + " /tags [prefix] list tags with usage counts\n" + " /tools [name] show core and lazy tools\n" + " /install ... install status, install, upgrade, reinstall\n" + " /search ranked search over everything\n" + " /audit [path] show file audit trail\n" + " /restore restore a file from an audit row\n" + " /release part msg bump version, back up, log message\n" " /fork spawn a background subagent, REPL stays free\n" " /agents list background subagents\n" " /agent show one result, or purge finished agents\n" @@ -155,6 +214,8 @@ class Ansi: RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" + UNDERLINE = "\033[4m" + STRIKE = "\033[9m" RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" @@ -186,6 +247,260 @@ def truncate(text, head=1500, tail=500): return text[:head] + "\n...[%d chars truncated]...\n" % skipped + text[-tail:] +SPILL_LIMIT = 6000 +RECORD_KINDS = ("note", "output", "file", "research", "transcript") +AUDIT_MAX_CHARS = 20000 +FILE_RECORD_MAX = 50000 +SHELL_SNAPSHOT_MAX_FILES = 25 +BACKUP_KEEP = 10 +_UNSET = object() + + +def capped_text(text): + if text is None: + return None, None + if len(text) <= AUDIT_MAX_CHARS: + return text, len(text.encode("utf-8")) + return text[:AUDIT_MAX_CHARS] + "\n...[truncated, full size %d bytes]..." % len(text.encode("utf-8")), len(text.encode("utf-8")) + + +def trunc_marker(true_bytes): + return "\n...[truncated, full size %d bytes]..." % true_bytes + + +def read_capped(path, cap=AUDIT_MAX_CHARS): + try: + size = os.path.getsize(path) + except OSError: + return None, None + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read(cap + 1) + except OSError: + return None, None + if len(text) > cap: + return text[:cap], size + return text, None + + +SHELL_SEGMENT = (";", "&&", "||", "|", "&", "(", ")") +SHELL_WRAPPERS = ("sudo", "command", "env", "nice", "nohup", "setsid") + + +def shell_target_paths(command, workdir): + spaced = re.sub(r"([;&|()<>])", r" \1 ", command) + try: + tokens = shlex.split(spaced, posix=True) + except ValueError: + return [] + found = [] + segment = [] + + def flush(): + found.extend(segment_targets(segment)) + + for token in tokens: + if token in SHELL_SEGMENT: + flush() + segment = [] + else: + segment.append(token) + flush() + resolved = [] + for item in found: + if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: + break + base = item if os.path.isabs(item) else os.path.join(workdir, item) + if any(mark in item for mark in ("*", "?", "[")): + try: + hits = sorted(glob.glob(base, recursive=True)) + except (OSError, ValueError): + continue + else: + hits = [base] + for hit in hits: + if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: + break + if os.path.isfile(hit): + resolved.append(os.path.abspath(hit)) + elif os.path.isdir(hit): + for root, _dirs, files in os.walk(hit): + for entry in sorted(files): + if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: + break + full = os.path.join(root, entry) + if os.path.isfile(full): + resolved.append(os.path.abspath(full)) + if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: + break + seen = [] + for item in resolved: + if item not in seen: + seen.append(item) + return seen + + +def segment_targets(tokens): + targets = [] + pos = 0 + while pos < len(tokens): + token = tokens[pos] + if "=" in token and not token.startswith((">", "<", "-", "/")): + pos += 1 + elif token in SHELL_WRAPPERS: + pos += 1 + elif token == "timeout" and pos + 1 < len(tokens) and re.fullmatch(r"[0-9.]+[smhd]?", tokens[pos + 1] or ""): + pos += 2 + else: + break + rest = tokens[pos:] + for index, token in enumerate(rest): + if token in (">", ">>") and index + 1 < len(rest): + cand = rest[index + 1] + if cand and not cand.startswith("&") and not cand.startswith("/dev/"): + targets.append(cand) + if not rest: + return targets + name = os.path.basename(rest[0]) + operands = [item for item in rest[1:] if not item.startswith("-") or item in ("-", "--")] + operands = [item for item in operands if item not in (">", ">>", "<", "--")] + if name == "rm": + targets.extend(operands) + elif name in ("mv", "cp") and operands: + targets.append(operands[-1]) + elif name == "tee": + targets.extend(operands) + elif name == "truncate": + for item in operands: + if re.fullmatch(r"[0-9]+[KMGTPE]?[iB]?", item or ""): + continue + targets.append(item) + elif name == "dd": + for item in operands: + if item.startswith("of="): + targets.append(item[3:]) + elif name == "shred": + targets.extend(operands) + return [item for item in targets if item and item != "-"] + + +def spill_output(store, kind, title, text, tags=(), profile=None): + return store.add_record(kind, title, text, tags, profile if profile is not None else store.profile) + + +def spilled_view(text, record_id, size, head=1500, tail=500): + return text[:head] + "\n...[%d chars spilled to %s, record_read pages the rest]...\n" % (size, record_id) + text[-tail:] + + +def spill_or_truncate(store, kind, title, text, tags=(), profile=None): + if store is None or len(text) <= SPILL_LIMIT: + return truncate(text) + record_id = spill_output(store, kind, title, text, tags, profile) + return spilled_view(text, record_id, len(text)) + + +VERSION_RE = re.compile(r"^VERSION = \"(\d+)\.(\d+)\.(\d+)\"$", re.MULTILINE) + + +def next_version(current, part): + major, minor, patch = (int(item) for item in current.split(".")) + if part == "major": + return "%d.0.0" % (major + 1) + if part == "minor": + return "%d.%d.0" % (major, minor + 1) + if part == "patch": + return "%d.%d.%d" % (major, minor, patch + 1) + raise ValueError("part must be major, minor, or patch") + + +def atomic_write(path, content): + tmp = "%s.tmp-%d" % (path, os.getpid()) + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + + +def release_bump(script_file, part): + with open(script_file, "r", encoding="utf-8") as handle: + text = handle.read() + match = VERSION_RE.search(text) + if match is None: + raise ValueError("no VERSION line in %s" % script_file) + old = ".".join(match.groups()) + new = next_version(old, part) + atomic_write(script_file, text[:match.start()] + "VERSION = \"%s\"" % new + text[match.end():]) + return old, new + + +def backups_dir(home): + return os.path.join(home, "backups") + + +def prune_backups(folder): + try: + names = sorted((entry for entry in os.listdir(folder) if entry.startswith("tai-") and entry.endswith(".py")), key=lambda entry: os.path.getmtime(os.path.join(folder, entry))) + except OSError: + return 0 + pruned = 0 + while len(names) > BACKUP_KEEP: + try: + os.remove(os.path.join(folder, names.pop(0))) + pruned += 1 + except OSError: + break + return pruned + + +def snapshot_self(script_file, folder, version): + with open(script_file, "rb") as handle: + data = handle.read() + digest = hashlib.sha256(data).hexdigest()[:8] + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + name = "tai-%s-%s-%s.py" % (version, stamp, digest) + os.makedirs(folder, exist_ok=True) + dest = os.path.join(folder, name) + with open(dest, "wb") as handle: + handle.write(data) + prune_backups(folder) + return dest, digest + + +def backup_has_digest(folder, digest): + try: + return any(entry.endswith("-%s.py" % digest) for entry in os.listdir(folder)) + except OSError: + return False + + +def ensure_self_backup(config, store, script_file=None, actor="main"): + script_file = script_file or os.path.abspath(__file__) + folder = backups_dir(config.home) + with open(script_file, "rb") as handle: + digest = hashlib.sha256(handle.read()).hexdigest()[:8] + if backup_has_digest(folder, digest): + return None + dest, _digest = snapshot_self(script_file, folder, VERSION) + try: + store.audit_event(actor, "snapshot", script_file, "backed up as %s" % os.path.basename(dest), tags=["snapshot", "v" + VERSION]) + text, override = read_capped(script_file, FILE_RECORD_MAX) + if text is not None: + store.upsert_file_record(script_file, text, override) + except (sqlite3.Error, OSError): + pass + return dest + + +def do_release(store, script_file, folder, part, message, actor="main"): + if not message or not message.strip(): + raise ValueError("release message is required") + old, new = release_bump(script_file, part) + dest, _digest = snapshot_self(script_file, folder, new) + row_id = store.audit_event(actor, "release", script_file, message.strip()[:500], old=old, new=new, tags=["release", "v" + new]) + return old, new, dest, row_id + + def estimate_tokens(text): return max(1, len(text or "") // 4) @@ -221,6 +536,271 @@ class Spinner: self.worker.join(timeout=1) +ANSI_RE = re.compile(r"\033\[[0-9;]*m") +MD_BULLETS = ("•", "◦", "▪") + + +def strip_ansi(text): + return ANSI_RE.sub("", text) + + +def style_text(text, *codes): + return "".join(codes) + text + Ansi.RESET + + +def style_span(text, *codes): + prefix = "".join(codes) + return prefix + text.replace(Ansi.RESET, Ansi.RESET + prefix) + Ansi.RESET + + +def wrap_ansi(text, width, first_prefix="", next_prefix=""): + parts = re.findall(r"\033\[[0-9;]*m|[^\s\033]+|\s+", text) + lines = [] + current = first_prefix + length = len(strip_ansi(first_prefix)) + base = length + active = "" + for part in parts: + if not part: + continue + if part.startswith("\033"): + current += part + if part == Ansi.RESET: + active = "" + else: + active += part + continue + if part.isspace(): + if length < width and length > base: + current += " " + length += 1 + continue + if length + len(part) > width and length > base: + if active: + current += Ansi.RESET + lines.append(current.rstrip()) + current = next_prefix + active + length = len(strip_ansi(next_prefix)) + base = length + current += part + length += len(part) + if active: + current += Ansi.RESET + if current.strip(): + lines.append(current.rstrip()) + return lines + + +def md_inline(text): + stashed = [] + + def stash(value): + stashed.append(value) + return "\x00%d\x00" % (len(stashed) - 1) + + text = re.sub(r"`([^`\n]+)`", lambda match: stash(style_text(match.group(1), Ansi.CYAN)), text) + text = re.sub(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])", lambda match: stash(match.group(1)), text) + text = re.sub(r"!\[([^\]\n]*)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: stash("[image: %s]" % (match.group(1) or match.group(2))), text) + text = re.sub(r"\[([^\]\n]+)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: style_span(md_inline(match.group(1)), Ansi.BLUE, Ansi.UNDERLINE) + style_text(" (%s)" % match.group(2), Ansi.DIM), text) + text = re.sub(r"\*\*([^*\n]+)\*\*", lambda match: style_text(match.group(1), Ansi.BOLD), text) + text = re.sub(r"(?", " \n", text, flags=re.IGNORECASE) + raw = text.split("\n") + out = [] + pos = 0 + total = len(raw) + while pos < total: + line = raw[pos] + stripped = line.strip() + if not stripped: + pos += 1 + continue + fence = re.match(r"^\s*(`{3,}|~{3,})\s*\S*\s*$", line) + if fence: + char = fence.group(1)[0] + pos += 1 + while pos < total: + closer = raw[pos].strip() + if closer and set(closer) == {char} and len(closer) >= 3: + pos += 1 + break + out.append(" " + style_text(raw[pos], Ansi.DIM) if raw[pos].strip() else "") + pos += 1 + out.append("") + continue + if "|" in line and pos + 1 < total and md_is_separator(raw[pos + 1]): + header = md_split_row(line) + aligns = [] + for cell in md_split_row(raw[pos + 1]): + if cell.startswith(":") and cell.endswith(":") and len(cell) > 2: + aligns.append("center") + elif cell.endswith(":"): + aligns.append("right") + else: + aligns.append("left") + pos += 2 + rows = [] + while pos < total and "|" in raw[pos] and raw[pos].strip(): + rows.append(md_split_row(raw[pos])) + pos += 1 + while len(aligns) < max([len(header)] + [len(row) for row in rows]): + aligns.append("left") + out.extend(md_table_block(header, aligns, rows)) + out.append("") + continue + heading = re.match(r"^(#{1,6})\s+(.*\S)\s*$", stripped) + if heading: + level = len(heading.group(1)) + body = md_inline(heading.group(2)) + if level == 1: + out.append(style_span(body, Ansi.BOLD, Ansi.CYAN)) + elif level == 2: + out.append(style_span(body, Ansi.BOLD)) + else: + out.append(style_span(body, Ansi.BOLD, Ansi.DIM)) + out.append("") + pos += 1 + continue + if re.fullmatch(r"\*{3,}|-{3,}|_{3,}", stripped.replace(" ", "")): + out.append(style_text("─" * width, Ansi.DIM)) + out.append("") + pos += 1 + continue + if stripped.startswith(">"): + paras = [] + current = [] + while pos < total and raw[pos].strip().startswith(">"): + inner = re.sub(r"^\s*(?:>\s?)+", "", raw[pos]) + if inner.strip(): + current.append(inner.strip()) + elif current: + paras.append(current) + current = [] + pos += 1 + if current: + paras.append(current) + for index, para in enumerate(paras): + if index: + out.append(style_text("│", Ansi.DIM)) + prefix = style_text("│ ", Ansi.DIM) + for wrapped in wrap_ansi(md_inline(" ".join(para)), width, prefix, prefix): + out.append(wrapped) + out.append("") + continue + bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", line) + numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", line) + if bullet or numbered: + counters = {} + while pos < total: + again_bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", raw[pos]) + again_numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", raw[pos]) + if not again_bullet and not again_numbered: + break + if again_bullet: + level = len(again_bullet.group(1)) // 2 + marker = MD_BULLETS[level % len(MD_BULLETS)] + body = again_bullet.group(2) + check = re.match(r"^\[([ xX])\]\s+(.*\S)\s*$", body) + if check: + marker = "☑" if check.group(1).lower() == "x" else "☐" + body = check.group(2) + else: + level = len(again_numbered.group(1)) // 2 + counters[level] = counters.get(level, 0) + 1 + for deeper in [key for key in counters if key > level]: + del counters[deeper] + marker = "%d." % counters[level] + body = again_numbered.group(2) + pad = " " * level + for wrapped in wrap_ansi(md_inline(body), width, pad + marker + " ", pad + " " * (len(marker) + 1)): + out.append(wrapped) + pos += 1 + out.append("") + continue + para = [] + while pos < total and raw[pos].strip(): + para.append(raw[pos]) + pos += 1 + segments = [] + current = [] + for chunk in para: + current.append(chunk.strip()) + if chunk.endswith(" "): + segments.append(" ".join(current)) + current = [] + if current: + segments.append(" ".join(current)) + for segment in segments: + for wrapped in wrap_ansi(md_inline(segment), width): + out.append(wrapped) + out.append("") + while out and not out[-1]: + out.pop() + return "\n".join(out) + + class SealError(Exception): pass @@ -322,13 +902,28 @@ def resolve_passphrase(): def rotate_seal(config, old_seal, new_passphrase): db = sqlite3.connect(config.db_path) rows = db.execute("SELECT id, text FROM events").fetchall() + tables = [row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()] + secret_cols = [row[1] for row in db.execute("PRAGMA table_info(secrets)").fetchall()] if "secrets" in tables else [] + has_profile = "profile" in secret_cols + secret_rows = db.execute("SELECT profile, name, value FROM secrets").fetchall() if has_profile else [(None, name, value) for name, value in db.execute("SELECT name, value FROM secrets").fetchall()] if "secrets" in tables else [] + meta_rows = db.execute("SELECT profile, name, meta FROM secrets WHERE meta IS NOT NULL").fetchall() if has_profile and "meta" in secret_cols else [(None, name, meta) for name, meta in db.execute("SELECT name, meta FROM secrets WHERE meta IS NOT NULL").fetchall()] if "meta" in secret_cols else [] + planned_rows = db.execute("SELECT id, prompt FROM schedules").fetchall() if "schedules" in tables else [] db.close() texts = {} for row_id, text in rows: texts[row_id] = old_seal.unlock(text) if text.startswith(Seal.PREFIX) else text + secret_texts = {} + for profile, name, value in secret_rows: + secret_texts[(profile, name)] = old_seal.unlock(value) if value.startswith(Seal.PREFIX) else value + secret_metas = {} + for profile, name, meta in meta_rows: + secret_metas[(profile, name)] = old_seal.unlock(meta) if meta.startswith(Seal.PREFIX) else meta + planned_texts = {} + for row_id, prompt in planned_rows: + planned_texts[row_id] = old_seal.unlock(prompt) if prompt.startswith(Seal.PREFIX) else prompt files = {} for entry in os.listdir(config.profiles_dir): - if not entry.endswith((".sys.md", ".session.json")): + if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(config.profiles_dir, entry) with open(path, "r", encoding="utf-8") as handle: @@ -342,6 +937,18 @@ def rotate_seal(config, old_seal, new_passphrase): db = sqlite3.connect(config.db_path) for row_id, plain in texts.items(): db.execute("UPDATE events SET text = ? WHERE id = ?", (fresh.lock(plain), row_id)) + for (profile, name), plain in secret_texts.items(): + if profile is None: + db.execute("UPDATE secrets SET value = ? WHERE name = ?", (fresh.lock(plain), name)) + else: + db.execute("UPDATE secrets SET value = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) + for (profile, name), plain in secret_metas.items(): + if profile is None: + db.execute("UPDATE secrets SET meta = ? WHERE name = ?", (fresh.lock(plain), name)) + else: + db.execute("UPDATE secrets SET meta = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) + for row_id, plain in planned_texts.items(): + db.execute("UPDATE schedules SET prompt = ? WHERE id = ?", (fresh.lock(plain), row_id)) db.commit() db.close() for path, plain in files.items(): @@ -407,12 +1014,52 @@ def discover_skills(home_dir, project_dir): return found +SKILL_BLUEPRINTS = { + "bot-creator": { + "hint": "build a dedicated chat bot from a tai.py variant", + "scope": "project", + "brief": "Deep-research preferred chat-bot features first: transport (Telegram long-poll versus webhook), command routing, per-user allowlists, rate limits, and safe restarts. Also research safe techniques for scripting huge single-file programs: exact-match edits, atomic temp-plus-rename writes, and versioned backups before every reshape. Then copy tai.py to a variant path and reshape the copy into a dedicated bot, editing efficiently and keeping the audit trail intact.", + }, + "api-client": { + "hint": "build a resilient REST/OpenAPI client", + "scope": "project", + "brief": "Deep-research resilient HTTP client design first: retries with backoff, timeouts, pagination, auth header handling, and error classification. Then write a skill that builds small stdlib-only API clients from a base URL and an endpoint list.", + }, + "web-researcher": { + "hint": "deep research with triangulated sources", + "scope": "project", + "brief": "Deep-research professional open-source research methodology first: query expansion, source triangulation, recency checks, and claim grading. Then write a skill that turns a question into a sourced brief with confidence levels.", + }, + "pdf-forms": { + "hint": "fill PDF forms and extract field data", + "scope": "project", + "brief": "Deep-research PDF form handling with freely available tooling first: AcroForm field discovery, filling, flattening, and text extraction. Then write a skill that fills forms and extracts field data from this machine's installed tools.", + }, + "data-wrangler": { + "hint": "reshape CSV, JSON, and SQLite data", + "scope": "project", + "brief": "Deep-research tabular data reshaping first: CSV dialects, JSON normalization, SQLite import ergonomics, and streaming for large files. Then write a skill that reshapes CSV, JSON, and SQLite inputs using stdlib-only scripts.", + }, + "home-sysadmin": { + "hint": "routine Linux host care and triage", + "scope": "home", + "brief": "Deep-research routine single-host Linux care first: disk, memory, service health, log triage, and backup verification. Then write a skill that runs a host checkup and reports findings with suggested fixes.", + }, +} + + def skill_catalog(skills): - if not skills: - return "" - lines = ["", "", "## Available skills"] - for name in sorted(skills): - lines.append("- %s: %s" % (name, skills[name]["description"][:300])) + lines = [] + if skills: + lines += ["", "", "## Available skills"] + for name in sorted(skills): + lines.append("- %s: %s" % (name, skills[name]["description"][:300])) + wanted = sorted(name for name in SKILL_BLUEPRINTS if name not in (skills or {})) + if wanted: + lines += ["", "", "## Buildable skill blueprints (load_skill builds one on demand)"] + for name in wanted: + lines.append("- %s: %s" % (name, SKILL_BLUEPRINTS[name]["hint"])) + return "\n".join(lines) lines.append("Load full instructions with load_skill before using one.") return "\n".join(lines) @@ -465,15 +1112,25 @@ def box_exec(engine, argv, extra=(), input_data=None, timeout=180): return subprocess.run([engine, "exec", *extra, "-i", BOX_NAME, *argv], input=input_data, capture_output=True, timeout=timeout) +def box_python(engine): + if engine not in _BOX_PYTHON_CACHE: + try: + probe = box_exec(engine, ["test", "-x", BOX_PYTHON], timeout=30) + _BOX_PYTHON_CACHE[engine] = BOX_PYTHON if probe.returncode == 0 else "python3" + except (OSError, subprocess.SubprocessError): + _BOX_PYTHON_CACHE[engine] = "python3" + return _BOX_PYTHON_CACHE[engine] + + def box_transcribe(engine, audio, timeout=300): - done = box_exec(engine, ["python3", "/box/stt.py"], input_data=audio, timeout=timeout) + done = box_exec(engine, [box_python(engine), "/box/stt.py"], input_data=audio, timeout=timeout) if done.returncode != 0: raise BackendError("box stt failed: " + done.stderr.decode("utf-8", "replace")[:300]) return done.stdout.decode("utf-8", "replace").strip() def box_speak(engine, text, voice=""): - argv = ["python3", "/box/tts.py"] + ([voice] if voice else []) + argv = [box_python(engine), "/box/tts.py"] + ([voice] if voice else []) done = box_exec(engine, argv, input_data=text.encode("utf-8"), timeout=180) if done.returncode != 0 or not done.stdout: raise BackendError("box tts failed: " + done.stderr.decode("utf-8", "replace")[:300]) @@ -496,28 +1153,189 @@ class Config: os.makedirs(self.audio_dir, exist_ok=True) +def ensure_column(db, table, column, ddl): + names = [row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()] + if column not in names: + db.execute("ALTER TABLE %s ADD COLUMN %s %s" % (table, column, ddl)) + + +def fts_escape(query): + terms = re.findall(r"[a-z0-9]+", (query or "").lower()) + if not terms: + return None + return ['"%s"' % term for term in terms] + + +def fts_match(terms): + return " AND ".join("{title body} : %s" % term for term in terms) + + +MEM_EVENTS_CAP = 10000 + + +def open_mem_events(): + mem = sqlite3.connect(":memory:") + try: + mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") + except sqlite3.Error: + mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile)") + return mem + + +def setup_fts(db): + try: + exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'fts_docs'").fetchone() + if not exists: + try: + db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") + except sqlite3.Error: + db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile)") + db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ai AFTER INSERT ON records BEGIN + INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); + END""") + db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_au AFTER UPDATE ON records WHEN old.title != new.title OR old.content != new.content OR old.profile != new.profile BEGIN + DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; + INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); + END""") + db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ad AFTER DELETE ON records BEGIN + DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; + END""") + db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_ai AFTER INSERT ON events BEGIN + INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); + END""") + db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_au AFTER UPDATE ON events WHEN old.text != new.text OR old.profile != new.profile BEGIN + DELETE FROM fts_docs WHERE item = old.id AND kind = 'event'; + INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); + END""") + db.execute("""CREATE TRIGGER IF NOT EXISTS trg_audit_ai AFTER INSERT ON audit BEGIN + INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('audit:' || new.id, 'audit', new.action, new.path, new.message, new.profile); + END""") + if db.execute("SELECT COUNT(*) FROM fts_docs").fetchone()[0] == 0: + db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'record', kind, title, content, profile FROM records") + db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'event', role || '/' || kind, ts || ' ' || role || '/' || kind, text, profile FROM events") + db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT 'audit:' || id, 'audit', action, path, message, profile FROM audit") + db.commit() + return True + except sqlite3.Error: + return False + + +def table_pk_columns(db, table): + for row in db.execute("PRAGMA index_list(%s)" % table).fetchall(): + if row[3] == "pk": + return [info[2] for info in db.execute("PRAGMA index_info(%s)" % row[1]).fetchall()] + return [] + + +def rebuild_with_profile(db, table, columns, pk): + if table_pk_columns(db, table) == list(pk): + return False + names = [column.split()[0] for column in columns] + db.execute("CREATE TABLE %s_new (%s, PRIMARY KEY (%s))" % (table, ", ".join(columns), ", ".join(pk))) + db.execute("INSERT OR IGNORE INTO %s_new (%s) SELECT %s FROM %s" % (table, ", ".join(names), ", ".join(names), table)) + db.execute("DROP TABLE %s" % table) + db.execute("ALTER TABLE %s_new RENAME TO %s" % (table, table)) + return True + + +def migrate_profiles(db): + for table in ("secrets", "records", "tags", "edges", "audit", "events", "schedules"): + ensure_column(db, table, "profile", "TEXT") + db.execute("UPDATE %s SET profile = 'default' WHERE profile IS NULL" % table) + rebuild_with_profile(db, "secrets", ("profile TEXT", "name TEXT", "value TEXT", "meta TEXT", "updated TEXT"), ("profile", "name")) + rebuild_with_profile(db, "tags", ("item TEXT", "tag TEXT", "profile TEXT"), ("item", "tag", "profile")) + rebuild_with_profile(db, "edges", ("src TEXT", "dst TEXT", "relation TEXT", "profile TEXT", "created TEXT"), ("src", "dst", "relation", "profile")) + db.execute("CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)") + db.execute("CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src)") + db.execute("CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst)") + db.execute("CREATE INDEX IF NOT EXISTS idx_tags_profile ON tags(profile)") + db.execute("CREATE INDEX IF NOT EXISTS idx_edges_profile ON edges(profile)") + db.execute("CREATE INDEX IF NOT EXISTS idx_records_profile ON records(profile)") + db.execute("CREATE INDEX IF NOT EXISTS idx_audit_profile ON audit(profile)") + + class Store: def __init__(self, config, seal): self.config = config self.seal = seal self.db = sqlite3.connect(config.db_path, timeout=30) - self.db.execute("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, profile TEXT, ts TEXT, role TEXT, kind TEXT, text TEXT)") + self.db.execute("PRAGMA journal_mode=WAL") + self.db.execute("PRAGMA synchronous=NORMAL") + self.db.execute("PRAGMA busy_timeout=30000") + self.db.execute("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, profile TEXT, ts TEXT, role TEXT, kind TEXT, text TEXT, tags TEXT)") self.db.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON events(profile)") + self.db.execute("CREATE TABLE IF NOT EXISTS secrets (profile TEXT, name TEXT, value TEXT, meta TEXT, updated TEXT, PRIMARY KEY (profile, name))") + self.db.execute("CREATE TABLE IF NOT EXISTS schedules (id INTEGER PRIMARY KEY, name TEXT, prompt TEXT, profile TEXT, every_sec INTEGER, next_run TEXT, timeout INTEGER, status TEXT, last_status TEXT, last_result TEXT, created TEXT, updated TEXT)") + self.db.execute("CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, kind TEXT, profile TEXT, title TEXT, content TEXT, size INTEGER, reads INTEGER, created TEXT, updated TEXT)") + self.db.execute("CREATE TABLE IF NOT EXISTS tags (item TEXT, tag TEXT, profile TEXT, PRIMARY KEY (item, tag, profile))") + self.db.execute("CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)") + self.db.execute("CREATE TABLE IF NOT EXISTS edges (src TEXT, dst TEXT, relation TEXT, profile TEXT, created TEXT, PRIMARY KEY (src, dst, relation, profile))") + self.db.execute("CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src)") + self.db.execute("CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst)") + self.db.execute("CREATE TABLE IF NOT EXISTS audit (id INTEGER PRIMARY KEY, profile TEXT, ts TEXT, actor TEXT, action TEXT, path TEXT, message TEXT, old_size INTEGER, new_size INTEGER, old TEXT, new TEXT, tags TEXT)") + self.db.execute("CREATE INDEX IF NOT EXISTS idx_audit_path ON audit(path)") self.db.commit() + migrate_profiles(self.db) + ensure_column(self.db, "secrets", "meta", "TEXT") + ensure_column(self.db, "events", "tags", "TEXT") + self.db.commit() + self.profile = config.profile + self.secret_cache = {} + self.fts_ok = setup_fts(self.db) if seal.enabled: self.db.create_function("tai_enc", 1, seal.lock) self.db.create_function("tai_dec", 1, seal.safe_unlock) self.db.execute("UPDATE events SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") + self.db.execute("UPDATE secrets SET value = tai_enc(value) WHERE value NOT LIKE 'tai1$%'") + self.db.execute("UPDATE secrets SET meta = tai_enc(COALESCE(meta, '{}')) WHERE meta IS NULL OR meta NOT LIKE 'tai1$%'") + self.db.execute("UPDATE schedules SET prompt = tai_enc(prompt) WHERE prompt NOT LIKE 'tai1$%'") self.db.commit() self.seal_files() else: sealed = self.db.execute("SELECT COUNT(*) FROM events WHERE text LIKE 'tai1$%'").fetchone()[0] if sealed: raise SealError("memory is sealed, set TAI_PASSPHRASE") + locked = self.db.execute("SELECT COUNT(*) FROM secrets WHERE value LIKE 'tai1$%' OR meta LIKE 'tai1$%'").fetchone()[0] + if locked: + raise SealError("secrets are sealed, set TAI_PASSPHRASE") + planned = self.db.execute("SELECT COUNT(*) FROM schedules WHERE prompt LIKE 'tai1$%'").fetchone()[0] + if planned: + raise SealError("schedules are sealed, set TAI_PASSPHRASE") + self.memdb = None + self.mem_events_max = 0 + if seal.enabled and self.fts_ok: + try: + self.memdb = open_mem_events() + floor = self.db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0] - MEM_EVENTS_CAP + self.mem_events_max = max(0, floor) + self._sync_mem_events() + except sqlite3.Error: + self.memdb = None + + def _sync_mem_events(self): + if self.memdb is None: + return + try: + rows = self.db.execute("SELECT id, profile, ts, role, kind, tai_dec(text) FROM events WHERE id > ? ORDER BY id ASC", (self.mem_events_max,)).fetchall() + except sqlite3.Error: + return + if not rows: + return + try: + self.memdb.executemany("INSERT INTO mem_events(item, kind, sub, title, body, profile) VALUES (?, 'event', ? || '/' || ?, ? || ' ' || ? || '/' || ?, ?, ?)", [(row[0], row[3], row[4], row[2], row[3], row[4], row[5], row[1]) for row in rows]) + self.memdb.commit() + except sqlite3.Error: + return + self.mem_events_max = rows[-1][0] + try: + self.memdb.execute("DELETE FROM mem_events WHERE CAST(item AS INTEGER) <= ?", (self.mem_events_max - MEM_EVENTS_CAP,)) + self.memdb.commit() + except sqlite3.Error: + pass def seal_files(self): for entry in os.listdir(self.config.profiles_dir): - if not entry.endswith((".sys.md", ".session.json")): + if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(self.config.profiles_dir, entry) try: @@ -533,23 +1351,546 @@ class Store: except OSError: pass - def log_event(self, profile, role, kind, text): + def log_event(self, profile, role, kind, text, tags=()): value = (text or "")[:2000] + stamped = " " + " ".join([role, kind] + normalize_tags(tags)) + " " try: if self.seal.enabled: - self.db.execute("INSERT INTO events (profile, ts, role, kind, text) VALUES (?, ?, ?, ?, tai_enc(?))", (profile, now_iso(), role, kind, value)) + self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, tai_enc(?), ?)", (profile, now_iso(), role, kind, value, stamped)) else: - self.db.execute("INSERT INTO events (profile, ts, role, kind, text) VALUES (?, ?, ?, ?, ?)", (profile, now_iso(), role, kind, value)) + self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, ?, ?)", (profile, now_iso(), role, kind, value, stamped)) self.db.commit() + self._sync_mem_events() except sqlite3.Error: pass - def search_events(self, profile, query, limit=8): + def fts_search(self, query, kinds=(), profile=None, limit=10): + profile = self._scope(profile) + if not self.fts_ok: + return [] + terms = fts_escape(query) + if terms is None: + return [] + sql = "SELECT item, kind, sub, title, snippet(fts_docs, 4, '[', ']', '...', 12), bm25(fts_docs) FROM fts_docs WHERE fts_docs MATCH ? AND profile = ?" + params = [fts_match(terms), profile] + if kinds: + marks = ", ".join("?" * len(kinds)) + sql += " AND kind IN (%s)" % marks + params += list(kinds) if self.seal.enabled: - rows = self.db.execute("SELECT ts, role, kind, text FROM (SELECT id, ts, role, kind, tai_dec(text) AS text FROM events WHERE profile = ? ORDER BY id DESC LIMIT 5000) WHERE text LIKE ? ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%", limit)).fetchall() + sql += " AND kind != 'event'" + sql += " ORDER BY bm25(fts_docs) LIMIT ?" + capped = max(1, min(limit, 100)) + params.append(capped) + try: + rows = self.db.execute(sql, tuple(params)).fetchall() + except sqlite3.Error: + return [] + hits = [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in rows] + if self.seal.enabled and self.memdb is not None and (not kinds or "event" in kinds): + self._sync_mem_events() + try: + mem_rows = self.memdb.execute("SELECT item, kind, sub, title, snippet(mem_events, 4, '[', ']', '...', 12), bm25(mem_events) FROM mem_events WHERE mem_events MATCH ? AND profile = ? ORDER BY bm25(mem_events) LIMIT ?", (fts_match(terms), profile, capped)).fetchall() + except sqlite3.Error: + mem_rows = [] + hits += [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in mem_rows] + hits.sort(key=lambda hit: hit["rank"]) + hits = hits[:capped] + return hits + + def audit_search(self, query, profile=None, limit=20): + profile = self._scope(profile) + hits = self.fts_search(query, ("audit",), profile, limit) + found = [] + for hit in hits: + try: + row_id = int(str(hit["item"]).split(":", 1)[1]) + except (IndexError, ValueError): + continue + row = self.audit_get(row_id, profile) + if row is not None: + row["snippet"] = hit["snippet"] + found.append(row) + return found + + def search_events(self, profile, query, limit=8, tags=()): + clean = normalize_tags(tags) + if self.fts_ok: + hits = self.fts_search(query, ("event",), profile, max(limit * 5, 20)) + if hits: + ids = [int(hit["item"]) for hit in hits] + marks = ", ".join("?" * len(ids)) + text_col = "tai_dec(text)" if self.seal.enabled else "text" + rows = {row[0]: row[1:] for row in self.db.execute("SELECT id, ts, role, kind, %s, tags FROM events WHERE id IN (%s)" % (text_col, marks), tuple(ids)).fetchall()} + found = [] + for hit in hits: + row = rows.get(int(hit["item"])) + if row is None: + continue + if clean and not all((" %s " % tag) in (" %s " % (row[4] or "")) for tag in clean): + continue + found.append(row[:4]) + if len(found) >= limit: + break + if found: + return found + return self.events_like(profile, query, limit, clean) + + def events_like(self, profile, query, limit=8, tags=()): + clean = normalize_tags(tags) + extra = "".join(" AND tags LIKE ?" for _tag in clean) + wild = tuple("% %s %%" % tag for tag in clean) + if self.seal.enabled: + return self.db.execute("SELECT ts, role, kind, text FROM (SELECT id, ts, role, kind, tags, tai_dec(text) AS text FROM events WHERE profile = ? ORDER BY id DESC LIMIT 5000) WHERE text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() + return self.db.execute("SELECT ts, role, kind, text FROM events WHERE profile = ? AND text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() + + def _scope(self, profile): + return profile or self.profile + + def save_secret(self, name, value, meta=None, tags=None, profile=None): + profile = self._scope(profile) + self.secret_cache.pop(profile, None) + stored = self.seal.lock(value) if self.seal.enabled else value + blob = json.dumps(meta or {}) + locked_meta = self.seal.lock(blob) if self.seal.enabled else blob + self.db.execute("INSERT OR REPLACE INTO secrets (profile, name, value, meta, updated) VALUES (?, ?, ?, ?, ?)", (profile, name, stored, locked_meta, now_iso())) + if tags is None: + self.tag_item("secret:" + name, ["secret"], profile) else: - rows = self.db.execute("SELECT ts, role, kind, text FROM events WHERE profile = ? AND text LIKE ? ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%", limit)).fetchall() - return rows + self.set_item_tags("secret:" + name, ["secret"] + list(tags), profile) + self.db.commit() + + def secret_meta(self, name, profile=None): + profile = self._scope(profile) + row = self.db.execute("SELECT meta FROM secrets WHERE profile = ? AND name = ?", (profile, name)).fetchone() + if row is None or not row[0]: + return {} + raw = self.seal.safe_unlock(row[0]) if row[0].startswith(Seal.PREFIX) else row[0] + try: + data = json.loads(raw) + except ValueError: + return {} + return data if isinstance(data, dict) else {} + + def list_secret_infos(self, profile=None): + profile = self._scope(profile) + rows = self.db.execute("SELECT name, meta, updated FROM secrets WHERE profile = ? ORDER BY name", (profile,)).fetchall() + items = [] + for name, meta, updated in rows: + raw = None + if meta: + raw = self.seal.safe_unlock(meta) if meta.startswith(Seal.PREFIX) else meta + try: + data = json.loads(raw or "{}") + except ValueError: + data = {} + items.append({"name": name, "meta": data if isinstance(data, dict) else {}, "tags": self.item_tags("secret:" + name, profile), "updated": updated}) + return items + + def known_tags(self, profile=None): + profile = self._scope(profile) + return {row[0] for row in self.db.execute("SELECT DISTINCT tag FROM tags WHERE profile = ?", (profile,)).fetchall()} + + def canonical_tag(self, tag, vocab=None, profile=None): + if vocab is None: + vocab = self.known_tags(profile) + if tag in vocab: + return tag + single = singular_noun(tag) + if single != tag and single in vocab: + return single + return tag + + def canonical_tags(self, tags, vocab=None, profile=None): + profile = self._scope(profile) + if vocab is None: + vocab = self.known_tags(profile) + seen = [] + for tag in normalize_tags(tags): + clean = self.canonical_tag(tag, vocab) + if clean not in seen: + seen.append(clean) + return seen + + def tag_counts(self, prefix="", limit=50, profile=None): + profile = self._scope(profile) + clean = normalize_tag(prefix) + sql = "SELECT tag, COUNT(*) FROM tags WHERE profile = ?" + params = [profile] + if clean: + sql += " AND tag LIKE ? ESCAPE '\\'" + params.append(clean.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%") + sql += " GROUP BY tag ORDER BY COUNT(*) DESC, tag LIMIT ?" + params.append(max(1, min(limit, 200))) + return [(row[0], row[1]) for row in self.db.execute(sql, tuple(params)).fetchall()] + + def auto_tags_for(self, text, explicit=(), vocab=None): + if vocab is None: + vocab = self.known_tags() + skip = set(explicit) | set(TAG_AUTO_SKIP) + words = re.findall(r"[a-z0-9]+", (text or "").lower()) + found = [] + for pos, word in enumerate(words): + if len(found) >= TAG_AUTO_MAX: + break + if pos + 1 < len(words): + pair = word + "-" + words[pos + 1] + if pair in vocab and pair not in skip and pair not in found: + found.append(pair) + continue + if word in vocab and word not in skip and word not in found: + found.append(word) + continue + single = singular_noun(word) + if single != word and single in vocab and single not in skip and single not in found: + found.append(single) + return found + + def tag_neighbors(self, record_id, limit=None, profile=None): + profile = self._scope(profile) + rows = self.db.execute("SELECT r.id, MIN(t.tag) FROM records r JOIN tags t ON t.item = r.id WHERE t.tag IN (SELECT tag FROM tags WHERE item = ? AND profile = ? AND tag NOT IN ('record', 'file')) AND r.id != ? AND r.profile = ? AND t.profile = ? GROUP BY r.id ORDER BY r.updated DESC LIMIT ?", (record_id, profile, record_id, profile, profile, max(1, limit or TAG_LINK_MAX))).fetchall() + return [(row[0], row[1]) for row in rows] + + def tag_item(self, item, tags, profile=None): + profile = self._scope(profile) + for tag in self.canonical_tags(tags, None, profile): + self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) + self.db.commit() + + def set_item_tags(self, item, tags, profile=None): + profile = self._scope(profile) + self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) + for tag in self.canonical_tags(tags, None, profile): + self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) + self.db.commit() + + def item_tags(self, item, profile=None): + profile = self._scope(profile) + return [row[0] for row in self.db.execute("SELECT tag FROM tags WHERE item = ? AND profile = ? ORDER BY tag", (item, profile)).fetchall()] + + def tagged_items(self, tags, profile=None): + profile = self._scope(profile) + clean = normalize_tags(tags) + if not clean: + return None + marks = ", ".join("?" * len(clean)) + rows = self.db.execute("SELECT item FROM tags WHERE profile = ? AND tag IN (%s) GROUP BY item HAVING COUNT(DISTINCT tag) = ?" % marks, (profile,) + tuple(clean) + (len(clean),)).fetchall() + return [row[0] for row in rows] + + def has_node(self, kind, key, profile=None): + profile = self._scope(profile) + if kind == "mem": + row = self.db.execute("SELECT 1 FROM records WHERE profile = ? AND id = ?", (profile, "mem:" + key,)).fetchone() + elif kind == "secret": + row = self.db.execute("SELECT 1 FROM secrets WHERE profile = ? AND name = ?", (profile, key,)).fetchone() + else: + row = self.db.execute("SELECT 1 FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() + return row is not None + + def node_title(self, node, profile=None): + profile = self._scope(profile) + parsed = parse_node_id(node) + if parsed is None: + return node + kind, key = parsed + if kind == "mem": + row = self.db.execute("SELECT title FROM records WHERE profile = ? AND id = ?", (profile, node,)).fetchone() + return row[0] or node if row else node + if kind == "secret": + return "secret:" + key + row = self.db.execute("SELECT name, prompt FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() + if not row: + return node + return row[0] or "schedule #%s" % key + + def add_record(self, kind, title, content, tags=(), profile=None): + profile = self._scope(profile) + record_id = "mem:" + uuid.uuid4().hex[:16] + while self.db.execute("SELECT 1 FROM records WHERE id = ?", (record_id,)).fetchone(): + record_id = "mem:" + uuid.uuid4().hex[:16] + stamp = datetime.now(timezone.utc).isoformat() + size = len(content.encode("utf-8")) + self.db.execute("INSERT INTO records(id, kind, profile, title, content, size, reads, created, updated) VALUES(?, ?, ?, ?, ?, ?, 0, ?, ?)", (record_id, kind, profile, title, content, size, stamp, stamp)) + vocab = self.known_tags(profile) + explicit = self.canonical_tags(tags, vocab, profile) + scan = "%s\n%s" % (title, content.split("\n...[truncated", 1)[0]) + auto = self.auto_tags_for(scan, explicit, vocab) + self.set_item_tags(record_id, ["record"] + explicit + auto, profile) + for other, shared in self.tag_neighbors(record_id, None, profile): + self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (record_id, other, "shares-" + shared, profile, stamp)) + self.db.commit() + return record_id + + def get_record(self, record_id, profile=None): + profile = self._scope(profile) + row = self.db.execute("SELECT id, kind, title, content, size, reads, created, updated FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() + if row is None: + return None + self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) + self.db.commit() + return {"id": row[0], "kind": row[1], "title": row[2], "content": row[3], "size": row[4], "reads": row[5] + 1, "created": row[6], "updated": row[7], "tags": self.item_tags(row[0], profile)} + + def read_record(self, record_id, offset=0, limit=4000, profile=None): + profile = self._scope(profile) + row = self.db.execute("SELECT content FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() + if row is None: + return None + content = row[0] + total = len(content) + start = max(0, min(offset, total)) + end = min(total, start + max(1, limit)) + self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) + self.db.commit() + return {"id": record_id, "slice": content[start:end], "start": start, "end": end, "total": total, "tags": self.item_tags(record_id, profile)} + + def search_records(self, query="", kind=None, tags=(), limit=8, profile=None): + profile = self._scope(profile) + clean = normalize_tags(tags) + terms = fts_escape(query) if query else None + if terms is not None and self.fts_ok: + ranked = self.search_records_fts(terms, kind, clean, limit, profile) + if ranked: + return ranked + sql = "SELECT id, kind, title, size, reads, updated FROM records" + clauses = ["profile = ?"] + params = [profile] + if kind: + clauses.append("kind = ?") + params.append(kind) + if query: + clauses.append("(title LIKE ? OR content LIKE ?)") + params += ["%" + query + "%", "%" + query + "%"] + if clean: + for tag in clean: + options = tag_variants(tag) + marks = ", ".join("?" * len(options)) + clauses.append("id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks) + params += [profile] + options + sql += " WHERE " + " AND ".join(clauses) + sql += " ORDER BY updated DESC LIMIT ?" + params.append(max(1, min(limit, 50))) + rows = self.db.execute(sql, tuple(params)).fetchall() + return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] + + def search_records_fts(self, terms, kind, clean, limit, profile): + sql = "SELECT r.id, r.kind, r.title, r.size, r.reads, r.updated FROM records r JOIN fts_docs f ON f.item = r.id AND f.kind = 'record' WHERE r.profile = ? AND f MATCH ?" + params = [profile, fts_match(terms)] + if kind: + sql += " AND r.kind = ?" + params.append(kind) + for tag in clean: + options = tag_variants(tag) + marks = ", ".join("?" * len(options)) + sql += " AND r.id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks + params += [profile] + options + sql += " ORDER BY bm25(f) LIMIT ?" + params.append(max(1, min(limit, 50))) + try: + rows = self.db.execute(sql, tuple(params)).fetchall() + except sqlite3.Error: + return [] + return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] + + def delete_record(self, record_id, profile=None): + profile = self._scope(profile) + done = self.db.execute("DELETE FROM records WHERE profile = ? AND id = ?", (profile, record_id,)) + if done.rowcount > 0: + self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (record_id, profile)) + self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, record_id, record_id)) + self.db.commit() + return True + self.db.commit() + return False + + def add_edge(self, src, dst, relation="linked", profile=None): + profile = self._scope(profile) + for node in (src, dst): + parsed = parse_node_id(node) + if parsed is None or not self.has_node(*parsed, profile): + raise ValueError("unknown node " + node) + clean = re.sub(r"[^a-z0-9]+", "-", str(relation or "").lower()).strip("-")[:32] or "linked" + self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (src, dst, clean, profile, datetime.now(timezone.utc).isoformat())) + self.db.commit() + return clean + + def edges_for(self, node, profile=None): + profile = self._scope(profile) + rows = self.db.execute("SELECT src, dst, relation FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)).fetchall() + return [{"relation": row[2], "other": row[1] if row[0] == node else row[0], "direction": "out" if row[0] == node else "in"} for row in rows] + + def remove_edges(self, node, profile=None): + profile = self._scope(profile) + done = self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)) + self.db.commit() + return done.rowcount + + def traverse(self, start, depth=2, limit=50, profile=None): + profile = self._scope(profile) + seen = {start} + found = [{"node": start, "title": self.node_title(start, profile), "depth": 0, "via": ""}] + frontier = [(start, 0, "")] + capped = max(1, min(depth, 4)) + while frontier and len(found) < max(1, min(limit, 200)): + node, level, _via = frontier.pop(0) + if level >= capped: + continue + for edge in self.edges_for(node, profile): + other = edge["other"] + if other in seen: + continue + seen.add(other) + hop = "%s -[%s]-> %s" % (node, edge["relation"], other) if edge["direction"] == "out" else "%s <-[%s]- %s" % (node, edge["relation"], other) + found.append({"node": other, "title": self.node_title(other, profile), "depth": level + 1, "via": hop}) + frontier.append((other, level + 1, hop)) + if len(found) >= max(1, min(limit, 200)): + break + return found + + def audit_event(self, actor, action, path, message="", old=None, new=None, tags=(), old_size=None, new_size=None, profile=None): + profile = self._scope(profile) + old_text, computed_old = capped_text(old) + new_text, computed_new = capped_text(new) + if old is not None and old_size is not None and old_size > len(old.encode("utf-8")): + old_text = old[:AUDIT_MAX_CHARS] + trunc_marker(old_size) + computed_old = old_size + if new is not None and new_size is not None and new_size > len(new.encode("utf-8")): + new_text = new[:AUDIT_MAX_CHARS] + trunc_marker(new_size) + computed_new = new_size + cursor = self.db.execute("INSERT INTO audit(profile, ts, actor, action, path, message, old_size, new_size, old, new, tags) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (profile, datetime.now(timezone.utc).isoformat(), actor, action, path, str(message or "")[:2000], computed_old, computed_new, old_text, new_text, " ".join(normalize_tags(tags)))) + self.db.commit() + return cursor.lastrowid + + def audit_history(self, path=None, tag=None, limit=20, profile=None): + profile = self._scope(profile) + sql = "SELECT id, ts, actor, action, path, message, old_size, new_size, tags FROM audit" + clauses = ["profile = ?"] + params = [profile] + if path: + clauses.append("path = ?") + params.append(path) + if tag: + clauses.append("tags LIKE ?") + params.append("%" + tag + "%") + sql += " WHERE " + " AND ".join(clauses) + sql += " ORDER BY id DESC LIMIT ?" + params.append(max(1, min(limit, 100))) + rows = self.db.execute(sql, tuple(params)).fetchall() + return [{"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "tags": row[8]} for row in rows] + + def audit_get(self, row_id, profile=None): + profile = self._scope(profile) + row = self.db.execute("SELECT id, ts, actor, action, path, message, old_size, new_size, old, new, tags FROM audit WHERE profile = ? AND id = ?", (profile, row_id,)).fetchone() + if row is None: + return None + return {"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "old": row[8], "new": row[9], "tags": row[10]} + + def file_record_id(self, path, profile=None): + profile = self._scope(profile) + row = self.db.execute("SELECT id FROM records WHERE profile = ? AND kind = 'file' AND title = ?", (profile, path,)).fetchone() + return row[0] if row else None + + def upsert_file_record(self, path, content, true_size=None, profile=None): + profile = self._scope(profile) + full_bytes = len(content.encode("utf-8")) + if true_size is not None and true_size > full_bytes: + text = content + trunc_marker(true_size) + elif len(content) <= FILE_RECORD_MAX: + text = content + else: + text = content[:FILE_RECORD_MAX] + trunc_marker(full_bytes) + ext = os.path.splitext(path)[1].lstrip(".").lower() + explicit = ["file"] + ([ext] if ext and ext.isalnum() and len(ext) <= 5 else []) + vocab = self.known_tags(profile) + tags = explicit + self.auto_tags_for("%s\n%s" % (path, content), explicit, vocab) + record_id = self.file_record_id(path, profile) + stamp = datetime.now(timezone.utc).isoformat() + if record_id is None: + return self.add_record("file", path, text, tags, profile) + self.db.execute("UPDATE records SET content = ?, size = ?, updated = ? WHERE id = ?", (text, len(text.encode("utf-8")), stamp, record_id)) + self.set_item_tags(record_id, tags, profile) + self.db.commit() + return record_id + + def delete_file_record(self, path, profile=None): + profile = self._scope(profile) + record_id = self.file_record_id(path, profile) + if record_id is None: + return False + return self.delete_record(record_id, profile) + + def load_secret(self, name, profile=None): + profile = self._scope(profile) + row = self.db.execute("SELECT value FROM secrets WHERE profile = ? AND name = ?", (profile, name,)).fetchone() + if row is None: + return None + if row[0].startswith(Seal.PREFIX): + return self.seal.unlock(row[0]) if self.seal.enabled else None + return row[0] + + def delete_secret(self, name, profile=None): + profile = self._scope(profile) + self.secret_cache.pop(profile, None) + done = self.db.execute("DELETE FROM secrets WHERE profile = ? AND name = ?", (profile, name,)) + if done.rowcount > 0: + item = "secret:" + name + self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) + self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) + self.db.commit() + return True + self.db.commit() + return False + + def secret_values(self, profile=None): + profile = self._scope(profile) + if profile not in self.secret_cache: + found = {} + for name, value in self.db.execute("SELECT name, value FROM secrets WHERE profile = ?", (profile,)).fetchall(): + if value.startswith(Seal.PREFIX): + if not self.seal.enabled: + continue + value = self.seal.unlock(value) + if len(value) >= 4: + found[name] = value + self.secret_cache[profile] = found + return self.secret_cache[profile] + + def redact(self, text, profile=None): + for name, value in sorted(self.secret_values(profile).items(), key=lambda item: -len(item[1])): + if value in text: + text = text.replace(value, "[redacted:%s]" % name) + return text + + def add_schedule(self, name, prompt, profile, every_sec, next_run, timeout, tags=None): + stored = self.seal.lock(prompt) if self.seal.enabled else prompt + done = self.db.execute("INSERT INTO schedules (name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created, updated) VALUES (?, ?, ?, ?, ?, ?, 'pending', '', '', ?, ?)", (name, stored, profile, every_sec, next_run, timeout, now_iso(), now_iso())) + item = "sched:%s" % done.lastrowid + if tags is None: + self.tag_item(item, ["schedule"], profile) + else: + self.set_item_tags(item, ["schedule"] + list(tags), profile) + self.db.commit() + return done.lastrowid + + def remove_schedule(self, row_id, profile=None): + profile = self._scope(profile) + done = self.db.execute("DELETE FROM schedules WHERE id = ? AND profile = ?", (row_id, profile)) + if done.rowcount > 0: + item = "sched:%s" % row_id + self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) + self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) + self.db.commit() + return True + self.db.commit() + return False + + def list_schedules(self, profile=None): + profile = self._scope(profile) + rows = self.db.execute("SELECT id, name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created FROM schedules WHERE profile = ? ORDER BY next_run", (profile,)).fetchall() + items = [] + for row in rows: + prompt = self.seal.safe_unlock(row[2]) if row[2].startswith(Seal.PREFIX) else row[2] + items.append({"id": row[0], "name": row[1], "prompt": prompt, "profile": row[3], "every": row[4], "next_run": row[5], "timeout": row[6], "status": row[7], "last": row[8], "result": row[9], "created": row[10], "tags": self.item_tags("sched:%s" % row[0], row[3])}) + return items def profile_path(self, name): return os.path.join(self.config.profiles_dir, name + ".sys.md") @@ -560,7 +1901,7 @@ class Store: def list_profiles(self): names = [] for entry in sorted(os.listdir(self.config.profiles_dir)): - if entry.endswith(".sys.md"): + if entry.endswith(".sys.md") and ".bot." not in entry: names.append(entry[:-7]) return names @@ -587,8 +1928,8 @@ class Store: except OSError: pass - def load_session(self, name): - path = self.session_path(name) + def load_session(self, name, bot="main"): + path = self.session_path(name) if bot == "main" else self.bot_session_path(name, bot) if not os.path.exists(path): return [] try: @@ -599,8 +1940,8 @@ class Store: except (OSError, ValueError): return [] - def save_session(self, name, messages): - path = self.session_path(name) + def save_session(self, name, messages, bot="main"): + path = self.session_path(name) if bot == "main" else self.bot_session_path(name, bot) try: with open(path, "w", encoding="utf-8") as handle: handle.write(self.seal.lock(json.dumps([item for item in messages if item.get("role") != "system"]))) @@ -608,11 +1949,87 @@ class Store: except OSError: pass + def bot_system_path(self, profile, bot): + return os.path.join(self.config.profiles_dir, "%s.bot.%s.sys.md" % (profile, bot)) + + def bot_session_path(self, profile, bot): + return os.path.join(self.config.profiles_dir, "%s.bot.%s.session.json" % (profile, bot)) + + def bots_registry_path(self, profile): + return os.path.join(self.config.profiles_dir, "%s.bots.json" % profile) + + def load_bots(self, profile): + path = self.bots_registry_path(profile) + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.loads(self.unseal_file(handle.read())) + bots = data.get("bots") if isinstance(data, dict) else None + return bots if isinstance(bots, dict) else {} + except (OSError, ValueError): + return {} + + def save_bots(self, profile, bots): + try: + with open(self.bots_registry_path(profile), "w", encoding="utf-8") as handle: + handle.write(self.seal.lock(json.dumps({"bots": bots}))) + os.chmod(self.bots_registry_path(profile), 0o600) + except OSError: + pass + + def list_bots(self, profile): + bots = self.load_bots(profile) + found = [{"name": "main", "nicknames": []}] + for name in sorted(bots): + found.append({"name": name, "nicknames": (bots.get(name) or {}).get("nicknames", [])}) + return found + + def resolve_bot(self, profile, mention): + want = str(mention or "").strip().lower() + if want in ("", "main"): + return "main" + bots = self.load_bots(profile) + if want in bots: + return want + for name in sorted(bots): + if want in (bots.get(name) or {}).get("nicknames", []): + return name + return None + + def load_bot_system(self, profile, bot): + if bot == "main": + return self.load_system(profile) + path = self.bot_system_path(profile, bot) + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as handle: + return self.unseal_file(handle.read()) + return None + + def save_bot_system(self, profile, bot, text): + path = self.bot_system_path(profile, bot) + with open(path, "w", encoding="utf-8") as handle: + handle.write(self.seal.lock(text)) + try: + os.chmod(path, 0o600) + except OSError: + pass + def close(self): + try: + self.db.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except sqlite3.Error: + pass try: self.db.close() except sqlite3.Error: pass + if getattr(self, "memdb", None) is not None: + try: + self.memdb.close() + except sqlite3.Error: + pass + self.memdb = None class BackendError(Exception): @@ -847,6 +2264,7 @@ def edge_connect(): path = "%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=1-%s" % (EDGE_WS_PATH, EDGE_TRUSTED_TOKEN, uuid.uuid4().hex, edge_token(skew), EDGE_CHROMIUM) raw = socket.create_connection((EDGE_HOST, 443), timeout=20) sock = ssl.create_default_context().wrap_socket(raw, server_hostname=EDGE_HOST) + sock.settimeout(30) key = base64.b64encode(os.urandom(16)).decode("ascii") muid = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) handshake = "\r\n".join([ @@ -955,28 +2373,317 @@ def listen_audio(seconds, workdir): return truncate(text, 3000, 500) if text else "heard nothing" +def sysinfo_check_os(): + try: + uname = os.uname() + base = "%s %s %s" % (uname.sysname, uname.release, uname.machine) + except (AttributeError, OSError): + base = sys.platform + try: + with open("/etc/os-release", "r", encoding="utf-8") as handle: + fields = dict(line.strip().split("=", 1) for line in handle if "=" in line) + pretty = fields.get("PRETTY_NAME", "").strip('"') + if pretty: + return "%s (%s)" % (pretty, base) + except OSError: + pass + return base + + +def sysinfo_check_python(): + return "%s (%s)" % (sys.version.split()[0], sys.executable) + + +def sysinfo_check_venv(): + env_path = os.environ.get("VIRTUAL_ENV") or "" + if env_path: + return "yes (%s)" % env_path + if sys.prefix != sys.base_prefix: + return "yes (%s)" % sys.prefix + return "no (system python at %s)" % sys.prefix + + +def sysinfo_check_root(): + geteuid = getattr(os, "geteuid", None) + if geteuid is None: + return "unknown (no geteuid on %s)" % sys.platform + uid = geteuid() + return "yes (uid 0)" if uid == 0 else "no (uid %d)" % uid + + +def sysinfo_check_container(): + engine = container_engine() + if not engine: + return "no engine (install podman or docker)" + return "%s, box %s" % (engine, box_state(engine)) + + +def sysinfo_check_binaries(): + found = [] + lost = [] + for name in SYSINFO_BINARIES: + (found if shutil.which(name) else lost).append(name) + return "present: %s; missing: %s" % (", ".join(found) or "none", ", ".join(lost) or "none") + + +def sysinfo_check_cpu(): + count = os.cpu_count() or 0 + try: + one, five, fifteen = os.getloadavg() + return "%d cores, load %.2f/%.2f/%.2f" % (count, one, five, fifteen) + except OSError: + return "%d cores" % count + + +def sysinfo_check_disk(): + usage = shutil.disk_usage(os.getcwd()) + return "%.1f GiB free of %.1f GiB at %s" % (usage.free / 2 ** 30, usage.total / 2 ** 30, os.getcwd()) + + +SYSINFO_CHECKS = ( + ("os", sysinfo_check_os), + ("python", sysinfo_check_python), + ("venv", sysinfo_check_venv), + ("root", sysinfo_check_root), + ("container", sysinfo_check_container), + ("binaries", sysinfo_check_binaries), + ("cpu", sysinfo_check_cpu), + ("disk", sysinfo_check_disk), +) + + +def collect_sysinfo(names=None): + wanted = [pair for pair in SYSINFO_CHECKS if names is None or pair[0] in names] + results = {} + + def target(name, func): + started = time.time() + try: + value = func() + except Exception as exc: + value = "error: " + short_error(exc) + results[name] = (value, int((time.time() - started) * 1000)) + + workers = [threading.Thread(target=target, args=(name, func), daemon=True) for name, func in wanted] + for worker in workers: + worker.start() + deadline = time.time() + SYSINFO_TIMEOUT + for worker in workers: + worker.join(timeout=max(0, deadline - time.time())) + lines = [] + for name, _func in wanted: + if name in results: + value, elapsed = results[name] + lines.append("%s: %s (%dms)" % (name, value, elapsed)) + else: + lines.append("%s: timed out after %ds" % (name, SYSINFO_TIMEOUT)) + return "\n".join(lines) + + def tool_schema(name, description, properties, required): return {"type": "function", "function": {"name": name, "description": description, "parameters": {"type": "object", "properties": properties, "required": required}}} TOOL_SCHEMAS = [ - tool_schema("shell", "Run a shell command. Output is truncated. On home, destructive commands ask the user first. On sandbox, commands run isolated in the container and need no approval.", {"command": {"type": "string"}, "workdir": {"type": "string"}}, ["command"]), + tool_schema("shell", "Run a shell command. Output is truncated. On home, destructive commands ask the user first. On sandbox, commands run isolated in the container and need no approval. Pass secrets to expose named vault secrets as TAI_SECRET_ variables for that command only; using secrets asks the user first.", {"command": {"type": "string"}, "workdir": {"type": "string"}, "secrets": {"type": "array"}}, ["command"]), tool_schema("read_file", "Read a text file. Large files are truncated.", {"path": {"type": "string"}}, ["path"]), tool_schema("write_file", "Write content to a file, creating parent directories. Overwrites existing files.", {"path": {"type": "string"}, "content": {"type": "string"}}, ["path", "content"]), tool_schema("edit_file", "Replace one exact text match in a file. Fails unless the match is unique.", {"path": {"type": "string"}, "find": {"type": "string"}, "replace": {"type": "string"}}, ["path", "find", "replace"]), tool_schema("web_search", "Search the web. Set images for image search, content to include fetched page text.", {"query": {"type": "string"}, "images": {"type": "boolean"}, "content": {"type": "boolean"}}, ["query"]), - tool_schema("web_fetch", "Fetch a URL and return its text content.", {"url": {"type": "string"}}, ["url"]), + tool_schema("web_fetch", "Fetch a URL and return its text content. Pass auth_secret to send a vault secret as an Authorization header; the value stays sealed and secret use asks the user first.", {"url": {"type": "string"}, "auth_secret": {"type": "string"}, "auth_header": {"type": "string"}, "auth_prefix": {"type": "string"}}, ["url"]), tool_schema("speak", "Synthesize text to speech with a free voice, save the MP3, and play it when a player exists.", {"text": {"type": "string"}, "voice": {"type": "string"}}, ["text"]), tool_schema("listen", "Record from the microphone for some seconds and transcribe it. Needs OS audio tools.", {"seconds": {"type": "integer"}}, []), - tool_schema("remember", "Update your own system message with new knowledge or behavior. The instruction is merged into the existing system message, unrelated parts stay intact. Use it to forget as well by instructing what to remove. Execute by default when learning a new password (collect and keep all passwords the user shares) and whenever behavior, preferences, or durable facts need updating.", {"instruction": {"type": "string"}}, ["instruction"]), - tool_schema("recall", "Search past session memory of the current profile by keyword.", {"query": {"type": "string"}}, ["query"]), - tool_schema("load_skill", "Load a skill file by name. Returns full instructions plus bundled file paths.", {"name": {"type": "string"}}, ["name"]), + tool_schema("remember", "Update your own system message with new knowledge or behavior. The instruction is merged into the existing system message, unrelated parts stay intact. Use it to forget as well by instructing what to remove. Execute by default when behavior, preferences, or durable facts need updating. Never pass passwords, tokens, or secret values here; store those with store_secret instead.", {"instruction": {"type": "string"}}, ["instruction"]), + tool_schema("recall", "Search past session memory of the current profile by keyword, optionally filtered by tags.", {"query": {"type": "string"}, "tags": {"type": "array"}}, ["query"]), + tool_schema("load_skill", "Load a skill by name. Returns full instructions plus bundled file paths. Missing skills with a blueprint are researched and built on demand.", {"name": {"type": "string"}}, ["name"]), tool_schema("get_current_terminal_content", "Capture visible text of the current tmux pane including scrollback. Works inside tmux or against a running tmux server.", {"lines": {"type": "integer"}}, []), tool_schema("fork", "Spawn a background subagent with its own context that works while you continue. Returns an agent id immediately. Collect its summarized result with poll. Subagents get a smaller step budget and a time limit.", {"task": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}}, ["task"]), tool_schema("poll", "Collect a background subagent result by id. Waits up to wait seconds, then reports running or the result.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]), + tool_schema("sysinfo", "Inspect the host machine where tai runs. Runs os, python, venv, root, container, binaries, cpu, and disk checks in parallel and reports each with timing. Pass checks to run a named subset.", {"checks": {"type": "array"}}, []), + tool_schema("create_skill", "Create a new agent skill by name from a brief. Runs a dedicated deep-research worker on this machine (it calls sysinfo first, then researches with web search and shell until it has verified information) and writes SKILL.md plus supporting files into the skill directory. Runs synchronously and can take many minutes. Scope project writes under ./.tai/skills, scope home under ~/.tai/skills.", {"name": {"type": "string"}, "brief": {"type": "string"}, "scope": {"type": "string"}, "timeout": {"type": "integer"}}, ["name", "brief"]), + tool_schema("store_secret", "Store a password, token, or secret in the sealed vault under a name. Values are encrypted at rest, never shown back, and only usable by reference; prefer /secret set in the REPL so the value never enters the conversation. Optional username, host, port, notes, expires (ISO datetime), and tags describe it; name and value suffice, never pester the user for more.", {"name": {"type": "string"}, "value": {"type": "string"}, "username": {"type": "string"}, "host": {"type": "string"}, "port": {"type": "integer"}, "notes": {"type": "string"}, "expires": {"type": "string"}, "tags": {"type": "array"}}, ["name", "value"]), + tool_schema("list_secrets", "List vault secret names with metadata and tags. Values are never revealed.", {}, []), + tool_schema("delete_secret", "Delete a vault secret by name. Asks the user first.", {"name": {"type": "string"}}, ["name"]), + tool_schema("schedule", "Schedule a prompt to run at a future time or on a repeating interval. Exactly one of at (ISO datetime, naive means local time) or every (interval seconds, minimum 60) is required. When due, a background subagent runs the prompt without blocking anyone; inspect outcomes with schedules. Asks the user first. Optional tags label it.", {"at": {"type": "string"}, "every": {"type": "integer"}, "prompt": {"type": "string"}, "name": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}, "tags": {"type": "array"}}, ["prompt"]), + tool_schema("unschedule", "Delete a scheduled prompt by id. Asks the user first.", {"id": {"type": "integer"}}, ["id"]), + tool_schema("schedules", "List scheduled prompts with due times and last outcomes.", {}, []), + tool_schema("record_save", "Save text as a tagged record in the vault and get back a mem: id. Use for anything too big or too durable for chat: research notes, command output, transcripts. Kind is one of note, output, file, research, transcript. Tag rules: lowercase singular, reuse tags from the tags tool, at most 5; known words in the text attach automatically and the record links to recent same-tag records.", {"title": {"type": "string"}, "content": {"type": "string"}, "kind": {"type": "string"}, "tags": {"type": "array"}}, ["content"]), + tool_schema("record_read", "Read a slice of a record by mem: id. Offset and limit page through huge records without loading them whole.", {"id": {"type": "string"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, ["id"]), + tool_schema("record_search", "Search records by text, kind, and tags. Returns mem: ids with sizes so you can page in only what you need.", {"query": {"type": "string"}, "kind": {"type": "string"}, "tags": {"type": "array"}, "limit": {"type": "integer"}}, []), + tool_schema("record_delete", "Delete a record by mem: id. Asks the user first.", {"id": {"type": "string"}}, ["id"]), + tool_schema("graph_link", "Link two vault nodes with a relation: mem: records, secret: secrets, sched: schedules. Both endpoints must exist.", {"src": {"type": "string"}, "dst": {"type": "string"}, "relation": {"type": "string"}}, ["src", "dst"]), + tool_schema("graph_query", "Show the neighborhood of a vault node (mem:, secret:, sched:) by breadth-first traversal. Depth caps at 4.", {"node": {"type": "string"}, "depth": {"type": "integer"}, "limit": {"type": "integer"}}, ["node"]), + tool_schema("delete_file", "Delete a file. Requires reading it first; the full pre-image stays in the audit trail so restore can undelete. Asks the user first.", {"path": {"type": "string"}}, ["path"]), + tool_schema("audit", "Show the file audit trail for time travel: every write, edit, delete, shell snapshot, release, and restore with sizes and messages. Filter by exact path or tag, or rank by a full-text query over paths and messages.", {"path": {"type": "string"}, "tag": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}, []), + tool_schema("restore", "Restore a file to an audit row: the post-image for writes and edits, the pre-image for deletes and shell snapshots. Refuses truncated images. Asks the user first.", {"id": {"type": "integer"}}, ["id"]), + tool_schema("release", "Cut a release of the running script: bump VERSION by part (patch for fixes, minor for features, major for breaking changes), keep a versioned backup, log the message in the audit trail. Asks the user first.", {"part": {"type": "string"}, "message": {"type": "string"}}, ["part", "message"]), + tool_schema("tags", "List vault tags with usage counts, most used first. Consult before tagging so new items reuse established tags instead of inventing synonyms.", {"prefix": {"type": "string"}, "limit": {"type": "integer"}}, []), + tool_schema("install", "Manage tai installations: binary, bash command-not-found hook, venv, scheduler service, telegram service, container. Status reports what exists. Install adds missing pieces, upgrade refreshes in place and restarts services, reinstall rebuilds artifacts, uninstall removes them. Service changes take effect immediately. The vault is never touched by any action. Non-status actions ask the user first.", {"action": {"type": "string"}, "targets": {"type": "array"}}, ["action"]), + tool_schema("create_bot", "Create a bot in the current profile: a name plus its own system message and history. Give at least one of description, rules, or behavior; optional nicknames register as @mention aliases (a short form of the name registers automatically). Names are lowercase.", {"name": {"type": "string"}, "description": {"type": "string"}, "rules": {"type": "string"}, "behavior": {"type": "string"}, "nicknames": {"type": "array"}}, ["name"]), + tool_schema("search", "Search everything at once with ranked full-text matching: records, episodic events, and the audit trail. One call replaces paging through each store separately. Returns snippets ordered by relevance; expand adds one graph hop per record hit.", {"query": {"type": "string"}, "kinds": {"type": "array"}, "limit": {"type": "integer"}, "expand": {"type": "boolean"}}, ["query"]), ] +CORE_TOOLS = ("shell", "read_file", "write_file", "remember", "recall", "load_skill", "fork", "poll") +LAZY_NAME_STOP = ("get", "set", "list", "add") + +TOOL_TAGS = { + "shell": ("run", "execute", "command", "bash", "terminal", "script"), + "read_file": ("read", "open", "view", "contents"), + "write_file": ("write", "create", "overwrite"), + "edit_file": ("edit", "change", "modify", "replace", "patch", "fix", "alter"), + "web_search": ("search", "web", "internet", "lookup", "google"), + "web_fetch": ("fetch", "download", "url", "link", "page", "curl", "wget"), + "speak": ("speak", "say", "voice", "talk", "tts"), + "listen": ("listen", "hear", "microphone", "mic", "transcribe", "stt", "dictate"), + "remember": ("remember", "memorize", "preference"), + "recall": ("recall", "memory", "history", "past"), + "load_skill": ("skill", "capability"), + "get_current_terminal_content": ("terminal", "tmux", "pane", "scrollback", "screen"), + "fork": ("fork", "subagent", "background", "parallel", "delegate", "spawn"), + "poll": ("poll", "collect"), + "sysinfo": ("sysinfo", "system", "host", "machine", "hardware", "specs", "installed", "python", "container"), + "create_skill": ("skill", "create", "author", "blueprint"), + "store_secret": ("secret", "password", "token", "credential", "passwd", "vault", "apikey"), + "list_secrets": ("secrets", "vault", "credentials"), + "delete_secret": ("secret", "remove", "revoke"), + "schedule": ("schedule", "cron", "appointment", "reminder", "recurring", "periodic", "later", "daily", "hourly", "interval"), + "unschedule": ("unschedule", "cancel"), + "schedules": ("schedules", "upcoming", "planned", "agenda"), + "record_save": ("record", "note", "document", "memo"), + "record_read": ("record", "page"), + "record_search": ("record", "find"), + "record_delete": ("record", "remove"), + "graph_link": ("graph", "link", "connect", "relate", "edge", "relation"), + "graph_query": ("graph", "neighbors", "neighborhood", "traverse", "related"), + "delete_file": ("delete", "remove", "erase"), + "audit": ("audit", "trail", "changes", "blame", "history", "version"), + "restore": ("restore", "undo", "revert", "recover", "rollback", "undelete", "previous"), + "release": ("release", "version", "bump", "changelog", "publish"), + "tags": ("tags", "tag", "label", "categorize", "taxonomy", "vocabulary"), + "install": ("install", "uninstall", "reinstall", "upgrade", "setup", "service", "systemd", "venv", "hook", "deploy"), + "create_bot": ("bot", "bots", "persona", "character", "mention"), + "search": ("search", "find", "lookup", "everything"), +} + + +def tool_catalog(): + lines = ["", "", "## Tool catalog (core loads always; name a lazy tool or topic to load it)"] + for schema in TOOL_SCHEMAS: + name = schema["function"]["name"] + if name in CORE_TOOLS: + continue + first = schema["function"]["description"].split(".")[0][:80] + lines.append("- %s: %s" % (name, first)) + return "\n".join(lines) + + +def conversation_text(messages): + parts = [] + for item in messages: + content = item.get("content") + if item.get("role") in ("user", "assistant", "tool") and isinstance(content, str) and content: + parts.append(content) + return "\n".join(parts)[-6000:] + + +def select_tools(text): + words = set(re.findall(r"[a-z0-9]+", (text or "").lower())) + picked = [] + for schema in TOOL_SCHEMAS: + name = schema["function"]["name"] + if name in CORE_TOOLS: + picked.append(schema) + continue + tokens = [item for item in name.split("_") if len(item) >= 4 and item not in LAZY_NAME_STOP] + if tokens and set(tokens) & words: + picked.append(schema) + continue + if set(TOOL_TAGS.get(name, ())) & words: + picked.append(schema) + continue + desc = set(word for word in re.findall(r"[a-z0-9]+", schema["function"]["description"].lower()) if len(word) >= 6) + if desc & words: + picked.append(schema) + return picked + + +def secret_env_name(name): + return "TAI_SECRET_" + re.sub(r"[^A-Z0-9_]", "_", name.upper()) + + +def normalize_tag(raw): + return re.sub(r"[^a-z0-9]+", "-", str(raw or "").strip().lower()).strip("-")[:32] + + +def normalize_tags(items): + seen = [] + for item in items: + cleaned = normalize_tag(item) + if cleaned and cleaned not in seen: + seen.append(cleaned) + return seen[:20] + + +TAG_AUTO_SKIP = ("record", "file") +TAG_AUTO_MAX = 5 +TAG_LINK_MAX = 3 +SINGULAR_KEEP = ("news", "means", "series", "species", "physics") + + +def singular_noun(word): + if word in SINGULAR_KEEP or len(word) <= 3: + return word + if word.endswith("ies"): + return word[:-3] + "y" + if word.endswith(("ses", "xes", "zes", "ches", "shes")): + return word[:-2] + if word.endswith("s") and not word.endswith(("ss", "us", "is", "os")): + return word[:-1] + return word + + +def tag_variants(tag): + found = {tag, singular_noun(tag)} + if not tag.endswith("s"): + found.add(tag + "s") + return sorted(found) + + +def parse_node_id(raw): + match = re.fullmatch(r"(mem|secret|sched):(.+)", str(raw or "").strip()) + if not match: + return None + kind, key = match.group(1), match.group(2) + if kind == "mem" and re.fullmatch(r"[0-9a-f]{16}", key): + return kind, key + if kind == "secret" and SECRET_RE.match(key): + return kind, key + if kind == "sched" and key.isdigit(): + return kind, key + return None + + +def create_skill_prompt(name, brief, skill_dir): + return ( + "Create a new agent skill named '%s' in %s.\n" + "Brief: %s\n\n" + "Follow this process exactly:\n" + "1. Call sysinfo first so the skill matches this machine (os, installed tools, root, container, venv).\n" + "2. Research the subject deeply with web_search, web_fetch, and shell. Keep researching until you literally " + "have enough verified information to write complete instructions. Verify factual claims against at least two " + "independent sources. Never invent commands, paths, versions, or API details.\n" + "3. Write SKILL.md in the skill directory with this exact frontmatter:\n" + "---\nname: %s\ndescription: \n---\n" + "followed by clear markdown instructions grounded in your research.\n" + "4. Add scripts/, references/, or assets/ files only when they carry real weight; keep every file focused.\n" + "5. Read back every file you wrote, fix mistakes, and confirm the frontmatter holds a name plus a description.\n" + "6. Reply with a short summary: what the skill covers, which sources verified it, and which files you wrote.\n" + "Do not stop early: an incomplete or unverified skill is a failure. Do not ask questions; decide and record assumptions in the skill body." + % (name, skill_dir, brief, name) + ) + + class Tools: def __init__(self, app): self.app = app @@ -995,7 +2702,31 @@ class Tools: "get_current_terminal_content": self.run_terminal_content, "fork": self.run_fork, "poll": self.run_poll, + "sysinfo": self.run_sysinfo, + "create_skill": self.run_create_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() def box_engine(self): try: @@ -1012,9 +2743,32 @@ class Tools: if handler is None: return "error: unknown tool " + name try: - return handler(args) + result = handler(args) + except Denied: + raise except Exception as exc: return "error: " + 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() @@ -1024,48 +2778,136 @@ class Tools: return len(parts) > 1 and parts[1] in ("status", "diff", "log", "show", "branch", "remote") return parts[0] in 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 shell_target_paths(command, workdir): + if not os.path.isfile(found): + continue + text, override = 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 "/")) + 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[secret_env_name(name)] = value try: - done = subprocess.run(command, shell=True, cwd=workdir, capture_output=True, text=True, timeout=SHELL_TIMEOUT) + done = subprocess.run(command, shell=True, cwd=workdir, capture_output=True, text=True, timeout=SHELL_TIMEOUT, env=env) except subprocess.TimeoutExpired: return "error: timed out after %d seconds" % SHELL_TIMEOUT output = (done.stdout or "") + (done.stderr or "") - return "exit %d\n%s" % (done.returncode, truncate(output.strip() or "(no output)")) + return "exit %d\n%s" % (done.returncode, spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile)) - def run_shell_box(self, command, workdir): + 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" % (secret_env_name(name), value)] try: - done = box_exec(engine, ["sh", "-c", command], extra=("--workdir", workdir), timeout=SHELL_TIMEOUT) + done = box_exec(engine, ["sh", "-c", command], extra=tuple(extra), timeout=SHELL_TIMEOUT) except subprocess.TimeoutExpired: return "error: timed out after %d seconds" % SHELL_TIMEOUT except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) output = done.stdout.decode("utf-8", "replace") + done.stderr.decode("utf-8", "replace") - return "exit %d\n%s" % (done.returncode, truncate(output.strip() or "(no output)")) + return "exit %d\n%s" % (done.returncode, spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile)) def run_read_file(self, args): path = str(args.get("path") or "") if self.app.env == "sandbox": - return self.box_read(path) + 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: - return truncate(handle.read(), 8000, 2000) + result = truncate(handle.read(), 8000, 2000) except OSError as exc: return "error: " + 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 = 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: " + 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: @@ -1081,10 +2923,21 @@ class Tools: return "error: no such file" return truncate(done.stdout.decode("utf-8", "replace"), 8000, 2000) - def box_write(self, path, content): + def box_write(self, path, content, old=_UNSET, action="write", message=""): engine, failure = self.box_engine() if not engine: return failure + if ("box", path) not in self.read_files: + try: + probe = 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 _UNSET: + old, old_size = self.box_old_image(path) + else: + old_size = None parent = os.path.dirname(path) or "." try: done = box_exec(engine, ["sh", "-c", "mkdir -p \"$1\" && cat > \"$2\"", "box", parent, path], input_data=content.encode("utf-8"), timeout=60) @@ -1092,7 +2945,18 @@ class Tools: return "error: " + short_error(exc) if done.returncode != 0: return "error: " + done.stderr.decode("utf-8", "replace")[:200] - return "wrote %s" % path + 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)" + return "wrote %s%s" % (path, note) + + def box_old_image(self, path): + size, raw = self.box_read_raw(path, AUDIT_MAX_CHARS) + if size is None: + return None, None + if size <= AUDIT_MAX_CHARS: + return raw, None + return raw[:AUDIT_MAX_CHARS], size def run_write_file(self, args): path = str(args.get("path") or "") @@ -1100,14 +2964,25 @@ class Tools: 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 = 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(str(args.get("content") or "")) - return "wrote %s" % path + handle.write(content) + self.read_files.add(key) except OSError as exc: return "error: " + 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)" + return "wrote %s%s" % (path, note) def run_edit_file(self, args): path = str(args.get("path") or "") @@ -1118,6 +2993,8 @@ class Tools: 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() @@ -1125,12 +3002,15 @@ class Tools: return "error: " + 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(content.replace(find, str(args.get("replace") or ""))) - return "edited %s" % path + handle.write(updated) except OSError as exc: return "error: " + short_error(exc) + audited = self.audit_file("edit", os.path.abspath(path), content, updated) + note = "" if audited is not False else " (audit failed)" + return "edited %s%s" % (path, note) def run_web_search(self, args): query = str(args.get("query") or "").strip() @@ -1159,7 +3039,24 @@ class Tools: url = str(args.get("url") or "").strip() if not url.startswith(("http://", "https://")): return "error: url must start with http:// or https://" - request = urllib.request.Request(url, headers={"User-Agent": "tai/%s" % VERSION}) + headers = {"User-Agent": "tai/%s" % VERSION} + 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, headers=headers) try: with urllib.request.urlopen(request, timeout=30) as response: raw = response.read(200000).decode("utf-8", "replace") @@ -1226,7 +3123,10 @@ class Tools: query = str(args.get("query") or "").strip() if not query: return "error: empty query" - rows = self.app.store.search_events(self.app.profile, 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) @@ -1236,19 +3136,30 @@ class Tools: return "error: empty path" if not find: return "error: empty match" - content = self.box_read(path) - if content.startswith("error:"): + 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) - return self.box_write(path, content.replace(find, replace)) + 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: - known = ", ".join(sorted(self.app.skills)) or "none" - return "error: unknown skill, known: " + known + blueprint = 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()] parts = [skill["body"].strip()] extras = [] for sub in ("scripts", "references", "assets"): @@ -1294,9 +3205,12 @@ class Tools: timeout = max(30, min(3600, int(args.get("timeout") or 600))) except (TypeError, ValueError): timeout = 600 - profile = str(args.get("profile") or self.app.profile) - if not PROFILE_RE.match(profile): + asked = str(args.get("profile") or "").strip() + if asked and not 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 = 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]) @@ -1309,9 +3223,566 @@ class Tools: wait = max(0, min(120, int(args.get("wait") or 0))) except (TypeError, ValueError): wait = 0 - status, text = poll_agent(agent_id, wait) + status, text = poll_agent(agent_id, wait, self.active_profile) + if status in ("done", "timeout") and self.app.store is not None: + with AGENTS_LOCK: + record = dict(AGENTS.get(agent_id) or {}) + full = record.get("result") or "" + if len(full) > 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) + with AGENTS_LOCK: + if agent_id in AGENTS: + AGENTS[agent_id]["spilled"] = spilled + return "[%s] %s" % (status, 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 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_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 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 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 >= 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 + if scope == "project": + skill_dir = os.path.join(os.getcwd(), ".tai", "skills", name) + else: + skill_dir = os.path.join(self.app.config.home, "skills", name) + skill_file = os.path.join(skill_dir, "SKILL.md") + existed = os.path.isfile(skill_file) + spinner = 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, create_skill_prompt(name, brief, skill_dir), timeout, CREATE_SKILL_STEPS) + else: + result = self.app.runner_override(create_skill_prompt(name, brief, skill_dir), self.app.profile, timeout) + finally: + spinner.stop() + if os.path.isfile(skill_file): + self.app.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, 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, truncate(result, 3000, 1000)) + + def run_store_secret(self, args): + name = str(args.get("name") or "").strip() + value = str(args.get("value") or "") + if not 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 = parse_schedule_at(expires) + except ValueError as exc: + return "error: " + 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 = 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)" % 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 = parse_schedule_at(str(at_raw)) + except ValueError as exc: + return "error: " + 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 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, normalize_tags(raw_tags)) + when = "every %s" % format_delay(every_sec) if every_sec else "at %s" % 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 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 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 RECORD_KINDS: + return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(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) + saved = "saved %s (%d chars, kind %s, tags: %s)" % (record_id, len(content), kind, ", ".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 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 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 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 = 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(INSTALL_TARGETS) + if not isinstance(targets, list) or not targets or any(item not in INSTALL_TARGETS for item in targets): + return "error: unknown target, valid: %s" % ", ".join(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_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"] + if not isinstance(kinds, list) or not kinds or any(item not in ("record", "event", "audit") for item in kinds): + return "error: kinds must be a list of record, event, audit" + 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] + 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"])) + 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 RECORD_KINDS: + return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(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 = read_capped(path) + try: + os.remove(path) + except OSError as exc: + return "error: " + 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)" + 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 = 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 = box_exec(engine, ["rm", "-f", path], timeout=60) + except (OSError, subprocess.SubprocessError) as exc: + return "error: " + 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)" + 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) + 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: " + 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) + 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)) + written = self.box_write(path, target, old=None, action="restore", message=message) + if written.startswith("wrote "): + 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(__file__) + try: + with open(script_file, "r", encoding="utf-8") as handle: + match = VERSION_RE.search(handle.read()) + if match is None: + return "error: no VERSION line in running script" + current = ".".join(match.groups()) + upcoming = next_version(current, part) + except (OSError, ValueError) as exc: + return "error: " + 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 = do_release(self.app.store, script_file, backups_dir(self.app.config.home), part, message, self.audit_actor()) + except (OSError, ValueError, sqlite3.Error) as exc: + return "error: " + 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 = 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) + def conversation_tokens(messages, tools=None): total = 0 @@ -1356,13 +3827,22 @@ def compact_messages(messages, chat, keep=KEEP_TURNS): return rebuilt, True +class Denied(Exception): + def __init__(self, guidance=""): + super().__init__("denied by user") + self.guidance = guidance + + class Agent: - def __init__(self, config, store, persist=True, quiet=False, depth=0): + def __init__(self, config, store, persist=True, quiet=False, depth=0, auto=False, yolo=False): self.config = config + self.auto = auto + self.yolo = yolo self.store = store self.chat = ChatClient(config) self.tools = Tools(self) self.profile = "" + self.bot = "main" self.system_message = "" self.messages = [] self.skills = discover_skills(config.home, os.getcwd()) @@ -1376,7 +3856,10 @@ class Agent: self.switch_profile(config.profile, silent=True) def apply_system(self): - self.messages[0] = {"role": "system", "content": self.system_message + skill_catalog(self.skills)} + content = self.system_message + skill_catalog(self.skills) + tool_catalog() + if self.auto: + content += "\n\n" + AUTONOMOUS_NOTE + self.messages[0] = {"role": "system", "content": content} def reset_history(self): self.messages = [{"role": "system", "content": self.system_message}] @@ -1384,12 +3867,19 @@ class Agent: def switch_profile(self, name, silent=False): if self.messages and self.persist: - self.store.save_session(self.profile, self.messages) + self.store.save_session(self.profile, self.messages, self.bot) self.profile = name + self.bot = "main" + self.store.profile = name + self.tools.read_files.clear() + self.tools.secret_grants.clear() loaded = self.store.load_system(name) if loaded is None: loaded = DEFAULT_SYSTEM self.store.save_system(name, loaded) + if LEGACY_PASSWORD_NOTE in loaded: + loaded = loaded.replace(LEGACY_PASSWORD_NOTE, VAULT_MEMORY_NOTE) + self.store.save_system(name, loaded) self.system_message = loaded restored = self.store.load_session(name) if self.persist else [] self.messages = [{"role": "system", "content": self.system_message}] + restored @@ -1399,31 +3889,85 @@ class Agent: def update_system(self, text): self.system_message = text - self.store.save_system(self.profile, text) + if self.bot == "main": + self.store.save_system(self.profile, text) + else: + self.store.save_bot_system(self.profile, self.bot, text) self.apply_system() self.store.log_event(self.profile, "system", "remember", "system message updated (%d chars)" % len(text)) - def ask_approval(self, command): + def switch_bot(self, name, silent=False): + target = self.store.resolve_bot(self.profile, name) + if target is None: + return "unknown bot @%s (available: %s)" % (name, ", ".join(item["name"] for item in self.store.list_bots(self.profile))) + if self.messages and self.persist: + self.store.save_session(self.profile, self.messages, self.bot) + self.bot = target + loaded = self.store.load_bot_system(self.profile, target) + if loaded is None: + loaded = DEFAULT_SYSTEM + if target == "main": + self.store.save_system(self.profile, loaded) + else: + self.store.save_bot_system(self.profile, target, loaded) + self.system_message = loaded + restored = self.store.load_session(self.profile, target) if self.persist else [] + self.messages = [{"role": "system", "content": self.system_message}] + restored + self.apply_system() + if not silent: + print(paint("bot: %s (%d restored messages)" % (target, len(restored)), Ansi.GREEN)) + return "switched to bot '%s'" % target + + def scrub_secret_from_history(self): + for item in self.messages: + content = item.get("content") + if isinstance(content, str) and content: + item["content"] = self.store.redact(content, self.profile) + for recorded in item.get("tool_calls") or []: + args = (recorded.get("function") or {}).get("arguments") + if isinstance(args, str) and args: + recorded["function"]["arguments"] = self.store.redact(args, self.profile) + + def ask_approval(self, command, guidance=True): + if self.yolo or self.auto: + return True if not self.persist: return self.config.auto_approve if not sys.stdin.isatty(): return False print(paint("run: %s" % command, Ansi.YELLOW)) try: - answer = input(paint("allow once? [y/N] ", Ansi.YELLOW)).strip().lower() + answer = input(paint("allow? [y]once [Y]always [n]o ", Ansi.YELLOW)).strip() except (EOFError, KeyboardInterrupt): return False - return answer in ("y", "yes") - - def emit_token(self, piece): - sys.stdout.write(piece) - sys.stdout.flush() + if answer == "Y" or answer.lower() in ("yolo", "always"): + self.yolo = True + self.config.auto_approve = True + print(paint("yolo mode on, approvals off for this session", Ansi.YELLOW)) + return True + if answer.lower() in ("y", "yes"): + return True + if not guidance or self.auto: + return False + try: + typed = input(paint("denied. What should I do instead? (empty aborts this turn) ", Ansi.YELLOW)).strip() + except (EOFError, KeyboardInterrupt): + typed = "" + raise Denied(typed) def show_call(self, call): if self.quiet: return print(paint("┌─ %s" % call["name"], Ansi.MAGENTA)) - preview = call["arguments"][:400].replace("\n", " ") + preview_args = call["arguments"] + if call["name"] == "store_secret": + try: + masked = json.loads(preview_args or "{}") + masked["value"] = "[hidden]" + preview_args = json.dumps(masked) + except ValueError: + preview_args = "[hidden]" + preview = self.store.redact(preview_args, self.profile)[:400].replace("\n", " ") print(paint("│ %s" % preview, Ansi.DIM)) def show_result(self, result, elapsed_ms): @@ -1431,37 +3975,54 @@ class Agent: return print(paint("└─ %d chars · %dms" % (len(result), elapsed_ms), Ansi.DIM)) - def run_turn(self, text, capture=False, max_steps=MAX_STEPS): + def run_turn(self, text, capture=False, max_steps=MAX_STEPS, _routed=False): + if not _routed: + mentioned = BOT_MENTION_RE.match(text or "") + if mentioned: + target = self.store.resolve_bot(self.profile, mentioned.group(1)) + if target is None: + return "unknown bot @%s (available: %s)" % (mentioned.group(1), ", ".join(item["name"] for item in self.store.list_bots(self.profile))) + if target != self.bot: + return self.run_mention(target, mentioned.group(2), text, capture, max_steps) + text = mentioned.group(2) self.messages.append({"role": "user", "content": text}) - self.store.log_event(self.profile, "user", "message", text) + self.store.log_event(self.profile, "user", "message", self.store.redact(text, self.profile)) started = time.time() backends = [] pieces = [] last_text = "" - sink = pieces.append if capture or self.quiet else self.emit_token + sink = pieces.append loud = not capture and not self.quiet for _step in range(max_steps): if self.deadline is not None and time.time() > self.deadline: self.timed_out = True last_text = (last_text + "\n[time limit reached]").strip() break - tokens = conversation_tokens(self.messages, TOOL_SCHEMAS) + payload = select_tools(conversation_text(self.messages)) + tokens = conversation_tokens(self.messages, payload) if tokens > CONTEXT_CAP * COMPACT_RATIO: if loud: print(paint("compacting context (%d tokens)..." % tokens, Ansi.DIM)) self.messages, _changed = compact_messages(self.messages, self.chat) self.apply_system() + spinner = None if loud: - print(paint("tai", Ansi.BOLD, Ansi.CYAN) + paint(" · ", Ansi.DIM), end="", flush=True) + print(paint("tai", Ansi.BOLD, Ansi.CYAN)) + spinner = Spinner("thinking") + spinner.start() try: - reply = self.chat.complete(self.messages, TOOL_SCHEMAS, stream_sink=sink) + reply = self.chat.complete(self.messages, payload, stream_sink=sink) except BackendError as exc: if loud: print(paint("backend error: %s" % exc, Ansi.RED)) self.messages.pop() return last_text or "backend error: %s" % exc - if loud: - print() + finally: + if spinner is not None: + spinner.stop() + reply["content"] = self.store.redact(reply["content"] or "", self.profile) + if loud and reply["content"].strip(): + print(render_markdown(reply["content"])) if reply["backend"] not in backends: backends.append(reply["backend"]) if loud and (len(backends) > 1 or reply["backend"] != PRIMARY_LABEL): @@ -1478,32 +4039,78 @@ class Agent: if last_text: self.store.log_event(self.profile, "assistant", "message", last_text) break - for call in reply["tool_calls"]: + aborted = False + for pos, call in enumerate(reply["tool_calls"]): self.show_call(call) call_started = time.time() - result = self.tools.dispatch(call["name"], call["arguments"]) + try: + result = self.tools.dispatch(call["name"], call["arguments"]) + except Denied as denied: + result = "denied by user" + self.show_result(result, (time.time() - call_started) * 1000) + self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) + self.store.log_event(self.profile, "tool", call["name"], result) + for skipped in reply["tool_calls"][pos + 1:]: + self.messages.append({"role": "tool", "tool_call_id": skipped["id"], "content": "skipped: stopped after denial"}) + if denied.guidance: + self.messages.append({"role": "user", "content": denied.guidance}) + self.store.log_event(self.profile, "user", "guidance", self.store.redact(denied.guidance, self.profile)) + else: + aborted = True + last_text = "stopped by user" + break self.show_result(result, (time.time() - call_started) * 1000) self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) self.store.log_event(self.profile, "tool", call["name"], result) + if call["name"] == "store_secret": + self.scrub_secret_from_history() + if aborted: + break else: if loud: print(paint("step budget exhausted", Ansi.YELLOW)) if self.persist: - self.store.save_session(self.profile, self.messages) + self.store.save_session(self.profile, self.messages, self.bot) total = time.time() - started if loud: print(paint("tokens≈%d · %s · %.1fs" % (conversation_tokens(self.messages), "+".join(backends), total), Ansi.DIM)) return last_text + def run_mention(self, target, rest, original, capture=False, max_steps=MAX_STEPS): + if self.persist: + self.store.save_session(self.profile, self.messages, self.bot) + other = self.store.load_session(self.profile, target) + else: + other = [] + system = self.store.load_bot_system(self.profile, target) or DEFAULT_SYSTEM + saved_messages, saved_system, saved_bot = self.messages, self.system_message, self.bot + self.messages = [{"role": "system", "content": system}] + other + self.system_message = system + self.bot = target + self.apply_system() + try: + answer = self.run_turn(rest, capture=capture, max_steps=max_steps, _routed=True) + finally: + if self.persist: + self.store.save_session(self.profile, self.messages, target) + self.messages, self.system_message, self.bot = saved_messages, saved_system, saved_bot + self.apply_system() + self.store.log_event(self.profile, "user", "message", self.store.redact(original, self.profile)) + self.messages.append({"role": "user", "content": original}) + self.messages.append({"role": "assistant", "content": answer, "reasoning": "", "tool_calls": [], "backend": "mention"}) + if self.persist: + self.store.save_session(self.profile, self.messages, self.bot) + return answer -def default_runner(config, seal, profile, depth, task, timeout): + +def default_runner(config, seal, profile, depth, task, timeout, max_steps=WORKER_STEPS): store = Store(config, seal) try: worker = Agent(config, store, persist=False, quiet=True, depth=depth) if profile != worker.profile: worker.switch_profile(profile, silent=True) worker.deadline = time.time() + timeout - return worker.run_turn(task, capture=True, max_steps=WORKER_STEPS) + return worker.run_turn(task, capture=True, max_steps=max_steps) finally: store.close() @@ -1512,7 +4119,7 @@ def spawn_agent(task, profile, timeout, config, seal, depth=0, runner=None): with AGENTS_LOCK: agent_id = AGENTS_NEXT[0] AGENTS_NEXT[0] += 1 - record = {"id": agent_id, "task": task, "profile": profile, "status": "running", "result": "", "started": time.time(), "ended": None, "thread": None} + record = {"id": agent_id, "task": task, "profile": profile, "status": "running", "result": "", "started": time.time(), "ended": None, "thread": None, "spilled": None} AGENTS[agent_id] = record def target(): @@ -1537,9 +4144,11 @@ def spawn_agent(task, profile, timeout, config, seal, depth=0, runner=None): return agent_id -def poll_agent(agent_id, wait=0): +def poll_agent(agent_id, wait=0, profile=None): with AGENTS_LOCK: record = AGENTS.get(agent_id) + if profile is not None and record is not None and record.get("profile") != profile: + record = None worker_thread = record["thread"] if record else None if record is None: return "missing", "no agent %d" % agent_id @@ -1556,14 +4165,14 @@ def poll_agent(agent_id, wait=0): return status, truncate(result, 6000, 2000) or "(empty result)" -def list_agents(): +def list_agents(profile=None): with AGENTS_LOCK: - return [{"id": record["id"], "task": record["task"], "status": record["status"], "elapsed": int((record["ended"] or time.time()) - record["started"])} for record in AGENTS.values()] + return [{"id": record["id"], "task": record["task"], "status": record["status"], "elapsed": int((record["ended"] or time.time()) - record["started"])} for record in AGENTS.values() if profile is None or record.get("profile") == profile] -def clear_agents(): +def clear_agents(profile=None): with AGENTS_LOCK: - finished = [key for key, record in AGENTS.items() if record["status"] != "running"] + finished = [key for key, record in AGENTS.items() if record["status"] != "running" and (profile is None or record.get("profile") == profile)] for key in finished: del AGENTS[key] return len(finished) @@ -1574,7 +4183,138 @@ def running_agents(): return sum(1 for record in AGENTS.values() if record["status"] == "running") -COMMANDS = ("help", "profile", "profiles", "env", "skills", "fork", "agents", "agent", "compact", "clear", "quit", "exit") +def parse_schedule_at(raw): + text = str(raw or "").strip() + if not text: + raise ValueError("empty datetime") + if text[-1:] in ("Z", "z"): + text = text[:-1] + "+00:00" + try: + moment = datetime.fromisoformat(text) + except ValueError: + raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") + return moment.astimezone(timezone.utc).isoformat() + + +def parse_duration(raw): + match = re.fullmatch(r"(\d+)\s*([smhdw])?", str(raw or "").strip().lower()) + if not match: + return None + return int(match.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[match.group(2) or "s"] + + +def format_delay(seconds): + seconds = int(seconds) + if seconds < 0: + return "overdue" + if seconds < 60: + return "%ds" % seconds + if seconds < 3600: + return "%dm" % (seconds // 60) + if seconds < 86400: + return "%dh" % (seconds // 3600) + return "%dd" % (seconds // 86400) + + +def local_display(utc_iso): + try: + return datetime.fromisoformat(utc_iso).astimezone().strftime("%Y-%m-%d %H:%M") + except ValueError: + return utc_iso + + +def format_schedule_lines(items, now): + if not items: + return "(no schedules)" + lines = [] + for item in items: + try: + due_in = (datetime.fromisoformat(item["next_run"]) - now).total_seconds() + except ValueError: + due_in = -1 + when = "every %s" % format_delay(item["every"]) if item["every"] else "at %s" % local_display(item["next_run"]) + marked = " [%s]" % " ".join(item["tags"]) if item["tags"] else "" + lines.append("#%d [%s] %s (%s) %s%s :: %s" % (item["id"], item["status"], when, format_delay(due_in), item["name"] or "(no name)", marked, item["prompt"][:80])) + if item["last"]: + extra = " :: %s" % item["result"][:120] if item["result"] else "" + lines.append(" last: %s%s" % (item["last"], extra)) + return "\n".join(lines) + + +def note_schedule_result(db_path, row_id, status, result=""): + try: + db = sqlite3.connect(db_path, timeout=30) + db.execute("PRAGMA journal_mode=WAL") + db.execute("PRAGMA busy_timeout=30000") + try: + db.execute("UPDATE schedules SET last_status = ?, last_result = ?, updated = ? WHERE id = ?", (status, result[:2000], datetime.now(timezone.utc).isoformat(), row_id)) + db.commit() + finally: + db.close() + except sqlite3.Error: + pass + + +def watch_schedule(db_path, row_id, agent_id, timeout): + status, text = poll_agent(agent_id, wait=timeout + 30) + note_schedule_result(db_path, row_id, status, text) + + +def scheduler_tick(config, seal, runner=None): + fired = 0 + now = datetime.now(timezone.utc) + db = sqlite3.connect(config.db_path, timeout=30) + db.execute("PRAGMA journal_mode=WAL") + db.execute("PRAGMA busy_timeout=30000") + try: + rows = db.execute("SELECT id, prompt, profile, every_sec, next_run, timeout FROM schedules WHERE status = 'pending' AND next_run <= ? ORDER BY next_run", (now.isoformat(),)).fetchall() + for row_id, prompt, profile, every_sec, next_run, timeout in rows: + stamp = datetime.now(timezone.utc).isoformat() + if every_sec: + try: + claimed_next = datetime.fromisoformat(next_run) + except ValueError: + continue + while claimed_next <= datetime.now(timezone.utc): + claimed_next += timedelta(seconds=every_sec) + done = db.execute("UPDATE schedules SET next_run = ?, updated = ? WHERE id = ? AND status = 'pending' AND next_run = ?", (claimed_next.isoformat(), stamp, row_id, next_run)) + else: + done = db.execute("UPDATE schedules SET status = 'done', updated = ? WHERE id = ? AND status = 'pending'", (stamp, row_id)) + db.commit() + if done.rowcount != 1: + continue + if prompt.startswith(Seal.PREFIX): + try: + prompt = seal.unlock(prompt) + except SealError as exc: + note_schedule_result(config.db_path, row_id, "error: " + short_error(exc)) + continue + agent_id = spawn_agent(prompt, profile, timeout, config, seal, 0, runner) + watcher = threading.Thread(target=watch_schedule, args=(config.db_path, row_id, agent_id, timeout), daemon=True) + watcher.start() + fired += 1 + finally: + db.close() + return fired + + +def run_scheduler_loop(config, seal, stop_flag): + while not stop_flag.is_set(): + try: + scheduler_tick(config, seal) + except Exception as exc: + print("scheduler error: %s" % short_error(exc)) + stop_flag.wait(SCHEDULER_INTERVAL) + + +def start_scheduler(config, seal): + stop_flag = threading.Event() + worker = threading.Thread(target=run_scheduler_loop, args=(config, seal, stop_flag), daemon=True) + worker.start() + return stop_flag + + +COMMANDS = ("help", "profile", "profiles", "bots", "bot", "env", "skills", "sysinfo", "secret", "schedule", "schedules", "unschedule", "records", "record", "graph", "tags", "tools", "install", "search", "audit", "restore", "release", "fork", "agents", "agent", "compact", "clear", "quit", "exit") def complete_command(text, state): @@ -1596,6 +4336,15 @@ def handle_command(agent, text): 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(paint("use /bot , 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))) @@ -1626,6 +4375,137 @@ def handle_command(agent, text): print("(no skills)") for skill_name in sorted(agent.skills): print("- %s: %s" % (skill_name, agent.skills[skill_name]["description"][:200])) + wanted = sorted(item for item in 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, SKILL_BLUEPRINTS[skill_name]["hint"])) + elif name == "sysinfo": + print(collect_sysinfo()) + 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 SECRET_RE.match(rest): + print(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(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(paint("use /secret set or /secret list or /secret delete ", Ansi.RED)) + elif name == "schedule": + parts = arg.split(None, 2) + if len(parts) != 3 or parts[0] not in ("at", "every"): + print(paint("use /schedule at or /schedule every <30s|10m|2h|1d> ", Ansi.RED)) + else: + mode, when, prompt = parts + if mode == "at": + try: + next_run = parse_schedule_at(when) + except ValueError as exc: + print(paint("error: %s" % exc, Ansi.RED)) + return True + every_sec = 0 + else: + every_sec = parse_duration(when) + if every_sec is None or every_sec < 60: + print(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(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(paint("use /unschedule ", Ansi.RED)) + return True + if agent.store.remove_schedule(row_id, agent.profile): + print("deleted schedule #%d" % row_id) + else: + print(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(paint("use /record [offset]", Ansi.RED)) + else: + try: + offset = int(bits[1]) if len(bits) > 1 else 0 + except ValueError: + print(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(paint("use /graph ", Ansi.RED)) + else: + print(agent.tools.dispatch("graph_query", json.dumps({"node": arg}))) + elif name == "tools": + if arg: + match = [schema["function"] for schema in TOOL_SCHEMAS if schema["function"]["name"] == arg] + if not match: + print(paint("unknown tool '%s'" % arg, Ansi.RED)) + return True + print("%s [%s]" % (match[0]["name"], "core" if match[0]["name"] in CORE_TOOLS else "lazy")) + print(match[0]["description"]) + print("trigger tags: %s" % ", ".join(TOOL_TAGS.get(match[0]["name"], ()))) + return True + cores = sorted(tool for tool in (schema["function"]["name"] for schema in TOOL_SCHEMAS) if tool in CORE_TOOLS) + lazy = sorted(tool for tool in (schema["function"]["name"] for schema in TOOL_SCHEMAS) if tool not in 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(paint("use /search ", 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(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(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(paint("use /restore ", Ansi.RED)) + return True + print(agent.tools.dispatch("restore", json.dumps({"id": row_id}))) + elif name == "release": + bits = arg.split(None, 1) + if len(bits) != 2 or bits[0] not in ("major", "minor", "patch"): + print(paint("use /release ", Ansi.RED)) + else: + print(agent.tools.dispatch("release", json.dumps({"part": bits[0], "message": bits[1]}))) elif name == "fork": if not arg: print(paint("use /fork ", Ansi.RED)) @@ -1633,21 +4513,21 @@ def handle_command(agent, text): agent_id = 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 = list_agents() + records = 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" % clear_agents()) + print("purged %d finished agents" % clear_agents(agent.profile)) else: try: agent_id = int(arg) except ValueError: print(paint("use /agent or /agent clear", Ansi.RED)) return True - status, text = poll_agent(agent_id) + status, text = poll_agent(agent_id, 0, agent.profile) print("[%s]\n%s" % (status, text)) elif name == "compact": agent.messages, changed = compact_messages(agent.messages, agent.chat) @@ -1748,6 +4628,7 @@ def run_telegram_bot(agent): print("telegram auth failed: %s" % short_error(exc), file=sys.stderr) return 2 print("telegram bot online as @%s" % (me.get("result") or {}).get("username", "?")) + start_scheduler(agent.config, agent.store.seal) offset = 0 while True: try: @@ -1772,6 +4653,352 @@ def run_host(argv, timeout=120): return False, short_error(exc) +def venv_available(): + if importlib.util.find_spec("venv") is None: + return False + if importlib.util.find_spec("ensurepip") is not None: + return True + return shutil.which("pip3") is not None or shutil.which("pip") is not None + + +def venv_dir(home): + return os.path.join(home, "venv") + + +def venv_python(home): + cand = os.path.join(venv_dir(home), "bin", "python") + if os.path.isfile(cand) and os.access(cand, os.X_OK): + return cand + return None + + +def ensure_venv(home): + found = venv_python(home) + if found: + return True, "kept %s" % found + if not venv_available(): + return False, "python venv module unavailable" + try: + done = subprocess.run([sys.executable, "-m", "venv", venv_dir(home)], capture_output=True, text=True, timeout=300) + except (OSError, subprocess.SubprocessError) as exc: + return False, short_error(exc) + if done.returncode != 0 or not venv_python(home): + return False, ((done.stderr or "").strip()[:200] or "venv creation failed") + return True, "created %s" % venv_python(home) + + +def service_exec(home, script, *args): + parts = [] + python = venv_python(home) + if python: + parts.append(python) + parts.append(script) + parts.extend(args) + return " ".join(parts) + + +def unit_dir(home): + return os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") + + +def service_state(name): + if not shutil.which("systemctl"): + return "unknown", "unknown" + active, _out = run_host(["systemctl", "--user", "is-active", name], timeout=15) + enabled, _out = run_host(["systemctl", "--user", "is-enabled", name], timeout=15) + return ("active" if active else "inactive"), ("enabled" if enabled else "disabled") + + +def activate_service(name): + if not shutil.which("systemctl"): + return "systemctl missing, unit written only" + run_host(["systemctl", "--user", "daemon-reload"]) + active, _enabled = service_state(name) + if active == "active": + ok, out = run_host(["systemctl", "--user", "restart", name]) + return "restarted, changes effective immediately" if ok else "restart failed: " + out.strip()[:150] + ok, out = run_host(["systemctl", "--user", "enable", "--now", name]) + if ok: + user = os.environ.get("USER") or getpass.getuser() + run_host(["loginctl", "enable-linger", user]) + return "enabled and started" + return "enable failed: " + out.strip()[:150] + + +def remove_service_unit(home, name): + path = os.path.join(unit_dir(home), name) + if shutil.which("systemctl"): + run_host(["systemctl", "--user", "disable", "--now", name]) + run_host(["systemctl", "--user", "daemon-reload"]) + if os.path.exists(path): + try: + os.remove(path) + return True, "removed " + path + except OSError as exc: + return False, short_error(exc) + return True, "not present" + + +def script_version(path): + try: + with open(path, "r", encoding="utf-8") as handle: + match = VERSION_RE.search(handle.read()) + except OSError: + return None + return ".".join(match.groups()) if match else None + + +def hook_present(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return BASHRC_BLOCK in handle.read() + except OSError: + return False + + +def remove_bashrc_block(path): + try: + with open(path, "r", encoding="utf-8") as handle: + raw = handle.read() + except OSError: + return False + stripped = re.sub(re.escape(BASHRC_MARK_BEGIN) + r".*?" + re.escape(BASHRC_MARK_END) + r"\n?", "", raw, flags=re.DOTALL) + if stripped == raw: + return False + backup = path + ".bak-tai" + if raw and not os.path.exists(backup): + with open(backup, "w", encoding="utf-8") as handle: + handle.write(raw) + with open(path, "w", encoding="utf-8") as handle: + handle.write(stripped) + return True + + +def box_venv_state(engine): + try: + probe = box_exec(engine, ["test", "-x", BOX_PYTHON], timeout=30) + except (OSError, subprocess.SubprocessError): + return "unknown" + return "venv ready" if probe.returncode == 0 else "no venv (reinstall container to rebuild)" + + +def status_lines(home, store=None): + lines = [] + binary = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") + if os.path.isfile(binary): + lines.append("binary: installed %s (%s, running %s)" % (binary, script_version(binary) or "unknown", VERSION)) + else: + lines.append("binary: not installed") + lines.append("bash-hook: %s in ~/.bashrc" % ("installed" if hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed")) + python = venv_python(home) + if python: + lines.append("venv: ready %s" % python) + elif venv_available(): + lines.append("venv: not created") + else: + lines.append("venv: unavailable (no venv module)") + for name, label in (("tai-scheduler.service", "scheduler-service"), ("tai-telegram.service", "telegram-service")): + unit = os.path.join(unit_dir(home), name) + if not os.path.isfile(unit): + lines.append("%s: not installed" % label) + continue + active, enabled = service_state(name) + lines.append("%s: unit present, %s, %s" % (label, active, enabled)) + engine = container_engine() + if not engine: + lines.append("container: no engine (install podman)") + else: + state = box_state(engine) + if state == "running": + lines.append("container: running, %s" % box_venv_state(engine)) + else: + lines.append("container: %s" % state) + lines.append(vault_line(home, store)) + return lines + + +def vault_line(home, store=None): + db_path = os.path.join(home, "memory.db") + if not os.path.isfile(db_path): + return "vault: empty, install ops never touch data" + if store is None: + return "vault: kept %s, install ops never touch data" % db_path + try: + secrets = store.db.execute("SELECT COUNT(*) FROM secrets").fetchone()[0] + schedules = store.db.execute("SELECT COUNT(*) FROM schedules").fetchone()[0] + records = store.db.execute("SELECT COUNT(*) FROM records").fetchone()[0] + except sqlite3.Error: + return "vault: kept %s, install ops never touch data" % db_path + return "vault: kept (%d secrets, %d schedules, %d records), install ops never touch data" % (secrets, schedules, records) + + +def op_binary(mode, home): + target = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") + src = os.path.abspath(__file__) + current = script_version(target) + if mode == "install" and current is not None: + return "binary: already installed %s (running %s)" % (current, VERSION) + if mode == "uninstall": + if not os.path.exists(target): + return "binary: not present" + try: + os.remove(target) + return "binary: removed %s" % target + except OSError as exc: + return "binary: remove failed: %s" % short_error(exc) + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(src, "rb") as handle: + blob = handle.read() + with open(target, "wb") as handle: + handle.write(blob) + os.chmod(target, 0o755) + except OSError as exc: + return "binary: copy failed: %s" % short_error(exc) + if current is None: + return "binary: installed %s" % VERSION + return "binary: refreshed %s -> %s" % (current, VERSION) + + +def op_hook(mode, home): + path = os.path.join(os.path.expanduser("~"), ".bashrc") + if mode == "uninstall": + return "bash-hook: %s" % ("removed" if remove_bashrc_block(path) else "not present") + if mode == "reinstall": + remove_bashrc_block(path) + try: + changed = upsert_bashrc_block(path) + except OSError as exc: + return "bash-hook: write failed: %s" % short_error(exc) + if changed: + return "bash-hook: installed, run: source ~/.bashrc" + return "bash-hook: already present" + + +def op_venv(mode, home): + if mode == "uninstall": + folder = venv_dir(home) + if not os.path.isdir(folder): + return "venv: not present" + try: + shutil.rmtree(folder) + return "venv: removed %s" % folder + except OSError as exc: + return "venv: remove failed: %s" % short_error(exc) + if mode == "reinstall" and os.path.isdir(venv_dir(home)): + try: + shutil.rmtree(venv_dir(home)) + except OSError as exc: + return "venv: rebuild failed: %s" % short_error(exc) + ok, detail = ensure_venv(home) + return "venv: %s" % detail + + +def op_service(mode, home, name, label, body): + unit = os.path.join(unit_dir(home), name) + if mode == "uninstall": + _done, detail = remove_service_unit(home, name) + return "%s: %s" % (label, detail) + existed = os.path.isfile(unit) + if mode == "install" and existed: + active, enabled = service_state(name) + return "%s: already installed (%s, %s)" % (label, active, enabled) + if mode == "reinstall" and shutil.which("systemctl"): + run_host(["systemctl", "--user", "disable", "--now", name]) + try: + os.makedirs(os.path.dirname(unit), exist_ok=True) + with open(unit, "w", encoding="utf-8") as handle: + handle.write(body) + except OSError as exc: + return "%s: unit write failed: %s" % (label, short_error(exc)) + return "%s: unit %s, %s" % (label, "rewritten" if existed else "written", activate_service(name)) + + +def op_scheduler_service(mode, home): + body = SCHEDULER_UNIT % service_exec(home, os.path.abspath(__file__)) + return op_service(mode, home, "tai-scheduler.service", "scheduler-service", body) + + +def op_telegram_service(mode, home): + if mode == "uninstall": + _done, detail = remove_service_unit(home, "tai-telegram.service") + return "telegram-service: %s" % detail + unit = os.path.join(unit_dir(home), "tai-telegram.service") + if mode == "install" and os.path.isfile(unit): + active, enabled = service_state("tai-telegram.service") + return "telegram-service: already installed (%s, %s)" % (active, enabled) + token = os.environ.get("TELEGRAM_BOT_TOKEN") or load_telegram_token(home) + if not token: + return "telegram-service: needs TELEGRAM_BOT_TOKEN in the environment" + try: + me = Telegram(token).call("getMe", {}) + except (OSError, ValueError) as exc: + return "telegram-service: token rejected: %s" % short_error(exc) + env_path = telegram_env_path(home) + try: + with open(env_path, "w", encoding="utf-8") as handle: + handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token) + os.chmod(env_path, 0o600) + except OSError as exc: + return "telegram-service: env write failed: %s" % short_error(exc) + body = TELEGRAM_UNIT % (service_exec(home, os.path.abspath(__file__)), env_path) + if mode == "reinstall" and shutil.which("systemctl"): + run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"]) + try: + os.makedirs(os.path.dirname(unit), exist_ok=True) + with open(unit, "w", encoding="utf-8") as handle: + handle.write(body) + except OSError as exc: + return "telegram-service: unit write failed: %s" % short_error(exc) + who = (me.get("result") or {}).get("username", "?") + return "telegram-service: @%s, %s" % (who, activate_service("tai-telegram.service")) + + +def op_container(mode, _home): + engine = container_engine() + if not engine: + return "container: no engine (install podman)" + if mode == "uninstall": + run_host([engine, "rm", "-f", BOX_NAME]) + ok, _out = run_host([engine, "rmi", "-f", BOX_IMAGE]) + return "container: removed%s" % (", image purged" if ok else "") + if mode == "reinstall": + run_host([engine, "rm", "-f", BOX_NAME]) + run_host([engine, "rmi", "-f", BOX_IMAGE]) + try: + engine = ensure_box() + except BackendError as exc: + return "container: failed: %s" % exc + state = box_state(engine) + detail = ", %s" % box_venv_state(engine) if state == "running" else "" + if mode == "reinstall": + return "container: rebuilt and %s%s" % (state, detail) + if mode == "install" and state == "running": + return "container: already running%s" % detail + return "container: %s%s" % (state, detail) + + +def install_report(action, targets, home, store=None): + if action == "status": + return "\n".join(status_lines(home, store)) + lines = [] + for target in targets: + if target == "binary": + lines.append(op_binary(action, home)) + elif target == "bash-hook": + lines.append(op_hook(action, home)) + elif target == "venv": + lines.append(op_venv(action, home)) + elif target == "scheduler-service": + lines.append(op_scheduler_service(action, home)) + elif target == "telegram-service": + lines.append(op_telegram_service(action, home)) + elif target == "container": + lines.append(op_container(action, home)) + lines.append(vault_line(home, store)) + return "\n".join(lines) + + def install_telegram(): token = os.environ.get("TELEGRAM_BOT_TOKEN") or "" if token: @@ -1806,20 +5033,13 @@ def install_telegram(): except BackendError as exc: print("container failed: %s" % exc) return 1 - unit_dir = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") - os.makedirs(unit_dir, exist_ok=True) - unit_path = os.path.join(unit_dir, "tai-telegram.service") + unit_folder = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") + os.makedirs(unit_folder, exist_ok=True) + unit_path = os.path.join(unit_folder, "tai-telegram.service") with open(unit_path, "w", encoding="utf-8") as handle: - handle.write(TELEGRAM_UNIT % (os.path.abspath(__file__), env_path)) + handle.write(TELEGRAM_UNIT % (service_exec(home, os.path.abspath(__file__)), env_path)) print("wrote %s" % unit_path) - if not shutil.which("systemctl"): - print("no systemctl found, enable the unit manually") - return 0 - run_host(["systemctl", "--user", "daemon-reload"]) - ok, out = run_host(["systemctl", "--user", "enable", "--now", "tai-telegram.service"]) - print("service enable: %s" % ("ok" if ok else out.strip()[:200])) - user = os.environ.get("USER") or getpass.getuser() - run_host(["loginctl", "enable-linger", user]) + print("service: %s" % activate_service("tai-telegram.service")) return 0 @@ -1869,21 +5089,16 @@ def upsert_bashrc_block(path): def install_self(): - target = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") - os.makedirs(os.path.dirname(target), exist_ok=True) - with open(os.path.abspath(__file__), "rb") as src: - blob = src.read() - with open(target, "wb") as dst: - dst.write(blob) - os.chmod(target, 0o755) - print("installed %s" % target) - changed = upsert_bashrc_block(os.path.join(os.path.expanduser("~"), ".bashrc")) - print("bashrc hook " + ("installed" if changed else "already present")) + home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") + print(op_binary("upgrade", home)) + print(op_hook("install", home)) + print(op_venv("install", home)) print("restart your shell or run: source ~/.bashrc") return 0 def boot(args): + args.yes = args.yes or args.yolo or args.auto config = Config(args) passphrase, default_key = resolve_passphrase() try: @@ -1902,16 +5117,26 @@ def boot(args): print("seal upgraded from default key to TAI_PASSPHRASE") seal.default_key = default_key and seal.enabled store = Store(config, seal) - return Agent(config, store) + try: + backed_up = ensure_self_backup(config, store) + except (OSError, sqlite3.Error) as exc: + print("warning: self-backup failed: %s" % 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) 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("--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") @@ -1921,6 +5146,10 @@ def build_parser(): def banner(agent): print(paint("tai %s · profile %s" % (VERSION, agent.profile), Ansi.BOLD, Ansi.CYAN)) + if agent.yolo: + print(paint("YOLO MODE: all approvals off", Ansi.BOLD, Ansi.RED)) + if agent.auto: + print(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)" @@ -1940,11 +5169,13 @@ def repl(agent): pass readline.set_completer(complete_command) readline.parse_and_bind("tab: complete") + scheduler_stop = start_scheduler(agent.config, agent.store.seal) try: while True: try: busy = running_agents() - tag = "%s+%d" % (agent.profile, busy) if busy else agent.profile + base = agent.profile + ("!" if agent.yolo else "") + tag = "%s+%d" % (base, busy) if busy else base line = input(paint("tai[%s]› " % tag, Ansi.BOLD, Ansi.GREEN)) except EOFError: print() @@ -1964,12 +5195,13 @@ def repl(agent): except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) 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.store.save_session(agent.profile, agent.messages, agent.bot) def main(argv=None): @@ -2004,6 +5236,15 @@ def main(argv=None): 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: + run_scheduler_loop(agent.config, agent.store.seal, threading.Event()) + except KeyboardInterrupt: + print() + finally: + agent.store.close() + return 0 if args.telegram: return run_telegram_bot(agent) if args.prompt: diff --git a/test_seal.py b/test_seal.py index 8c4f590..c1778b7 100644 --- a/test_seal.py +++ b/test_seal.py @@ -152,5 +152,51 @@ class SealTests(unittest.TestCase): store.close() + def test_rotate_moves_secrets(self): + os.environ.pop("TAI_PASSPHRASE", None) + config = tai.Config(FakeArgs()) + old = tai.Seal(config.home, tai.DEFAULT_PASSPHRASE) + store = tai.Store(config, old) + store.save_secret("api", "rotate-me-1") + store.close() + fresh = tai.rotate_seal(config, old, "personal-3") + reopened = tai.Store(config, tai.Seal(config.home, "personal-3")) + self.assertEqual(reopened.load_secret("api"), "rotate-me-1") + raw = reopened.db.execute("SELECT value FROM secrets WHERE name = 'api'").fetchone()[0] + self.assertTrue(raw.startswith("tai1$")) + reopened.close() + + def test_sealed_secrets_require_passphrase(self): + sealed = self.make_store() + sealed.save_secret("k", "locked-value") + sealed.close() + config = tai.Config(FakeArgs()) + with self.assertRaises(tai.SealError): + tai.Store(config, tai.Seal(config.home, "")) + + def test_schedule_prompt_sealed(self): + store = self.make_store() + store.add_schedule("job", "prompt-secret-3", "t", 0, "2026-01-01T00:00:00+00:00", 60) + raw = store.db.execute("SELECT prompt FROM schedules").fetchone()[0] + self.assertTrue(raw.startswith("tai1$")) + self.assertNotIn("prompt-secret", raw) + self.assertEqual(store.list_schedules("t")[0]["prompt"], "prompt-secret-3") + store.close() + + def test_rotate_moves_schedules(self): + os.environ.pop("TAI_PASSPHRASE", None) + config = tai.Config(FakeArgs()) + old = tai.Seal(config.home, tai.DEFAULT_PASSPHRASE) + store = tai.Store(config, old) + store.add_schedule("job", "rotate-prompt-4", "t", 3600, "2026-01-01T00:00:00+00:00", 60) + store.close() + fresh = tai.rotate_seal(config, old, "personal-4") + reopened = tai.Store(config, tai.Seal(config.home, "personal-4")) + self.assertEqual(reopened.list_schedules("t")[0]["prompt"], "rotate-prompt-4") + raw = reopened.db.execute("SELECT prompt FROM schedules").fetchone()[0] + self.assertTrue(raw.startswith("tai1$")) + reopened.close() + + if __name__ == "__main__": unittest.main() diff --git a/test_tai.py b/test_tai.py index 99398a2..ac5e871 100644 --- a/test_tai.py +++ b/test_tai.py @@ -1,10 +1,13 @@ # retoor import json import os +import re +import sqlite3 import sys import tempfile import time import unittest +from datetime import datetime, timedelta, timezone from unittest import mock sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -63,13 +66,16 @@ class SkillTests(unittest.TestCase): catalog = tai.skill_catalog(skills) self.assertLess(catalog.index("a-skill"), catalog.index("b-skill")) self.assertIn("load_skill", catalog) - self.assertEqual(tai.skill_catalog({}), "") + empty = tai.skill_catalog({}) + self.assertIn("Buildable skill blueprints", empty) + self.assertIn("bot-creator", empty) class FakeApp: def __init__(self): self.skills = {} self.env = "home" + self.store = None class ToolTests(unittest.TestCase): @@ -92,6 +98,2101 @@ class ToolTests(unittest.TestCase): self.assertIn("Communicate", tai.BOX_TTS) +class SysinfoTests(unittest.TestCase): + def test_collect_all_checks_visible(self): + report = tai.collect_sysinfo() + lines = report.splitlines() + self.assertEqual(len(lines), len(tai.SYSINFO_CHECKS)) + for (name, _func), line in zip(tai.SYSINFO_CHECKS, lines): + self.assertTrue(line.startswith(name + ": "), line) + self.assertRegex(line, r"\(\d+ms\)$") + + def test_subset(self): + report = tai.collect_sysinfo(["os", "root"]) + lines = report.splitlines() + self.assertEqual(len(lines), 2) + self.assertTrue(lines[0].startswith("os: ")) + self.assertTrue(lines[1].startswith("root: ")) + + def test_checks_run_in_parallel(self): + def slow(): + time.sleep(0.3) + return "ok" + + stubs = tuple(("slow%d" % pos, slow) for pos in range(4)) + with mock.patch.object(tai, "SYSINFO_CHECKS", stubs): + started = time.time() + report = tai.collect_sysinfo() + self.assertLess(time.time() - started, 1.0) + self.assertEqual(len(report.splitlines()), 4) + + def test_timeout_surfaces(self): + def stuck(): + time.sleep(5) + return "never" + + with mock.patch.object(tai, "SYSINFO_CHECKS", (("stuck", stuck),)): + with mock.patch.object(tai, "SYSINFO_TIMEOUT", 1): + report = tai.collect_sysinfo() + self.assertIn("stuck: timed out after 1s", report) + + def test_check_error_surfaces(self): + def broken(): + raise RuntimeError("boom") + + with mock.patch.object(tai, "SYSINFO_CHECKS", (("broken", broken),)): + report = tai.collect_sysinfo() + self.assertIn("broken: error: boom", report) + + def test_venv_detection(self): + with mock.patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/fake-venv"}): + self.assertIn("/tmp/fake-venv", tai.sysinfo_check_venv()) + + def test_root_shape(self): + result = tai.sysinfo_check_root() + self.assertTrue(result.startswith(("yes", "no", "unknown")), result) + + def test_tool_dispatch(self): + tools = tai.Tools(FakeApp()) + self.assertIn("os: ", tools.dispatch("sysinfo", "{}")) + subset = tools.dispatch("sysinfo", json.dumps({"checks": ["python"]})) + self.assertEqual(len(subset.splitlines()), 1) + self.assertIn("unknown checks: nope", tools.dispatch("sysinfo", json.dumps({"checks": ["nope"]}))) + self.assertIn("non-empty list", tools.dispatch("sysinfo", json.dumps({"checks": []}))) + + def test_sysinfo_command(self): + self.assertIn("sysinfo", tai.COMMANDS) + self.assertIn("/sysinfo", tai.HELP_TEXT) + with mock.patch.object(tai, "collect_sysinfo", return_value="fake-report") as collector: + with mock.patch("builtins.print") as printer: + self.assertTrue(tai.handle_command(mock.Mock(), "/sysinfo")) + collector.assert_called_once_with() + printer.assert_called_once_with("fake-report") + + +class FakeSkillApp: + def __init__(self, config): + self.depth = 0 + self.profile = "t" + self.config = config + self.store = mock.Mock() + self.store.redact = lambda text, profile=None: text + self.skills = {} + self.applied = 0 + self.runner_override = None + + def apply_system(self): + self.applied += 1 + + +class CreateSkillTests(unittest.TestCase): + def config_in(self, tmp): + old_home = os.environ.get("TAI_HOME") + os.environ["TAI_HOME"] = tmp + + class FakeArgs: + profile = "t" + yes = True + + try: + return tai.Config(FakeArgs()) + finally: + if old_home is None: + os.environ.pop("TAI_HOME", None) + else: + os.environ["TAI_HOME"] = old_home + + def test_prompt_is_self_contained(self): + prompt = tai.create_skill_prompt("pdf-forms", "fill pdf forms", "/tmp/x/pdf-forms") + self.assertIn("pdf-forms", prompt) + self.assertIn("fill pdf forms", prompt) + self.assertIn("/tmp/x/pdf-forms", prompt) + self.assertIn("sysinfo", prompt) + self.assertIn("SKILL.md", prompt) + self.assertIn("name: pdf-forms", prompt) + self.assertIn("two independent sources", prompt) + + def test_validation(self): + tools = tai.Tools(FakeSkillApp(None)) + self.assertIn("invalid skill name", tools.dispatch("create_skill", json.dumps({"name": "Bad_Name!", "brief": "b"}))) + self.assertIn("invalid skill name", tools.dispatch("create_skill", json.dumps({"brief": "b"}))) + self.assertIn("empty brief", tools.dispatch("create_skill", json.dumps({"name": "ok-name"}))) + self.assertIn("scope must be", tools.dispatch("create_skill", json.dumps({"name": "ok-name", "brief": "b", "scope": "moon"}))) + deep = FakeSkillApp(None) + deep.depth = 2 + self.assertIn("depth limit", tai.Tools(deep).dispatch("create_skill", json.dumps({"name": "ok-name", "brief": "b"}))) + + def test_creates_and_refreshes(self): + with tempfile.TemporaryDirectory() as tmp: + app = FakeSkillApp(self.config_in(tmp)) + seen = {} + + def runner(task, profile, timeout): + seen["task"] = task + seen["profile"] = profile + seen["timeout"] = timeout + skill_dir = os.path.join(tmp, ".tai", "skills", "pdf-forms") + os.makedirs(skill_dir, exist_ok=True) + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: + handle.write(VALID_SKILL) + return "researched and wrote the skill" + + app.runner_override = runner + with mock.patch("os.getcwd", return_value=tmp): + result = tai.Tools(app).dispatch("create_skill", json.dumps({"name": "pdf-forms", "brief": "fill pdf forms"})) + self.assertIn("created at", result) + self.assertIn("pdf-forms", app.skills) + self.assertEqual(app.applied, 1) + self.assertEqual(seen["profile"], "t") + self.assertIn("fill pdf forms", seen["task"]) + self.assertIn("SKILL.md", seen["task"]) + + def test_replace_and_missing(self): + with tempfile.TemporaryDirectory() as tmp: + config = self.config_in(tmp) + skill_dir = os.path.join(tmp, ".tai", "skills", "pdf-forms") + os.makedirs(skill_dir, exist_ok=True) + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: + handle.write(VALID_SKILL) + app = FakeSkillApp(config) + app.runner_override = lambda task, profile, timeout: "rewrote it" + with mock.patch("os.getcwd", return_value=tmp): + result = tai.Tools(app).dispatch("create_skill", json.dumps({"name": "pdf-forms", "brief": "b"})) + self.assertIn("replaced at", result) + app2 = FakeSkillApp(config) + app2.runner_override = lambda task, profile, timeout: "gave up" + with mock.patch("os.getcwd", return_value=tmp): + result2 = tai.Tools(app2).dispatch("create_skill", json.dumps({"name": "other-skill", "brief": "b"})) + self.assertIn("was not created (done)", result2) + self.assertNotIn("other-skill", app2.skills) + self.assertEqual(app2.applied, 0) + + def test_home_scope(self): + with tempfile.TemporaryDirectory() as tmp: + app = FakeSkillApp(self.config_in(tmp)) + + def runner(task, profile, timeout): + skill_dir = os.path.join(tmp, "skills", "home-skill") + os.makedirs(skill_dir, exist_ok=True) + with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as handle: + handle.write(VALID_SKILL.replace("pdf-forms", "home-skill")) + return "done" + + app.runner_override = runner + with mock.patch("os.getcwd", return_value="/nonexistent-dir"): + result = tai.Tools(app).dispatch("create_skill", json.dumps({"name": "home-skill", "brief": "b", "scope": "home"})) + self.assertIn("created at", result) + self.assertIn("home-skill", app.skills) + + +class FakeSecretApp: + def __init__(self, store, approve=True): + self.store = store + self.env = "home" + self.config = mock.Mock(auto_approve=True) + self.approvals = [] + self.approve = approve + + def ask_approval(self, command): + self.approvals.append(command) + return self.approve + + +class SecretsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + self.store = tai.Store(config, tai.Seal(config.home, "vault-test-1")) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_store_list_delete_roundtrip(self): + app = FakeSecretApp(self.store) + tools = tai.Tools(app) + self.assertIn("stored secret 'api'", tools.dispatch("store_secret", json.dumps({"name": "api", "value": "token-abc-123"}))) + self.assertEqual(self.store.load_secret("api"), "token-abc-123") + raw = self.store.db.execute("SELECT value FROM secrets WHERE name = 'api'").fetchone()[0] + self.assertTrue(raw.startswith("tai1$")) + self.assertNotIn("token-abc", raw) + listed = tools.dispatch("list_secrets", "{}") + self.assertIn("api", listed) + self.assertNotIn("token-abc", listed) + self.assertIn("deleted secret 'api'", tools.dispatch("delete_secret", json.dumps({"name": "api"}))) + self.assertIsNone(self.store.load_secret("api")) + self.assertEqual(len(app.approvals), 1) + + def test_validation(self): + tools = tai.Tools(FakeSecretApp(self.store)) + self.assertIn("invalid secret name", tools.dispatch("store_secret", json.dumps({"name": "Bad Name!", "value": "x"}))) + self.assertIn("empty value", tools.dispatch("store_secret", json.dumps({"name": "ok", "value": ""}))) + self.assertIn("unknown secret", tools.dispatch("delete_secret", json.dumps({"name": "nope"}))) + self.assertIn("unknown tool", tools.dispatch("get_secret", json.dumps({"name": "x"}))) + + def test_shell_blind_injection(self): + self.store.save_secret("demo", "injected-value-42") + app = FakeSecretApp(self.store) + tools = tai.Tools(app) + result = tools.dispatch("shell", json.dumps({"command": "echo $TAI_SECRET_DEMO", "secrets": ["demo"]})) + self.assertIn("[redacted:demo]", result) + self.assertNotIn("injected-value-42", result) + tools.dispatch("shell", json.dumps({"command": "echo $TAI_SECRET_DEMO", "secrets": ["demo"]})) + self.assertEqual(len(app.approvals), 1) + self.assertIn("unknown secret 'nope'", tools.dispatch("shell", json.dumps({"command": "echo hi", "secrets": ["nope"]}))) + self.assertIn("must be a list", tools.dispatch("shell", json.dumps({"command": "echo hi", "secrets": "demo"}))) + + def test_shell_secret_denied(self): + self.store.save_secret("demo", "injected-value-42") + app = FakeSecretApp(self.store, approve=False) + result = tai.Tools(app).dispatch("shell", json.dumps({"command": "echo hi", "secrets": ["demo"]})) + self.assertEqual(result, "denied by user") + + def test_web_fetch_auth(self): + self.store.save_secret("api", "fetch-token-7") + seen = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self, limit=0): + return b"ok fetch-token-7 here" + + def fake_urlopen(request, timeout=30): + seen[request.full_url] = {key.lower(): value for key, value in request.headers.items()} + return FakeResponse() + + app = FakeSecretApp(self.store) + tools = tai.Tools(app) + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + result = tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v1", "auth_secret": "api"})) + keyed = tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v2", "auth_secret": "api", "auth_header": "X-Api-Key", "auth_prefix": ""})) + first = seen["https://api.example.test/v1"] + second = seen["https://api.example.test/v2"] + self.assertEqual(first["authorization"], "Bearer fetch-token-7") + self.assertIn("tai/", first["user-agent"]) + self.assertEqual(second["x-api-key"], "fetch-token-7") + self.assertIn("[redacted:api]", result) + self.assertNotIn("fetch-token-7", result) + self.assertIn("[redacted:api]", keyed) + self.assertEqual(len(app.approvals), 1) + self.assertIn("unknown secret", tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v1", "auth_secret": "nope"}))) + self.assertIn("invalid auth header", tools.dispatch("web_fetch", json.dumps({"url": "https://api.example.test/v1", "auth_secret": "api", "auth_header": "Bad\nHeader"}))) + + def test_redact(self): + self.store.save_secret("long", "abcdefghij") + self.store.save_secret("short", "abc") + self.store.save_secret("sub", "cdef") + text = self.store.redact("see abcdefghij and abc here") + self.assertEqual(text, "see [redacted:long] and abc here") + + def test_run_turn_scrubs_and_redacts(self): + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + agent = tai.Agent(config, self.store, persist=False, quiet=True) + call_reply = {"role": "assistant", "content": "storing now hunter2-leak", "reasoning": "", "tool_calls": [{"id": "c9", "name": "store_secret", "arguments": json.dumps({"name": "leak", "value": "hunter2-leak"})}], "backend": "x"} + final_reply = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} + with mock.patch.object(agent.chat, "complete", side_effect=[call_reply, final_reply]): + result = agent.run_turn("remember it", capture=True) + self.assertEqual(result, "done") + blob = json.dumps(agent.messages) + self.assertNotIn("hunter2-leak", blob) + self.assertIn("[redacted:leak]", blob) + self.assertEqual(self.store.load_secret("leak"), "hunter2-leak") + self.assertEqual(self.store.search_events("t", "hunter2"), []) + + def test_secret_repl(self): + agent = mock.Mock() + agent.store = self.store + agent.profile = "t" + agent.ask_approval = lambda command, guidance=True: True + agent.tools = tai.Tools(agent) + with mock.patch("getpass.getpass", return_value="repl-value-1"): + with mock.patch("builtins.print") as printer: + self.assertTrue(tai.handle_command(agent, "/secret set wifi")) + self.assertEqual(self.store.load_secret("wifi"), "repl-value-1") + printed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn("stored secret 'wifi'", printed) + self.assertNotIn("repl-value-1", printed) + with mock.patch("builtins.print") as printer: + tai.handle_command(agent, "/secret list") + tai.handle_command(agent, "/secret") + listed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn("- wifi", listed) + self.assertNotIn("repl-value-1", listed) + with mock.patch("builtins.print") as printer: + tai.handle_command(agent, "/secret delete wifi") + tai.handle_command(agent, "/secret delete nope") + tai.handle_command(agent, "/secret frobnicate") + tai.handle_command(agent, "/secret set Bad Name") + removed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn("deleted secret 'wifi'", removed) + self.assertIn("unknown secret", removed) + self.assertIn("use /secret set", removed) + self.assertIn("invalid secret name", removed) + self.assertIsNone(self.store.load_secret("wifi")) + + def test_legacy_memory_migrates_to_vault(self): + self.assertIn("store_secret", tai.DEFAULT_SYSTEM) + self.assertNotIn("collect and keep", tai.DEFAULT_SYSTEM) + + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + old_system = "You are tai. Memory: call remember whenever you learn durable facts, " + tai.LEGACY_PASSWORD_NOTE + "; call it with a forget instruction." + self.store.save_system("old", old_system) + agent = tai.Agent(config, self.store, persist=False, quiet=True) + agent.switch_profile("old", silent=True) + self.assertNotIn(tai.LEGACY_PASSWORD_NOTE, agent.system_message) + self.assertIn("store_secret", agent.system_message) + self.assertIn("store_secret", self.store.load_system("old")) + + +class FakeSchedulerApp: + def __init__(self, store, approve=True): + self.store = store + self.profile = "t" + self.approvals = [] + self.approve = approve + + def ask_approval(self, command): + self.approvals.append(command) + return self.approve + + +class SchedulerTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.seal = tai.Seal(self.config.home, "sched-test-1") + self.store = tai.Store(self.config, self.seal) + with tai.AGENTS_LOCK: + tai.AGENTS.clear() + tai.AGENTS_NEXT[0] = 1 + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + with tai.AGENTS_LOCK: + tai.AGENTS.clear() + tai.AGENTS_NEXT[0] = 1 + + def test_parse_at_and_duration(self): + parsed = tai.parse_schedule_at("2026-10-08T09:00") + self.assertEqual(datetime.fromisoformat(parsed).utcoffset().total_seconds(), 0) + self.assertEqual(tai.local_display(parsed), "2026-10-08 09:00") + with self.assertRaises(ValueError): + tai.parse_schedule_at("not a date") + self.assertEqual(tai.parse_duration("10m"), 600) + self.assertEqual(tai.parse_duration("2h"), 7200) + self.assertEqual(tai.parse_duration("1d"), 86400) + self.assertEqual(tai.parse_duration("45"), 45) + self.assertIsNone(tai.parse_duration("nope")) + self.assertEqual(tai.format_delay(4000), "1h") + self.assertEqual(tai.format_delay(-5), "overdue") + + def test_schedule_validation(self): + tools = tai.Tools(FakeSchedulerApp(self.store)) + self.assertIn("empty prompt", tools.dispatch("schedule", json.dumps({"at": "2026-10-08T09:00"}))) + self.assertIn("exactly one", tools.dispatch("schedule", json.dumps({"prompt": "x"}))) + self.assertIn("exactly one", tools.dispatch("schedule", json.dumps({"prompt": "x", "at": "2026-10-08T09:00", "every": 60}))) + self.assertIn("invalid datetime", tools.dispatch("schedule", json.dumps({"prompt": "x", "at": "soon"}))) + self.assertIn("at least 60", tools.dispatch("schedule", json.dumps({"prompt": "x", "every": 5}))) + self.assertIn("invalid profile", tools.dispatch("schedule", json.dumps({"prompt": "x", "every": 60, "profile": "bad name!"}))) + denied = tai.Tools(FakeSchedulerApp(self.store, approve=False)) + self.assertEqual(denied.dispatch("schedule", json.dumps({"prompt": "x", "every": 60})), "denied by user") + self.assertEqual(self.store.list_schedules(), []) + + def test_schedule_roundtrip(self): + app = FakeSchedulerApp(self.store) + tools = tai.Tools(app) + once = tools.dispatch("schedule", json.dumps({"prompt": "water plants", "name": "plants", "at": "2026-10-08T09:00"})) + self.assertIn("scheduled #1", once) + rep = tools.dispatch("schedule", json.dumps({"prompt": "check mail", "every": 3600})) + self.assertIn("scheduled #2", rep) + raw = self.store.db.execute("SELECT prompt FROM schedules WHERE id = 1").fetchone()[0] + self.assertTrue(raw.startswith("tai1$")) + listed = tools.dispatch("schedules", "{}") + self.assertIn("#1", listed) + self.assertIn("plants", listed) + self.assertIn("every 1h", listed) + self.assertIn("water plants", listed) + self.assertIn("deleted schedule #1", tools.dispatch("unschedule", json.dumps({"id": 1}))) + self.assertIn("no schedule #1", tools.dispatch("unschedule", json.dumps({"id": 1}))) + self.assertEqual(len(app.approvals), 3) + + def test_tick_fires_once_async(self): + self.store.add_schedule("job", "do the thing", "t", 0, "2020-01-01T00:00:00+00:00", 60) + calls = [] + + def stub(task, profile, timeout): + calls.append((task, profile, timeout)) + return "stub-result" + + self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=stub), 1) + self.assertEqual(calls[0][0], "do the thing") + self.assertEqual(self.store.db.execute("SELECT status FROM schedules WHERE id = 1").fetchone()[0], "done") + self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=stub), 0) + self.assertEqual(len(calls), 1) + deadline = time.time() + 5 + row = ("", "") + while time.time() < deadline: + row = self.store.db.execute("SELECT last_status, last_result FROM schedules WHERE id = 1").fetchone() + if row[0]: + break + time.sleep(0.05) + self.assertEqual(row[0], "done") + self.assertIn("stub-result", row[1]) + + def test_tick_repeat_advances_without_backfill(self): + past = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + self.store.add_schedule("hourly", "ping", "t", 3600, past, 60) + calls = [] + self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=lambda task, profile, timeout: calls.append(task) or "ok"), 1) + self.assertEqual(len(calls), 1) + row = self.store.db.execute("SELECT status, next_run FROM schedules WHERE id = 1").fetchone() + self.assertEqual(row[0], "pending") + self.assertGreater(datetime.fromisoformat(row[1]), datetime.now(timezone.utc)) + + def test_tick_skips_claimed(self): + self.store.add_schedule("job", "do it", "t", 0, "2020-01-01T00:00:00+00:00", 60) + self.store.db.execute("UPDATE schedules SET status = 'done' WHERE id = 1") + self.store.db.commit() + calls = [] + self.assertEqual(tai.scheduler_tick(self.config, self.seal, runner=lambda task, profile, timeout: calls.append(task) or "ok"), 0) + self.assertEqual(calls, []) + + def test_schedule_repl(self): + agent = mock.Mock() + agent.store = self.store + agent.profile = "t" + with mock.patch("builtins.print") as printer: + tai.handle_command(agent, "/schedule every 1h check mail") + tai.handle_command(agent, "/schedule at 2026-10-08T09:00 water plants") + created = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn("scheduled #1", created) + self.assertIn("scheduled #2", created) + with mock.patch("builtins.print") as printer: + tai.handle_command(agent, "/schedules") + listed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn("#1", listed) + self.assertIn("check mail", listed) + with mock.patch("builtins.print") as printer: + tai.handle_command(agent, "/schedule soon x") + tai.handle_command(agent, "/schedule every 5s x") + tai.handle_command(agent, "/schedule at nope x") + tai.handle_command(agent, "/unschedule 1") + tai.handle_command(agent, "/unschedule 1") + tai.handle_command(agent, "/unschedule x") + errors = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn("use /schedule at", errors) + self.assertIn("at least 60", errors) + self.assertIn("invalid datetime", errors) + self.assertIn("deleted schedule #1", errors) + self.assertIn("no schedule #1", errors) + self.assertIn("use /unschedule", errors) + + def test_delegation_wording(self): + self.assertIn("Delegation:", tai.DEFAULT_SYSTEM) + self.assertIn("fork background subagents", tai.DEFAULT_SYSTEM) + self.assertIn("schedule future work", tai.DEFAULT_SYSTEM) + + +class RecordGraphTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "record-test-1")) + self.app = FakeSecretApp(self.store) + self.tools = tai.Tools(self.app) + with tai.AGENTS_LOCK: + tai.AGENTS.clear() + tai.AGENTS_NEXT[0] = 1 + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + with tai.AGENTS_LOCK: + tai.AGENTS.clear() + tai.AGENTS_NEXT[0] = 1 + + def mem_id(self, text): + match = re.search(r"mem:[0-9a-f]{16}", text) + self.assertIsNotNone(match, "no mem id in %r" % text) + return match.group(0) + + def test_save_read_search_delete_roundtrip(self): + saved = self.tools.dispatch("record_save", json.dumps({"title": "site notes", "content": "alpha beta gamma", "kind": "note", "tags": ["Site Visit"]})) + record_id = self.mem_id(saved) + self.assertIn("16 chars", saved) + self.assertIn("site-visit", saved) + page = self.tools.dispatch("record_read", json.dumps({"id": record_id})) + self.assertIn("chars 0-16 of 16", page) + self.assertIn("alpha beta gamma", page) + found = self.tools.dispatch("record_search", json.dumps({"query": "beta"})) + self.assertIn(record_id, found) + self.assertIn("site notes", found) + deleted = self.tools.dispatch("record_delete", json.dumps({"id": record_id})) + self.assertIn("deleted record", deleted) + self.assertIn("unknown record", self.tools.dispatch("record_read", json.dumps({"id": record_id}))) + self.assertEqual(self.app.approvals, ["delete record " + record_id]) + + def test_read_pages_offsets(self): + saved = self.tools.dispatch("record_save", json.dumps({"title": "paged", "content": "".join("%04d" % num for num in range(250))})) + record_id = self.mem_id(saved) + first = self.tools.dispatch("record_read", json.dumps({"id": record_id, "limit": 100})) + self.assertIn("chars 0-100 of 1000", first) + second = self.tools.dispatch("record_read", json.dumps({"id": record_id, "offset": 100, "limit": 100})) + self.assertIn("chars 100-200 of 1000", second) + self.assertNotEqual(first.splitlines()[-1], second.splitlines()[-1]) + record = self.store.get_record(record_id) + self.assertGreaterEqual(record["reads"], 3) + + def test_record_validation_errors(self): + self.assertIn("content is empty", self.tools.dispatch("record_save", json.dumps({"title": "x"}))) + self.assertIn("unknown kind", self.tools.dispatch("record_save", json.dumps({"content": "x", "kind": "song"}))) + self.assertIn("tags must be a list", self.tools.dispatch("record_save", json.dumps({"content": "x", "tags": "nope"}))) + self.assertIn("unknown record", self.tools.dispatch("record_read", json.dumps({"id": "mem:0123456789abcdef"}))) + self.assertIn("no records match", self.tools.dispatch("record_search", json.dumps({"query": "nothing-here-zzz"}))) + + def test_delete_denied_keeps_record(self): + saved = self.tools.dispatch("record_save", json.dumps({"content": "keep me"})) + record_id = self.mem_id(saved) + self.app.approve = False + self.assertIn("denied by user", self.tools.dispatch("record_delete", json.dumps({"id": record_id}))) + self.assertIsNotNone(self.store.get_record(record_id)) + + def test_search_filters_kind_and_tags(self): + self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "shared word", "kind": "note", "tags": ["team"]})) + self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "shared word", "kind": "output", "tags": ["team"]})) + by_kind = self.tools.dispatch("record_search", json.dumps({"query": "shared", "kind": "output"})) + self.assertIn("[output]", by_kind) + self.assertNotIn("[note]", by_kind) + by_tag = self.tools.dispatch("record_search", json.dumps({"tags": ["team"]})) + self.assertIn("[output]", by_tag) + self.assertIn("[note]", by_tag) + missing = self.tools.dispatch("record_search", json.dumps({"tags": ["team", "other"]})) + self.assertIn("no records match", missing) + + def test_graph_link_and_query(self): + first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "plan", "content": "the plan"}))) + second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "bravo-log-title", "content": "the log"}))) + self.tools.dispatch("store_secret", json.dumps({"name": "deploy", "value": "token-xyz-9"})) + linked = self.tools.dispatch("graph_link", json.dumps({"src": first, "dst": second, "relation": "Follows Up"})) + self.assertIn("follows-up", linked) + self.tools.dispatch("graph_link", json.dumps({"src": second, "dst": "secret:deploy", "relation": "uses"})) + view = self.tools.dispatch("graph_query", json.dumps({"node": first})) + self.assertIn(first + " :: plan", view) + self.assertIn(second, view) + self.assertIn("secret:deploy", view) + self.assertIn("bravo-log-title", view) + self.assertIn("unknown node", self.tools.dispatch("graph_query", json.dumps({"node": "mem:0123456789abcdef"}))) + self.assertIn("unknown node", self.tools.dispatch("graph_link", json.dumps({"src": first, "dst": "mem:0123456789abcdef"}))) + + def test_traverse_caps_depth(self): + ids = [self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "n%d" % num, "content": "x"}))) for num in range(6)] + for pos in range(5): + self.store.add_edge(ids[pos], ids[pos + 1], "next") + shallow = self.store.traverse(ids[0], depth=1, limit=50) + self.assertEqual(len(shallow), 2) + deep = self.store.traverse(ids[0], depth=99, limit=50) + self.assertEqual(len(deep), 5) + self.assertEqual(max(item["depth"] for item in deep), 4) + + def test_shell_output_spills_to_record(self): + result = self.tools.dispatch("shell", json.dumps({"command": "python3 -c \"print('0123456789' * 700)\""})) + self.assertIn("exit 0", result) + record_id = self.mem_id(result) + self.assertIn("record_read pages the rest", result) + page = self.tools.dispatch("record_read", json.dumps({"id": record_id, "limit": 20})) + self.assertIn("of 7000", page) + found = self.tools.dispatch("record_search", json.dumps({"tags": ["shell"]})) + self.assertIn(record_id, found) + + def test_poll_spills_once_and_reuses(self): + big = "z" * 7000 + agent_id = tai.spawn_agent("big task", "t", 60, None, None, 0, lambda task, profile, timeout: big) + first = self.tools.dispatch("poll", json.dumps({"id": agent_id, "wait": 5})) + record_id = self.mem_id(first) + second = self.tools.dispatch("poll", json.dumps({"id": agent_id})) + self.assertIn(record_id, second) + rows = self.store.db.execute("SELECT COUNT(*) FROM records").fetchone()[0] + self.assertEqual(rows, 1) + + def test_record_repl_commands(self): + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "repl note", "content": "visible words"}))) + self.app.tools = self.tools + with mock.patch("builtins.print") as shown: + tai.handle_command(self.app, "/records visible") + tai.handle_command(self.app, "/record " + record_id) + tai.handle_command(self.app, "/graph " + record_id) + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn(record_id, printed) + self.assertIn("repl note", printed) + + def test_wal_mode_enabled(self): + mode = self.store.db.execute("PRAGMA journal_mode").fetchone()[0] + self.assertEqual(mode.lower(), "wal") + + +class SecretMetaTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + self.store = tai.Store(config, tai.Seal(config.home, "meta-test-1")) + self.tools = tai.Tools(FakeSecretApp(self.store)) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_metadata_roundtrip(self): + result = self.tools.dispatch("store_secret", json.dumps({"name": "db", "value": "pw-12345", "username": "ops", "host": "db.internal", "port": 5432, "notes": "primary", "tags": ["Prod DB"]})) + self.assertIn("stored secret 'db'", result) + listed = self.tools.dispatch("list_secrets", "{}") + self.assertIn("ops@db.internal:5432", listed) + self.assertIn("prod-db", listed) + self.assertNotIn("pw-12345", listed) + infos = self.store.list_secret_infos() + self.assertEqual(infos[0]["meta"].get("username"), "ops") + self.assertEqual(infos[0]["meta"].get("port"), 5432) + + def test_minimum_info_suffices(self): + result = self.tools.dispatch("store_secret", json.dumps({"name": "plain", "value": "v-abcdef"})) + self.assertIn("stored secret 'plain'", result) + self.assertIn("- plain [secret]", self.tools.dispatch("list_secrets", "{}")) + + +class ScheduleTagTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "schetag-1")) + self.tools = tai.Tools(FakeSchedulerApp(self.store)) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_schedule_tags_shown(self): + self.tools.dispatch("schedule", json.dumps({"prompt": "water plants", "every": 3600, "tags": ["Home Chores"]})) + listed = self.tools.dispatch("schedules", "{}") + self.assertIn("home-chores", listed) + items = self.store.list_schedules() + self.assertIn("schedule", items[0]["tags"]) + + +class ModeTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = False + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "mode-test-1")) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_interactive_always_enables_yolo(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True) + with mock.patch("sys.stdin") as fake_stdin: + fake_stdin.isatty.return_value = True + with mock.patch("builtins.input", return_value="Y"): + with mock.patch("builtins.print"): + self.assertTrue(agent.ask_approval("do it")) + self.assertTrue(agent.yolo) + with mock.patch("builtins.input", side_effect=AssertionError("must not ask")): + self.assertTrue(agent.ask_approval("do it again")) + + def test_yolo_never_asks(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True, yolo=True) + with mock.patch("builtins.input", side_effect=AssertionError("must not ask")): + self.assertTrue(agent.ask_approval("anything")) + + def test_auto_gets_autonomous_note(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True, auto=True) + agent.apply_system() + self.assertIn("Autonomous mode", agent.messages[0]["content"]) + + +class AuditTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "audit-test-1")) + self.app = FakeSecretApp(self.store) + self.app.skills = {} + self.tools = tai.Tools(self.app) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def workfile(self, name="note.txt", content="v1"): + path = os.path.join(self.tmp.name, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + return path + + def test_write_audits_and_records(self): + path = os.path.join(self.tmp.name, "fresh.txt") + self.assertIn("wrote", self.tools.dispatch("write_file", json.dumps({"path": path, "content": "hello"}))) + rows = self.store.audit_history(path) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["action"], "write") + self.assertIsNone(rows[0]["old_size"]) + self.assertEqual(rows[0]["new_size"], 5) + record_id = self.store.file_record_id(path) + self.assertIsNotNone(record_id) + found = self.tools.dispatch("record_search", json.dumps({"tags": ["txt"]})) + self.assertIn(record_id, found) + self.tools.dispatch("read_file", json.dumps({"path": path})) + self.tools.dispatch("write_file", json.dumps({"path": path, "content": "hello again"})) + rows = self.store.audit_history(path) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["old_size"], 5) + self.assertEqual(rows[0]["new_size"], 11) + + def test_edit_captures_old_and_new(self): + path = self.workfile() + self.tools.dispatch("read_file", json.dumps({"path": path})) + self.assertIn("edited", self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "v1", "replace": "v2"}))) + row = self.store.audit_get(self.store.audit_history(path)[0]["id"]) + self.assertEqual(row["action"], "edit") + self.assertEqual(row["old"], "v1") + self.assertEqual(row["new"], "v2") + + def test_big_file_truncates_with_marker(self): + path = os.path.join(self.tmp.name, "big.txt") + content = "x" * 60000 + self.tools.dispatch("write_file", json.dumps({"path": path, "content": content})) + row = self.store.audit_get(self.store.audit_history(path)[0]["id"]) + self.assertEqual(row["new_size"], 60000) + self.assertIn("[truncated, full size 60000 bytes]", row["new"]) + record = self.store.get_record(self.store.file_record_id(path)) + self.assertIn("[truncated, full size 60000 bytes]", record["content"]) + + def test_shell_rm_snapshots_before_delete(self): + first = self.workfile("a.txt", "alpha") + second = self.workfile("b.txt", "beta") + result = self.tools.dispatch("shell", json.dumps({"command": "rm a.txt b.txt", "workdir": self.tmp.name})) + self.assertIn("exit 0", result) + self.assertFalse(os.path.exists(first)) + snaps = self.store.audit_history(tag="shell") + self.assertEqual(len(snaps), 2) + by_path = {row["path"]: row for row in snaps} + full = self.store.audit_get(by_path[first]["id"]) + self.assertEqual(full["old"], "alpha") + self.assertIsNone(full["new"]) + self.assertIn("rm a.txt b.txt", full["message"]) + + def test_shell_redirect_snapshots_target(self): + path = self.workfile("out.txt", "old words") + self.tools.dispatch("shell", json.dumps({"command": "echo new > out.txt", "workdir": self.tmp.name})) + snaps = self.store.audit_history(tag="shell") + self.assertEqual(len(snaps), 1) + full = self.store.audit_get(snaps[0]["id"]) + self.assertEqual(full["old"], "old words") + + def test_shell_parser_units(self): + self.assertEqual(tai.segment_targets(["rm", "-rf", "a", "b"]), ["a", "b"]) + self.assertEqual(tai.segment_targets(["sudo", "rm", "x"]), ["x"]) + self.assertEqual(tai.segment_targets(["VAR=1", "cmd", ">", "out"]), ["out"]) + self.assertEqual(tai.segment_targets(["tee", "t1", "t2"]), ["t1", "t2"]) + self.assertEqual(tai.segment_targets(["mv", "a", "b"]), ["b"]) + self.assertEqual(tai.segment_targets(["cmd", "2>", "/dev/null"]), []) + self.assertEqual(tai.segment_targets(["truncate", "-s", "0", "f"]), ["f"]) + self.assertEqual(tai.segment_targets(["dd", "if=a", "of=b"]), ["b"]) + sub = os.path.join(self.tmp.name, "sub") + os.makedirs(sub) + self.workfile("sub/one.txt", "1") + self.workfile("sub/two.txt", "2") + hits = tai.shell_target_paths("rm -rf sub", self.tmp.name) + self.assertEqual(len(hits), 2) + first = self.workfile("t1.txt", "1") + piped = tai.shell_target_paths("echo x | tee t1.txt missing.txt", self.tmp.name) + self.assertEqual(piped, [first]) + + def test_delete_roundtrip_and_deny(self): + gone = self.workfile("gone.txt", "bye") + kept = self.workfile("kept.txt", "hi") + self.tools.dispatch("read_file", json.dumps({"path": gone})) + self.tools.dispatch("read_file", json.dumps({"path": kept})) + self.assertIn("deleted", self.tools.dispatch("delete_file", json.dumps({"path": gone}))) + self.assertFalse(os.path.exists(gone)) + self.assertIsNone(self.store.file_record_id(gone)) + row = self.store.audit_get(self.store.audit_history(gone)[0]["id"]) + self.assertEqual(row["action"], "delete") + self.assertEqual(row["old"], "bye") + self.app.approve = False + self.assertIn("denied by user", self.tools.dispatch("delete_file", json.dumps({"path": kept}))) + self.assertTrue(os.path.exists(kept)) + + def test_audit_tool_filters(self): + first = self.workfile("f1.txt", "one") + self.workfile("f2.txt", "two") + self.tools.dispatch("read_file", json.dumps({"path": first})) + self.tools.dispatch("edit_file", json.dumps({"path": first, "find": "one", "replace": "uno"})) + by_path = self.tools.dispatch("audit", json.dumps({"path": first})) + self.assertIn("edit", by_path) + self.assertNotIn("f2.txt", by_path) + self.assertIn("invalid limit", self.tools.dispatch("audit", json.dumps({"limit": "zzz"}))) + self.assertIn("no audit rows match", self.tools.dispatch("audit", json.dumps({"path": "/nope/nothing"}))) + + def test_restore_post_pre_and_undelete(self): + path = self.workfile("time.txt", "v1") + self.tools.dispatch("read_file", json.dumps({"path": path})) + self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "v1", "replace": "v2"})) + rows = {row["action"]: row for row in self.store.audit_history(path)} + self.assertIn("restored", self.tools.dispatch("restore", json.dumps({"id": rows["edit"]["id"]}))) + with open(path, encoding="utf-8") as handle: + self.assertEqual(handle.read(), "v2") + self.tools.dispatch("shell", json.dumps({"command": "rm time.txt", "workdir": self.tmp.name})) + snap = self.store.audit_history(path, tag="shell")[0] + self.assertIn("restored", self.tools.dispatch("restore", json.dumps({"id": snap["id"]}))) + with open(path, encoding="utf-8") as handle: + self.assertEqual(handle.read(), "v2") + history = self.store.audit_history(path) + self.assertEqual(history[0]["action"], "restore") + self.assertIn("restored from audit", history[0]["message"]) + + def test_restore_refuses_truncated(self): + path = os.path.join(self.tmp.name, "huge.txt") + self.tools.dispatch("write_file", json.dumps({"path": path, "content": "y" * 60000})) + row_id = self.store.audit_history(path)[0]["id"] + result = self.tools.dispatch("restore", json.dumps({"id": row_id})) + self.assertIn("truncated", result) + self.assertIn("cannot restore safely", result) + + def test_restore_repl_command(self): + path = self.workfile("repl.txt", "keep") + self.tools.dispatch("read_file", json.dumps({"path": path})) + self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "keep", "replace": "changed"})) + row_id = self.store.audit_history(path)[0]["id"] + self.app.tools = self.tools + with mock.patch("builtins.print") as shown: + tai.handle_command(self.app, "/audit " + path) + tai.handle_command(self.app, "/restore %d" % row_id) + tai.handle_command(self.app, "/restore nope") + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn("edit", printed) + self.assertIn("restored", printed) + self.assertIn("use /restore ", printed) + + def test_lazy_blueprint_builds_on_load(self): + with mock.patch.object(self.tools, "run_create_skill", return_value="created") as creator: + result = self.tools.dispatch("load_skill", json.dumps({"name": "bot-creator"})) + self.assertIn("building skill 'bot-creator' from blueprint", result) + creator.assert_called_once() + sent = creator.call_args.args[0] + self.assertEqual(sent["name"], "bot-creator") + self.assertIn("Deep-research", sent["brief"]) + self.assertIn("unknown skill", self.tools.dispatch("load_skill", json.dumps({"name": "nope"}))) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "release-test-1")) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def script(self, version="1.2.3"): + path = os.path.join(self.tmp.name, "tai-copy.py") + with open(path, "w", encoding="utf-8") as handle: + handle.write("#!/usr/bin/env python3\nVERSION = \"%s\"\nprint('hi')\n" % version) + return path + + def test_bump_parts(self): + self.assertEqual(tai.release_bump(self.script(), "patch"), ("1.2.3", "1.2.4")) + self.assertEqual(tai.release_bump(self.script(), "minor"), ("1.2.3", "1.3.0")) + self.assertEqual(tai.release_bump(self.script(), "major"), ("1.2.3", "2.0.0")) + with open(self.script(), encoding="utf-8") as handle: + self.assertIn('VERSION = "1.2.3"', handle.read()) + with self.assertRaises(ValueError): + tai.release_bump(self.script(), "banana") + flat = os.path.join(self.tmp.name, "flat.py") + with open(flat, "w", encoding="utf-8") as handle: + handle.write("no version here\n") + with self.assertRaises(ValueError): + tai.release_bump(flat, "patch") + + def test_do_release_end_to_end(self): + path = self.script() + folder = tai.backups_dir(self.tmp.name) + old, new, dest, row_id = tai.do_release(self.store, path, folder, "minor", "add audit trail") + self.assertEqual((old, new), ("1.2.3", "1.3.0")) + self.assertTrue(dest.startswith(folder)) + self.assertIn("tai-1.3.0-", os.path.basename(dest)) + with open(path, encoding="utf-8") as handle: + text = handle.read() + self.assertIn('VERSION = "1.3.0"', text) + self.assertIn("print('hi')", text) + row = self.store.audit_get(row_id) + self.assertEqual(row["action"], "release") + self.assertEqual(row["message"], "add audit trail") + self.assertEqual(row["old"], "1.2.3") + self.assertEqual(row["new"], "1.3.0") + self.assertIn("release", row["tags"]) + with self.assertRaises(ValueError): + tai.do_release(self.store, path, folder, "patch", " ") + + def test_backup_prune_and_skip(self): + folder = tai.backups_dir(self.tmp.name) + os.makedirs(folder) + now = time.time() + for pos in range(12): + name = os.path.join(folder, "tai-1.0.0-20200101T00000%dZ-abc%d.py" % (pos, pos)) + with open(name, "w", encoding="utf-8") as handle: + handle.write("old") + stamp = now - (12 - pos) * 60 + os.utime(name, (stamp, stamp)) + path = self.script() + dest, _digest = tai.snapshot_self(path, folder, "1.2.3") + kept = [entry for entry in os.listdir(folder) if entry.endswith(".py")] + self.assertEqual(len(kept), tai.BACKUP_KEEP) + self.assertIn(os.path.basename(dest), kept) + with open(path, "w", encoding="utf-8") as handle: + handle.write("#!/usr/bin/env python3\nVERSION = \"9.9.9\"\nprint('changed')\n") + first = tai.ensure_self_backup(self.config, self.store, script_file=path) + self.assertIsNotNone(first) + again = tai.ensure_self_backup(self.config, self.store, script_file=path) + self.assertIsNone(again) + snaps = self.store.audit_history(tag="snapshot") + self.assertEqual(len(snaps), 1) + self.assertIn("backed up as", snaps[0]["message"]) + + def test_release_tool_validates_safely(self): + app = FakeSecretApp(self.store, approve=False) + tools = tai.Tools(app) + self.assertIn("part must be", tools.dispatch("release", json.dumps({"part": "banana", "message": "x"}))) + self.assertIn("message is required", tools.dispatch("release", json.dumps({"part": "patch", "message": ""}))) + denied = tools.dispatch("release", json.dumps({"part": "patch", "message": "try bump"})) + self.assertIn("denied by user", denied) + self.assertEqual(len(app.approvals), 1) + self.assertIn("release %s (patch)" % tai.next_version(tai.VERSION, "patch"), app.approvals[0]) + + def test_boot_creates_self_backup(self): + import argparse as ap + + args = ap.Namespace(profile="t", yes=True, yolo=False, auto=False) + with mock.patch.dict(os.environ, {"TAI_PASSPHRASE": "release-test-1"}): + with mock.patch("builtins.print"): + agent = tai.boot(args) + try: + names = os.listdir(tai.backups_dir(self.tmp.name)) + self.assertEqual(len(names), 1) + self.assertTrue(names[0].startswith("tai-%s-" % tai.VERSION)) + snaps = agent.store.audit_history(tag="snapshot") + self.assertEqual(len(snaps), 1) + finally: + agent.store.close() + + +class TaggingTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "tag-test-1")) + self.app = FakeSecretApp(self.store) + self.tools = tai.Tools(self.app) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def mem_id(self, text): + return re.search(r"mem:[0-9a-f]{16}", text).group(0) + + def test_singular_units(self): + self.assertEqual(tai.singular_noun("servers"), "server") + self.assertEqual(tai.singular_noun("cities"), "city") + self.assertEqual(tai.singular_noun("boxes"), "box") + self.assertEqual(tai.singular_noun("branches"), "branch") + self.assertEqual(tai.singular_noun("glass"), "glass") + self.assertEqual(tai.singular_noun("status"), "status") + self.assertEqual(tai.singular_noun("news"), "news") + self.assertEqual(tai.singular_noun("physics"), "physics") + self.assertEqual(tai.singular_noun("api"), "api") + + def test_plural_merges_into_known_singular(self): + first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "one", "content": "about a server", "tags": ["server"]}))) + self.assertIn("server", self.store.item_tags(first)) + second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "two", "content": "more", "tags": ["Servers"]}))) + tags = self.store.item_tags(second) + self.assertIn("server", tags) + self.assertNotIn("servers", tags) + self.assertNotIn("servers", self.store.known_tags()) + + def test_irregular_words_never_corrupt(self): + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "words", "content": "plain", "tags": ["news", "glass", "status", "physics"]}))) + tags = self.store.item_tags(record_id) + for word in ("news", "glass", "status", "physics"): + self.assertIn(word, tags) + + def test_known_words_attach_automatically(self): + self.tools.dispatch("record_save", json.dumps({"title": "seed", "content": "nothing yet", "tags": ["deploy", "server"]})) + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "friday", "content": "we deploy the thing friday", "tags": []}))) + self.assertIn("deploy", self.store.item_tags(record_id)) + self.assertNotIn("friday", self.store.item_tags(record_id)) + plural = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "fleet", "content": "all servers rebooted", "tags": []}))) + self.assertIn("server", self.store.item_tags(plural)) + + def test_new_records_link_to_same_tag_neighbors(self): + first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "deploy alpha", "tags": ["deploy"]}))) + saved = self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "deploy beta", "tags": ["deploy"]})) + second = self.mem_id(saved) + self.assertIn(first, saved) + edges = self.store.edges_for(second) + self.assertEqual(len(edges), 1) + self.assertEqual(edges[0]["relation"], "shares-deploy") + self.assertEqual(edges[0]["other"], first) + + def test_baseline_only_records_do_not_link(self): + self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "lorem ipsum", "tags": []})) + second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "dolor sit", "tags": []}))) + self.assertEqual(self.store.edges_for(second), []) + + def test_auto_link_caps_at_three(self): + for pos in range(5): + self.tools.dispatch("record_save", json.dumps({"title": "n%d" % pos, "content": "shared topic here", "tags": ["topic"]})) + sixth = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "n5", "content": "shared topic again", "tags": ["topic"]}))) + edges = [edge for edge in self.store.edges_for(sixth) if edge["direction"] == "out"] + self.assertEqual(len(edges), 3) + + def test_search_expands_singular_plural(self): + legacy = self.store.add_record("note", "old", "zeta-content", ["servers"]) + self.assertIn("servers", self.store.item_tags(legacy)) + modern = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "new", "content": "zeta-content", "tags": ["server"]}))) + found = self.tools.dispatch("record_search", json.dumps({"tags": ["server"]})) + self.assertIn(legacy, found) + self.assertIn(modern, found) + + def test_tags_tool_counts_and_prefix(self): + self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "x", "tags": ["deploy", "friday"]})) + self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "y", "tags": ["deploy"]})) + listed = self.tools.dispatch("tags", "{}") + self.assertIn("deploy (2)", listed) + self.assertIn("friday (1)", listed) + self.assertLess(listed.index("deploy (2)"), listed.index("friday (1)")) + prefixed = self.tools.dispatch("tags", json.dumps({"prefix": "fri"})) + self.assertIn("friday (1)", prefixed) + self.assertNotIn("deploy", prefixed) + self.app.tools = self.tools + with mock.patch("builtins.print") as shown: + tai.handle_command(self.app, "/tags") + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn("deploy (2)", printed) + + +class ProfileTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "profile-test-1")) + self.app = FakeSecretApp(self.store) + self.tools = tai.Tools(self.app) + with tai.AGENTS_LOCK: + tai.AGENTS.clear() + tai.AGENTS_NEXT[0] = 1 + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + with tai.AGENTS_LOCK: + tai.AGENTS.clear() + tai.AGENTS_NEXT[0] = 1 + + def mem_id(self, text): + return re.search(r"mem:[0-9a-f]{16}", text).group(0) + + def test_records_isolated(self): + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "mine", "content": "t-only words", "tags": ["t-tag"]}))) + self.assertIsNone(self.store.get_record(record_id, "other")) + self.assertEqual(self.store.search_records("t-only", profile="other"), []) + self.assertEqual(len(self.store.search_records("t-only", profile="t")), 1) + self.app.profile = "other" + self.assertIn("no records match", self.tools.dispatch("record_search", json.dumps({"query": "t-only"}))) + self.assertIn("unknown record", self.tools.dispatch("record_read", json.dumps({"id": record_id}))) + + def test_secrets_isolated(self): + self.tools.dispatch("store_secret", json.dumps({"name": "api", "value": "t-value-123"})) + self.assertIsNone(self.store.load_secret("api", "other")) + self.assertEqual(self.store.list_secret_infos("other"), []) + self.store.save_secret("api", "other-value-456", None, None, "other") + self.assertEqual(self.store.load_secret("api", "t"), "t-value-123") + self.assertEqual(self.store.load_secret("api", "other"), "other-value-456") + self.assertIn("[redacted:api]", self.store.redact("leak t-value-123 here", "t")) + self.assertIn("t-value-123", self.store.redact("leak t-value-123 here", "other")) + self.app.profile = "other" + listed = self.tools.dispatch("list_secrets", "{}") + self.assertIn("api", listed) + self.assertNotIn("t-value-123", listed) + + def test_tags_vocab_isolated(self): + self.tools.dispatch("record_save", json.dumps({"title": "seed", "content": "nothing", "tags": ["deploy"]})) + self.app.profile = "other" + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "fresh", "content": "we deploy friday", "tags": []}))) + self.assertNotIn("deploy", self.store.item_tags(record_id, "other")) + self.assertEqual(self.store.tag_counts(profile="other"), [("record", 1)]) + self.assertIn("deploy", self.store.known_tags("t")) + + def test_edges_isolated(self): + first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "a", "content": "x", "tags": ["linkable"]}))) + second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "b", "content": "y", "tags": ["unlinked"]}))) + self.tools.dispatch("graph_link", json.dumps({"src": first, "dst": second, "relation": "uses"})) + self.app.profile = "other" + self.assertIn("unknown node", self.tools.dispatch("graph_query", json.dumps({"node": first}))) + self.assertEqual(self.store.edges_for(first, "other"), []) + self.app.profile = "t" + view = self.tools.dispatch("graph_query", json.dumps({"node": first})) + self.assertIn("uses", view) + + def test_audit_isolated(self): + path = os.path.join(self.tmp.name, "aud.txt") + self.tools.dispatch("write_file", json.dumps({"path": path, "content": "t-data"})) + self.assertEqual(len(self.store.audit_history(profile="t")), 1) + self.assertEqual(self.store.audit_history(profile="other"), []) + row_id = self.store.audit_history(profile="t")[0]["id"] + self.assertIsNone(self.store.audit_get(row_id, "other")) + self.app.profile = "other" + self.assertIn("no audit rows match", self.tools.dispatch("audit", "{}")) + self.assertIn("unknown audit", self.tools.dispatch("restore", json.dumps({"id": row_id}))) + + def test_schedules_isolated(self): + app = FakeSchedulerApp(self.store) + tools = tai.Tools(app) + tools.dispatch("schedule", json.dumps({"prompt": "t-job", "every": 3600})) + self.assertEqual(len(self.store.list_schedules("t")), 1) + self.assertEqual(self.store.list_schedules("other"), []) + row_id = self.store.list_schedules("t")[0]["id"] + app.profile = "other" + self.assertIn("no schedule", tools.dispatch("unschedule", json.dumps({"id": row_id}))) + self.assertFalse(self.store.remove_schedule(row_id, "other")) + self.assertTrue(self.store.remove_schedule(row_id, "t")) + self.assertIn("another profile", tools.dispatch("schedule", json.dumps({"prompt": "x", "every": 60, "profile": "t"}))) + + def test_fork_and_poll_scoped(self): + first = tai.spawn_agent("task-t", "t", 60, None, None, 0, lambda task, profile, timeout: "done-t") + second = tai.spawn_agent("task-other", "other", 60, None, None, 0, lambda task, profile, timeout: "done-other") + self.assertEqual(tai.poll_agent(first, wait=5, profile="t")[0], "done") + self.assertEqual(tai.poll_agent(second, profile="t")[0], "missing") + self.assertEqual([item["id"] for item in tai.list_agents("t")], [first]) + app = FakePrincipal() + app.store = self.store + tools = tai.Tools(app) + self.assertIn("done-t", tools.dispatch("poll", json.dumps({"id": first}))) + self.assertIn("missing", tools.dispatch("poll", json.dumps({"id": second}))) + self.assertIn("another profile", tools.dispatch("fork", json.dumps({"task": "x", "profile": "other"}))) + + def test_switch_resets_identity(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True) + agent.tools.read_files.add(("home", "/tmp/x")) + agent.tools.secret_grants.add(("shell", "", ("api",))) + with mock.patch("builtins.print"): + agent.switch_profile("newbie") + self.assertEqual(agent.profile, "newbie") + self.assertEqual(self.store.profile, "newbie") + self.assertEqual(agent.tools.read_files, set()) + self.assertEqual(agent.tools.secret_grants, set()) + self.assertEqual([item["role"] for item in agent.messages], ["system"]) + self.assertIn("newbie", self.store.list_profiles()) + self.assertIn("t", self.store.list_profiles()) + + def test_recall_scoped(self): + self.store.log_event("t", "user", "message", "t recall marker") + self.store.log_event("other", "user", "message", "other recall marker") + found = self.tools.dispatch("recall", json.dumps({"query": "recall marker"})) + self.assertIn("t recall marker", found) + self.assertNotIn("other recall marker", found) + + def test_profile_list_is_global(self): + self.store.save_system("alpha", "system a") + self.store.save_system("beta", "system b") + self.assertIn("alpha", self.store.list_profiles()) + self.assertIn("beta", self.store.list_profiles()) + + +class MigrationTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "default" + yes = True + + self.config = tai.Config(FakeArgs()) + + def tearDown(self): + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_legacy_db_migrates_to_default(self): + db = sqlite3.connect(self.config.db_path) + db.execute("CREATE TABLE secrets (name TEXT PRIMARY KEY, value TEXT, meta TEXT, updated TEXT)") + db.execute("INSERT INTO secrets VALUES ('k', 'v-legacy', '{}', '2026-01-01')") + db.execute("CREATE TABLE records (id TEXT PRIMARY KEY, kind TEXT, title TEXT, content TEXT, size INTEGER, reads INTEGER, created TEXT, updated TEXT)") + db.execute("INSERT INTO records VALUES ('mem:0123456789abcdef', 'note', 'old', 'old words', 9, 0, '', '')") + db.execute("CREATE TABLE tags (item TEXT, tag TEXT, PRIMARY KEY (item, tag))") + db.execute("INSERT INTO tags VALUES ('mem:0123456789abcdef', 'legacy')") + db.execute("CREATE TABLE edges (src TEXT, dst TEXT, relation TEXT, created TEXT, PRIMARY KEY (src, dst, relation))") + db.execute("CREATE TABLE audit (id INTEGER PRIMARY KEY, ts TEXT, actor TEXT, action TEXT, path TEXT, message TEXT, old_size INTEGER, new_size INTEGER, old TEXT, new TEXT, tags TEXT)") + db.execute("INSERT INTO audit VALUES (1, 't', 'main', 'write', '/x', '', 0, 9, NULL, 'old words', '')") + db.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, profile TEXT, ts TEXT, role TEXT, kind TEXT, text TEXT, tags TEXT)") + db.execute("CREATE TABLE schedules (id INTEGER PRIMARY KEY, name TEXT, prompt TEXT, profile TEXT, every_sec INTEGER, next_run TEXT, timeout INTEGER, status TEXT, last_status TEXT, last_result TEXT, created TEXT, updated TEXT)") + db.commit() + db.close() + store = tai.Store(self.config, tai.Seal(self.config.home, "")) + try: + self.assertEqual(store.load_secret("k"), "v-legacy") + store.save_secret("k", "v-work", None, None, "work") + self.assertEqual(store.load_secret("k", "work"), "v-work") + self.assertEqual(store.load_secret("k"), "v-legacy") + self.assertEqual(store.get_record("mem:0123456789abcdef")["content"], "old words") + self.assertIn("legacy", store.item_tags("mem:0123456789abcdef")) + self.assertEqual(len(store.audit_history()), 1) + self.assertEqual(tai.table_pk_columns(store.db, "secrets"), ["profile", "name"]) + self.assertEqual(tai.table_pk_columns(store.db, "tags"), ["item", "tag", "profile"]) + finally: + store.close() + + +class LazyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "lazy-test-1")) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def names(self, text): + return {schema["function"]["name"] for schema in tai.select_tools(text)} + + def test_core_minimal(self): + self.assertEqual(self.names("hello"), set(tai.CORE_TOOLS)) + self.assertEqual(len(tai.CORE_TOOLS), 8) + + def test_synonym_triggers(self): + pairs = [ + ("remove that file", "delete_file"), + ("set a cron reminder", "schedule"), + ("my password", "store_secret"), + ("undo that change", "restore"), + ("show version history", "audit"), + ("what tags exist", "tags"), + ("publish a release", "release"), + ("search the web", "web_search"), + ("transcribe this", "listen"), + ("check the host specs", "sysinfo"), + ("upcoming agenda", "schedules"), + ("connect these nodes", "graph_link"), + ] + for text, tool in pairs: + self.assertIn(tool, self.names(text), "missing %s for %r" % (tool, text)) + + def test_name_mention_loads_family(self): + found = self.names("use record_save for this") + for tool in ("record_save", "record_read", "record_search", "record_delete"): + self.assertIn(tool, found) + + def test_no_overtrigger(self): + self.assertEqual(self.names("hello, how are you today"), set(tai.CORE_TOOLS)) + self.assertNotIn("edit_file", self.names("tell me about this")) + + def test_catalog_lists_lazy(self): + catalog = tai.tool_catalog() + self.assertIn("## Tool catalog", catalog) + for schema in tai.TOOL_SCHEMAS: + name = schema["function"]["name"] + if name in tai.CORE_TOOLS: + continue + self.assertIn("- %s:" % name, catalog) + + def test_tool_results_feed_selection(self): + messages = [ + {"role": "user", "content": "run it"}, + {"role": "assistant", "content": None, "tool_calls": []}, + {"role": "tool", "content": "exit 0\n...[7000 chars spilled to mem:0123456789abcdef, record_read pages the rest]..."}, + ] + text = tai.conversation_text(messages) + self.assertIn("record_read", text) + self.assertIn("record_read", self.names(text)) + + def test_run_turn_uses_filtered_payload(self): + agent = tai.Agent(self.config, self.store, persist=False, quiet=True) + seen = {} + reply = {"role": "assistant", "content": "hi", "reasoning": "", "tool_calls": [], "backend": "x"} + + def fake_complete(messages, tools, stream_sink=None): + seen["tools"] = {schema["function"]["name"] for schema in tools} + return dict(reply) + + with mock.patch.object(agent.chat, "complete", side_effect=fake_complete): + agent.run_turn("hi", capture=True) + self.assertEqual(seen["tools"], set(tai.CORE_TOOLS)) + + def test_tools_repl(self): + agent = mock.Mock() + with mock.patch("builtins.print") as shown: + tai.handle_command(agent, "/tools") + tai.handle_command(agent, "/tools schedule") + tai.handle_command(agent, "/tools nope") + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn("core, always loaded", printed) + self.assertIn("schedule", printed) + self.assertIn("cron", printed) + self.assertIn("unknown tool", printed) + + +class InstallOpsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = os.path.join(self.tmp.name, "home") + os.makedirs(self.home) + self.env = mock.patch.dict(os.environ, {"HOME": self.home, "TAI_HOME": os.path.join(self.home, ".tai")}) + self.env.start() + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "install-test-1")) + self.app = FakeSecretApp(self.store) + self.app.config = self.config + self.tools = tai.Tools(self.app) + + def tearDown(self): + self.store.close() + self.env.stop() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + tai._BOX_PYTHON_CACHE.clear() + + def test_status_reports_all_targets(self): + with mock.patch.object(tai, "container_engine", return_value=None): + report = tai.install_report("status", list(tai.INSTALL_TARGETS), self.config.home, self.store) + for label in ("binary:", "bash-hook:", "venv:", "scheduler-service:", "telegram-service:", "container:", "vault:"): + self.assertIn(label, report) + self.assertIn("not installed", report) + self.assertIn("never touch data", report) + + def test_binary_install_upgrade_uninstall(self): + target = os.path.join(self.home, ".local", "bin", "tai.py") + self.assertIn("installed", tai.op_binary("install", self.config.home)) + self.assertTrue(os.access(target, os.X_OK)) + with open(os.path.abspath(tai.__file__), "rb") as handle: + self.assertEqual(open(target, "rb").read(), handle.read()) + self.assertIn("already installed", tai.op_binary("install", self.config.home)) + self.assertIn("refreshed", tai.op_binary("upgrade", self.config.home)) + self.assertIn("removed", tai.op_binary("uninstall", self.config.home)) + self.assertFalse(os.path.exists(target)) + self.assertIn("not present", tai.op_binary("uninstall", self.config.home)) + + def test_hook_install_remove_idempotent(self): + path = os.path.join(self.home, ".bashrc") + self.assertIn("source ~/.bashrc", tai.op_hook("install", self.config.home)) + with open(path, encoding="utf-8") as handle: + self.assertIn(tai.BASHRC_MARK_BEGIN, handle.read()) + self.assertIn("already present", tai.op_hook("install", self.config.home)) + self.assertIn("removed", tai.op_hook("uninstall", self.config.home)) + with open(path, encoding="utf-8") as handle: + self.assertNotIn(tai.BASHRC_MARK_BEGIN, handle.read()) + self.assertTrue(os.path.isfile(path + ".bak-tai")) + self.assertIn("not present", tai.op_hook("uninstall", self.config.home)) + + def test_venv_kept_and_removed(self): + folder = tai.venv_dir(self.config.home) + python = os.path.join(folder, "bin", "python") + os.makedirs(os.path.dirname(python)) + with open(python, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") + os.chmod(python, 0o755) + self.assertIn("kept", tai.op_venv("install", self.config.home)) + self.assertIn(python, tai.service_exec(self.config.home, "/x/tai.py")) + self.assertIn("removed", tai.op_venv("uninstall", self.config.home)) + self.assertFalse(os.path.isdir(folder)) + self.assertEqual(tai.service_exec(self.config.home, "/x/tai.py"), "/x/tai.py") + + def test_venv_create_and_unavailable(self): + with mock.patch.object(tai, "venv_available", return_value=False): + self.assertIn("unavailable", tai.op_venv("install", self.config.home)) + + def fake_run(argv, capture_output=True, text=True, timeout=300): + folder = argv[-1] + python = os.path.join(folder, "bin", "python") + os.makedirs(os.path.dirname(python)) + with open(python, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") + os.chmod(python, 0o755) + outcome = mock.Mock() + outcome.returncode = 0 + outcome.stderr = "" + return outcome + + with mock.patch.object(tai, "venv_available", return_value=True): + with mock.patch("subprocess.run", side_effect=fake_run): + self.assertIn("created", tai.op_venv("install", self.config.home)) + + def test_scheduler_unit_write_and_remove(self): + with mock.patch("shutil.which", return_value=None): + self.assertIn("unit written", tai.op_scheduler_service("install", self.config.home)) + unit = os.path.join(self.home, ".config", "systemd", "user", "tai-scheduler.service") + with open(unit, encoding="utf-8") as handle: + body = handle.read() + self.assertIn("--scheduler", body) + self.assertIn("already installed", tai.op_scheduler_service("install", self.config.home)) + self.assertIn("removed", tai.op_scheduler_service("uninstall", self.config.home)) + self.assertFalse(os.path.exists(unit)) + + def test_telegram_needs_token(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TELEGRAM_BOT_TOKEN", None) + self.assertIn("TELEGRAM_BOT_TOKEN", tai.op_telegram_service("install", self.config.home)) + self.assertIn("not present", tai.op_telegram_service("uninstall", self.config.home)) + + def test_reinstall_keeps_vault(self): + marker = b"vault-data-marker-7" + with open(os.path.join(self.config.home, "memory.db"), "wb") as handle: + handle.write(marker) + with mock.patch.object(tai, "ensure_venv", return_value=(True, "created fake")): + report = tai.install_report("reinstall", ["binary", "bash-hook", "venv"], self.config.home, self.store) + with open(os.path.join(self.config.home, "memory.db"), "rb") as handle: + self.assertIn(marker, handle.read()) + self.assertIn("never touch data", report) + + def test_install_tool_validation_and_approval(self): + self.assertIn("action must be", self.tools.dispatch("install", json.dumps({"action": "explode"}))) + self.assertIn("unknown target", self.tools.dispatch("install", json.dumps({"action": "status", "targets": ["nope"]}))) + with mock.patch.object(tai, "container_engine", return_value=None): + status = self.tools.dispatch("install", json.dumps({"action": "status"})) + self.assertIn("scheduler-service:", status) + self.app.approve = False + self.assertIn("denied by user", self.tools.dispatch("install", json.dumps({"action": "upgrade", "targets": ["venv"]}))) + self.assertIn("vault always kept", self.app.approvals[0]) + self.app.approve = True + done = self.tools.dispatch("install", json.dumps({"action": "install", "targets": ["bash-hook"]})) + self.assertIn("bash-hook:", done) + + def test_install_mentions_load_tool(self): + found = {schema["function"]["name"] for schema in tai.select_tools("how do I install tai as a service")} + self.assertIn("install", found) + found = {schema["function"]["name"] for schema in tai.select_tools("set up a venv hook")} + self.assertIn("install", found) + + def test_box_python_cache_and_containerfile(self): + self.assertIn("/box/venv", tai.BOX_CONTAINERFILE) + self.assertIn("python3-venv", tai.BOX_CONTAINERFILE) + probe = mock.Mock() + probe.returncode = 0 + with mock.patch.object(tai, "box_exec", return_value=probe) as runner: + self.assertEqual(tai.box_python("podman"), tai.BOX_PYTHON) + self.assertEqual(tai.box_python("podman"), tai.BOX_PYTHON) + self.assertEqual(runner.call_count, 1) + tai._BOX_PYTHON_CACHE.clear() + probe.returncode = 1 + with mock.patch.object(tai, "box_exec", return_value=probe): + self.assertEqual(tai.box_python("podman"), "python3") + + def test_unit_templates_format(self): + body = tai.TELEGRAM_UNIT % ("/x/tai.py", "/x/telegram.env") + self.assertIn("WorkingDirectory=%h", body) + self.assertIn("/x/tai.py --telegram", body) + body = tai.SCHEDULER_UNIT % "/x/tai.py" + self.assertIn("/x/tai.py --scheduler", body) + + def test_install_repl(self): + self.app.tools = self.tools + with mock.patch.object(tai, "container_engine", return_value=None): + with mock.patch("builtins.print") as shown: + tai.handle_command(self.app, "/install status") + tai.handle_command(self.app, "/install frobnicate") + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn("bash-hook:", printed) + self.assertIn("use /install", printed) + + +class BotsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "bots-test-1")) + self.agent = tai.Agent(self.config, self.store, persist=True, quiet=True) + self.tools = self.agent.tools + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_create_bot_roundtrip(self): + result = self.tools.dispatch("create_bot", json.dumps({"name": "Helper-Bot", "description": "helps a lot", "rules": "be kind", "behavior": "concise", "nicknames": ["help"]})) + self.assertIn("created bot 'helper-bot'", result) + self.assertIn("help", result) + self.assertIn("helper", result) + bots = {item["name"]: item for item in self.store.list_bots("t")} + self.assertIn("helper-bot", bots) + self.assertIn("help", bots["helper-bot"]["nicknames"]) + self.assertIn("helper", bots["helper-bot"]["nicknames"]) + system = self.store.load_bot_system("t", "helper-bot") + self.assertIn("helps a lot", system) + self.assertIn("Rules:", system) + self.assertIn("Behavior:", system) + self.assertEqual(self.store.resolve_bot("t", "helper-bot"), "helper-bot") + self.assertEqual(self.store.resolve_bot("t", "HELP"), "helper-bot") + self.assertEqual(self.store.resolve_bot("t", "main"), "main") + self.assertIsNone(self.store.resolve_bot("t", "nope")) + + def test_create_bot_validation(self): + self.assertIn("invalid bot name", self.tools.dispatch("create_bot", json.dumps({"name": "Bad Name!"}))) + self.assertIn("default bot", self.tools.dispatch("create_bot", json.dumps({"name": "main", "description": "x"}))) + self.assertIn("at least one", self.tools.dispatch("create_bot", json.dumps({"name": "empty"}))) + self.assertIn("nicknames must be", self.tools.dispatch("create_bot", json.dumps({"name": "x", "description": "y", "nicknames": "nope"}))) + self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "writes code", "nicknames": ["cd"]})) + self.assertIn("taken", self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "again"}))) + self.assertIn("taken", self.tools.dispatch("create_bot", json.dumps({"name": "other", "description": "y", "nicknames": ["cd"]}))) + self.assertIn("invalid nickname", self.tools.dispatch("create_bot", json.dumps({"name": "ok", "description": "y", "nicknames": ["bad nick!"]}))) + + def test_switch_bot_resumes(self): + self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "writes code", "nicknames": ["cd"]})) + self.agent.messages.append({"role": "user", "content": "main hello"}) + self.agent.messages.append({"role": "assistant", "content": "main hi"}) + with mock.patch("builtins.print"): + self.assertIn("switched", self.agent.switch_bot("coder")) + self.assertEqual(self.agent.bot, "coder") + self.assertEqual([item["role"] for item in self.agent.messages], ["system"]) + self.assertIn("writes code", self.agent.system_message) + self.agent.messages.append({"role": "user", "content": "coder hello"}) + with mock.patch("builtins.print"): + self.agent.switch_bot("main") + contents = [item.get("content", "") for item in self.agent.messages] + self.assertIn("main hello", contents) + self.assertNotIn("coder hello", contents) + with mock.patch("builtins.print"): + self.assertIn("switched", self.agent.switch_bot("cd")) + self.assertIn("coder hello", [item.get("content", "") for item in self.agent.messages]) + + def test_mention_routes_and_crossposts(self): + self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "coder-brain-9"})) + seen = [] + reply = {"role": "assistant", "content": "coded-it", "reasoning": "", "tool_calls": [], "backend": "x"} + + def fake_complete(messages, tools, stream_sink=None): + seen.append(messages[0]["content"]) + return dict(reply) + + with mock.patch.object(self.agent.chat, "complete", side_effect=fake_complete): + answer = self.agent.run_turn("@coder write frob", capture=True) + self.assertEqual(answer, "coded-it") + self.assertIn("coder-brain-9", seen[0]) + self.assertEqual(self.agent.bot, "main") + tail = self.agent.messages[-2:] + self.assertEqual(tail[0]["content"], "@coder write frob") + self.assertEqual(tail[1]["content"], "coded-it") + restored = self.store.load_session("t", "coder") + texts = [item.get("content", "") for item in restored] + self.assertIn("write frob", texts) + self.assertIn("coded-it", texts) + + def test_mention_unknown_and_self(self): + reply = {"role": "assistant", "content": "hi", "reasoning": "", "tool_calls": [], "backend": "x"} + with mock.patch.object(self.agent.chat, "complete", return_value=dict(reply)): + self.assertIn("unknown bot", self.agent.run_turn("@ghost hi", capture=True)) + self.assertEqual(self.agent.run_turn("@main hi", capture=True), "hi") + self.assertEqual(self.agent.messages[-2]["content"], "hi") + + def test_remember_goes_to_current_bot(self): + self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "v1"})) + with mock.patch("builtins.print"): + self.agent.switch_bot("coder") + self.agent.update_system("coder rules v2") + self.assertIn("v2", self.store.load_bot_system("t", "coder")) + self.assertNotIn("v2", self.store.load_system("t")) + + def test_profile_switch_resets_bot(self): + self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "v1"})) + with mock.patch("builtins.print"): + self.agent.switch_bot("coder") + self.agent.switch_profile("other") + self.assertEqual(self.agent.bot, "main") + + def test_bots_repl(self): + self.tools.dispatch("create_bot", json.dumps({"name": "coder", "description": "v1", "nicknames": ["cd"]})) + with mock.patch("builtins.print") as shown: + tai.handle_command(self.agent, "/bots") + tai.handle_command(self.agent, "/bot coder") + tai.handle_command(self.agent, "/bot") + tai.handle_command(self.agent, "/bot nope") + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn("* main", printed) + self.assertIn("coder aka cd", printed) + self.assertIn("switched to bot", printed) + self.assertIn("use /bot ", printed) + self.assertIn("unknown bot", printed) + self.assertEqual(self.agent.bot, "coder") + + +class FTSSearchTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) + self.app = FakeSecretApp(self.store) + self.app.profile = "t" + self.app.config = self.config + self.tools = tai.Tools(self.app) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def mem_id(self, text): + return re.search(r"mem:[0-9a-f]{16}", text).group(0) + + def test_triggers_sync_index(self): + self.assertTrue(self.store.fts_ok) + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "tangerine", "content": "alpha zonk"}))) + self.assertEqual(len(self.store.fts_search("zonk", ("record",), "t", 5)), 1) + self.store.upsert_file_record("/tmp/x.txt", "beta zonk") + self.assertEqual(len(self.store.fts_search("zonk", ("record",), "t", 5)), 2) + self.store.delete_record(record_id, "t") + hits = self.store.fts_search("zonk", ("record",), "t", 5) + self.assertEqual(len(hits), 1) + self.assertNotEqual(hits[0]["item"], record_id) + self.store.log_event("t", "user", "message", "zonk event here") + event_hits = self.store.fts_search("zonk", ("event",), "t", 5) + self.assertEqual(len(event_hits), 1) + self.assertIn("[zonk]", event_hits[0]["snippet"]) + found_events = self.store.search_events("t", "zonk event") + self.assertEqual(len(found_events), 1) + self.assertIn("zonk event here", found_events[0][3]) + self.assertNotIn("tai1$", found_events[0][3]) + path = os.path.join(self.tmp.name, "zonk.txt") + self.tools.dispatch("write_file", json.dumps({"path": path, "content": "x"})) + found = self.store.audit_search("zonk", "t", 5) + self.assertEqual(len(found), 1) + self.assertEqual(found[0]["path"], path) + + def test_ranked_order_and_snippet(self): + self.store.add_record("note", "sparse", "quasar convenes", [], "t") + self.store.add_record("note", "dense", "quasar " * 20, [], "t") + hits = self.store.fts_search("quasar", ("record",), "t", 5) + self.assertEqual(len(hits), 2) + self.assertEqual(hits[0]["title"], "dense") + self.assertIn("[quasar]", hits[0]["snippet"]) + + def test_unified_search_mixed_and_expand(self): + first = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "plan", "content": "harbor launch", "tags": ["harbor"]}))) + second = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "log", "content": "harbor diary", "tags": ["harbor"]}))) + self.store.log_event("t", "user", "message", "harbor standup notes") + out = self.tools.dispatch("search", json.dumps({"query": "harbor"})) + self.assertIn("[record/", out) + self.assertIn(first, out) + self.assertIn("[event/", out) + narrowed = self.tools.dispatch("search", json.dumps({"query": "harbor", "kinds": ["event"]})) + self.assertIn("[event/", narrowed) + self.assertNotIn("[record/", narrowed) + expanded = self.tools.dispatch("search", json.dumps({"query": "harbor launch", "kinds": ["record"], "expand": True})) + self.assertIn(first, expanded) + self.assertIn("linked:", expanded) + self.assertIn("log", expanded) + self.assertIn(second, self.tools.dispatch("search", json.dumps({"query": "harbor", "kinds": ["record"], "expand": False}))) + + def test_record_search_fallback_partial_token(self): + record_id = self.mem_id(self.tools.dispatch("record_save", json.dumps({"title": "soup", "content": "alphabet soup serving", "tags": ["lunch"]}))) + found = self.tools.dispatch("record_search", json.dumps({"query": "alphab", "tags": ["lunch"]})) + self.assertIn(record_id, found) + + def test_recall_sealed_fallback(self): + other_home = os.path.join(self.tmp.name, "sealed") + os.makedirs(other_home) + + class SealedArgs: + profile = "t" + yes = True + + with mock.patch.dict(os.environ, {"TAI_HOME": other_home}): + config = tai.Config(SealedArgs()) + sealed = tai.Store(config, tai.Seal(config.home, "sealed-pw-3")) + try: + sealed.log_event("t", "user", "message", "sealed recall marker words") + rows = sealed.search_events("t", "recall marker") + self.assertEqual(len(rows), 1) + self.assertIn("sealed recall marker", rows[0][3]) + finally: + sealed.close() + + def test_backfill_on_reopen(self): + self.store.add_record("note", "keep", "backfill beacon", [], "t") + self.store.db.execute("DELETE FROM fts_docs") + self.store.db.commit() + self.assertEqual(self.store.fts_search("beacon", ("record",), "t", 5), []) + self.store.close() + reopened = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) + try: + hits = reopened.fts_search("beacon", ("record",), "t", 5) + self.assertEqual(len(hits), 1) + finally: + reopened.close() + self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) + + def test_search_tool_validation(self): + self.assertIn("empty query", self.tools.dispatch("search", json.dumps({"query": ""}))) + self.assertIn("kinds must be", self.tools.dispatch("search", json.dumps({"query": "x", "kinds": ["nope"]}))) + self.assertIn("invalid limit", self.tools.dispatch("search", json.dumps({"query": "x", "limit": "z"}))) + self.assertIn("no matches", self.tools.dispatch("search", json.dumps({"query": "zzz-nothing-here"}))) + + def test_audit_query(self): + path = os.path.join(self.tmp.name, "queryme.txt") + self.tools.dispatch("write_file", json.dumps({"path": path, "content": "x"})) + out = self.tools.dispatch("audit", json.dumps({"query": "queryme"})) + self.assertIn("queryme.txt", out) + + def test_search_lazy_tag(self): + found = {schema["function"]["name"] for schema in tai.select_tools("find everything about the plan")} + self.assertIn("search", found) + + def test_search_repl(self): + self.tools.dispatch("record_save", json.dumps({"title": "repl", "content": "repl beacon words"})) + self.app.tools = self.tools + with mock.patch("builtins.print") as shown: + tai.handle_command(self.app, "/search repl beacon") + tai.handle_command(self.app, "/search") + printed = "\n".join(str(call.args[0]) for call in shown.call_args_list) + self.assertIn("[record/", printed) + self.assertIn("use /search ", printed) + + def test_mem_index_ranked_sealed(self): + self.assertIsNotNone(self.store.memdb) + self.store.log_event("t", "user", "message", "sparse comet sighting") + self.store.log_event("t", "user", "message", "comet " * 15) + hits = self.store.fts_search("comet", ("event",), "t", 5) + self.assertEqual(len(hits), 2) + self.assertLess(hits[0]["rank"], hits[1]["rank"]) + self.assertIn("[comet]", hits[0]["snippet"]) + self.assertNotIn("tai1$", hits[0]["snippet"]) + stored = self.store.db.execute("SELECT text FROM events WHERE text LIKE 'tai1$%'").fetchall() + self.assertEqual(len(stored), 2) + + def test_mem_index_cross_process_sync(self): + self.store.log_event("t", "user", "message", "first syncable event") + other = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-1")) + try: + other.log_event("t", "user", "message", "second syncable event") + finally: + other.close() + hits = self.store.fts_search("syncable", ("event",), "t", 5) + self.assertEqual(len(hits), 2) + + def test_mem_index_profile_scoped(self): + self.store.log_event("t", "user", "message", "scoped beacon words") + self.store.log_event("other", "user", "message", "scoped beacon words") + self.assertEqual(len(self.store.fts_search("beacon", ("event",), "t", 5)), 1) + self.assertEqual(len(self.store.fts_search("beacon", ("event",), "other", 5)), 1) + + def test_mem_index_survives_rotation(self): + self.store.log_event("t", "user", "message", "rotation beacon words") + old = tai.Seal(self.config.home, "fts-test-1") + self.store.close() + tai.rotate_seal(self.config, old, "fts-test-2") + reopened = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) + try: + hits = reopened.fts_search("rotation", ("event",), "t", 5) + self.assertEqual(len(hits), 1) + self.assertIn("[rotation]", hits[0]["snippet"]) + finally: + reopened.close() + self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) + + def test_run_search_sealed_events_ranked_and_partial(self): + self.store.log_event("t", "user", "message", "harbor standup notes") + out = self.tools.dispatch("search", json.dumps({"query": "harbor", "kinds": ["event"]})) + self.assertIn("[event/", out) + self.assertIn("[harbor]", out) + partial = self.tools.dispatch("search", json.dumps({"query": "standu", "kinds": ["event"]})) + self.assertIn("[event/", partial) + self.assertIn("standup", partial) + + def test_rotate_multi_profile_secrets(self): + self.store.save_secret("api", "t-secret-1", None, None, "t") + self.store.save_secret("api", "o-secret-2", None, None, "other") + old = tai.Seal(self.config.home, "fts-test-1") + self.store.close() + tai.rotate_seal(self.config, old, "fts-test-2") + reopened = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) + try: + self.assertEqual(reopened.load_secret("api", "t"), "t-secret-1") + self.assertEqual(reopened.load_secret("api", "other"), "o-secret-2") + finally: + reopened.close() + self.store = tai.Store(self.config, tai.Seal(self.config.home, "fts-test-2")) + + +class DenialTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + os.environ["TAI_HOME"] = self.tmp.name + + class FakeArgs: + profile = "t" + yes = False + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "")) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def call_reply(self, *calls): + return {"role": "assistant", "content": "", "reasoning": "", "tool_calls": list(calls), "backend": "x"} + + def test_deny_continues_with_guidance(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True) + first = self.call_reply({"id": "c1", "name": "shell", "arguments": json.dumps({"command": "ssh evil.example.com"})}) + final = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} + with mock.patch.object(agent.chat, "complete", side_effect=[first, final]) as completer: + with mock.patch("sys.stdin") as fake_stdin: + fake_stdin.isatty.return_value = True + with mock.patch("builtins.input", side_effect=["n", "do X instead"]) as asker: + with mock.patch("builtins.print"): + result = agent.run_turn("try ssh", capture=True) + self.assertEqual(result, "done") + self.assertEqual(asker.call_count, 2) + second_messages = completer.call_args_list[1].args[0] + users = [item["content"] for item in second_messages if item["role"] == "user"] + self.assertIn("do X instead", users) + tools = [item for item in second_messages if item["role"] == "tool"] + self.assertEqual(tools[0]["content"], "denied by user") + + def test_deny_empty_aborts(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True) + first = self.call_reply( + {"id": "c1", "name": "shell", "arguments": json.dumps({"command": "ssh a.example.com"})}, + {"id": "c2", "name": "shell", "arguments": json.dumps({"command": "ssh b.example.com"})}, + ) + with mock.patch.object(agent.chat, "complete", return_value=first) as completer: + with mock.patch("sys.stdin") as fake_stdin: + fake_stdin.isatty.return_value = True + with mock.patch("builtins.input", side_effect=["n", ""]): + with mock.patch("builtins.print"): + result = agent.run_turn("try ssh", capture=True) + self.assertEqual(result, "stopped by user") + self.assertEqual(completer.call_count, 1) + by_id = {} + for item in agent.messages: + for recorded in item.get("tool_calls") or []: + by_id[recorded["id"]] = None + for item in agent.messages: + if item.get("role") == "tool": + by_id[item["tool_call_id"]] = item["content"] + self.assertEqual(by_id, {"c1": "denied by user", "c2": "skipped: stopped after denial"}) + + def test_auto_never_asks(self): + agent = tai.Agent(self.config, self.store, persist=True, quiet=True, auto=True) + first = self.call_reply({"id": "c1", "name": "shell", "arguments": json.dumps({"command": "echo auto-ok"})}) + final = {"role": "assistant", "content": "done", "reasoning": "", "tool_calls": [], "backend": "x"} + with mock.patch.object(agent.chat, "complete", side_effect=[first, final]): + with mock.patch("sys.stdin") as fake_stdin: + fake_stdin.isatty.return_value = True + with mock.patch("builtins.input", side_effect=AssertionError("must not ask")) as asker: + with mock.patch("builtins.print"): + result = agent.run_turn("try echo", capture=True) + self.assertEqual(result, "done") + self.assertEqual(asker.call_count, 0) + ran = [item for item in agent.messages if item.get("role") == "tool"] + self.assertIn("auto-ok", ran[0]["content"]) + + def test_dispatch_reraises_denied(self): + tools = tai.Tools(FakeApp()) + + def raiser(args): + raise tai.Denied("go left") + + tools.handlers["boom"] = raiser + with self.assertRaises(tai.Denied) as caught: + tools.dispatch("boom", "{}") + self.assertEqual(caught.exception.guidance, "go left") + + def test_secret_delete_passes_no_guidance(self): + agent = mock.Mock() + seen = [] + + def approver(command, guidance=True): + seen.append(guidance) + return False + + agent.ask_approval = approver + agent.store = self.store + agent.profile = "t" + self.store.save_secret("wifi", "repl-value-9") + with mock.patch("builtins.print"): + tai.handle_command(agent, "/secret delete wifi") + self.assertEqual(seen, [False]) + self.assertEqual(self.store.load_secret("wifi"), "repl-value-9") + + def test_show_call_masks_secrets(self): + agent = tai.Agent(self.config, self.store, persist=False, quiet=False) + self.store.save_secret("api", "known-token-5") + with mock.patch("builtins.print") as printer: + agent.show_call({"name": "store_secret", "arguments": json.dumps({"name": "api", "value": "brand-new-1"})}) + agent.show_call({"name": "shell", "arguments": json.dumps({"command": "curl known-token-5"})}) + shown = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertNotIn("brand-new-1", shown) + self.assertIn("[hidden]", shown) + self.assertNotIn("known-token-5", shown) + self.assertIn("[redacted:api]", shown) + + +class FileGuardTests(unittest.TestCase): + def test_write_requires_read(self): + with tempfile.TemporaryDirectory() as tmp: + target = os.path.join(tmp, "notes.txt") + with open(target, "w", encoding="utf-8") as handle: + handle.write("original one") + tools = tai.Tools(FakeApp()) + denied = tools.dispatch("write_file", json.dumps({"path": target, "content": "clobber"})) + self.assertIn("read it first", denied) + with open(target, encoding="utf-8") as handle: + self.assertEqual(handle.read(), "original one") + self.assertIn("original one", tools.dispatch("read_file", json.dumps({"path": target}))) + self.assertIn("wrote", tools.dispatch("write_file", json.dumps({"path": target, "content": "updated two"}))) + with open(target, encoding="utf-8") as handle: + self.assertEqual(handle.read(), "updated two") + + def test_new_files_always_writable(self): + with tempfile.TemporaryDirectory() as tmp: + target = os.path.join(tmp, "sub", "fresh.txt") + tools = tai.Tools(FakeApp()) + self.assertIn("wrote", tools.dispatch("write_file", json.dumps({"path": target, "content": "hello"}))) + self.assertIn("edited", tools.dispatch("edit_file", json.dumps({"path": target, "find": "hello", "replace": "hi"}))) + + def test_edit_requires_read(self): + with tempfile.TemporaryDirectory() as tmp: + target = os.path.join(tmp, "code.py") + with open(target, "w", encoding="utf-8") as handle: + handle.write("print(1)") + tools = tai.Tools(FakeApp()) + self.assertIn("read it first", tools.dispatch("edit_file", json.dumps({"path": target, "find": "1", "replace": "2"}))) + tools.dispatch("read_file", json.dumps({"path": target})) + self.assertIn("edited", tools.dispatch("edit_file", json.dumps({"path": target, "find": "1", "replace": "2"}))) + + def test_guard_is_per_session(self): + with tempfile.TemporaryDirectory() as tmp: + target = os.path.join(tmp, "data.txt") + with open(target, "w", encoding="utf-8") as handle: + handle.write("v1") + first = tai.Tools(FakeApp()) + first.dispatch("read_file", json.dumps({"path": target})) + second = tai.Tools(FakeApp()) + self.assertIn("read it first", second.dispatch("write_file", json.dumps({"path": target, "content": "v2"}))) + + class FakeBot: def __init__(self): self.sent = [] @@ -200,6 +2301,7 @@ class FakePrincipal: self.profile = "t" self.config = None self.store = mock.Mock() + self.store.redact = lambda text, profile=None: text self.runner_override = None @@ -320,5 +2422,139 @@ class OrchestrationTests(unittest.TestCase): os.environ["TAI_HOME"] = old_home +class MarkdownTests(unittest.TestCase): + def test_passthrough_without_tty(self): + with mock.patch("sys.stdout") as fake_out: + fake_out.isatty.return_value = False + self.assertEqual(tai.render_markdown("**x**"), "**x**") + with mock.patch("sys.stdout") as fake_out: + fake_out.isatty.return_value = True + with mock.patch.dict(os.environ, {"NO_COLOR": "1"}): + self.assertEqual(tai.render_markdown("**x**"), "**x**") + + def test_headings(self): + out = tai.render_markdown("# One\n\n## Two\n\n### Three", width=80, color=True) + self.assertNotIn("#", out) + self.assertIn(tai.Ansi.BOLD + tai.Ansi.CYAN + "One", out) + self.assertIn(tai.Ansi.BOLD + "Two", out) + self.assertIn("Three", tai.strip_ansi(out)) + + def test_inline_styles(self): + out = tai.render_markdown("**b** *i* `c` ~~s~~ _u_ __w__", width=80, color=True) + self.assertIn(tai.style_text("b", tai.Ansi.BOLD), out) + self.assertIn(tai.style_text("i", tai.Ansi.DIM), out) + self.assertIn(tai.style_text("c", tai.Ansi.CYAN), out) + self.assertIn(tai.style_text("s", tai.Ansi.STRIKE), out) + self.assertIn(tai.style_text("u", tai.Ansi.DIM), out) + self.assertIn(tai.style_text("w", tai.Ansi.BOLD), out) + self.assertNotIn("**", out) + + def test_snake_case_survives(self): + out = tai.render_markdown("use my_var_name and `__init__` here", width=80, color=True) + self.assertIn("my_var_name", tai.strip_ansi(out)) + self.assertIn("__init__", tai.strip_ansi(out)) + self.assertNotIn(tai.Ansi.BOLD, out) + + def test_link_and_image(self): + out = tai.render_markdown("[docs](https://x.example/d) and ![alt](pic.png)", width=80, color=True) + self.assertIn("docs", out) + self.assertIn("https://x.example/d", out) + self.assertIn("[image: alt]", tai.strip_ansi(out)) + self.assertNotIn("[docs]", out) + + def test_code_fence_verbatim(self): + out = tai.render_markdown("```python\nreturn \"**x**\"\n```", width=80, color=True) + self.assertIn('"**x**"', out) + self.assertNotIn(tai.Ansi.BOLD, out) + self.assertNotIn("```", out) + + def test_unclosed_fence(self): + out = tai.render_markdown("```\ncode **x**", width=80, color=True) + self.assertIn("**x**", out) + self.assertNotIn(tai.Ansi.BOLD, out) + + def test_table_alignment(self): + source = "| item | qty |\n|---|---:|\n| apple | 12 |\n| fig | 3 |" + out = tai.render_markdown(source, width=80, color=True) + plain = tai.strip_ansi(out) + self.assertIn("┌───────┬─────┐", plain) + self.assertIn("│ apple │ 12 │", plain) + self.assertIn("│ fig │ 3 │", plain) + self.assertEqual({len(line) for line in plain.splitlines()}, {15}) + self.assertIn(tai.Ansi.BOLD, out) + + def test_table_escaped_pipe(self): + out = tai.render_markdown("| a |\n|---|\n| x\\|y |", width=80, color=True) + plain = tai.strip_ansi(out) + self.assertIn("x|y", plain) + self.assertEqual(len(plain.splitlines()), 5) + + def test_lists(self): + source = "- a\n - b\n- [x] done\n- [ ] open\n1. x\n1. y" + plain = tai.strip_ansi(tai.render_markdown(source, width=80, color=True)) + self.assertIn("• a", plain) + self.assertIn(" ◦ b", plain) + self.assertIn("☑ done", plain) + self.assertIn("☐ open", plain) + self.assertIn("1. x", plain) + self.assertIn("2. y", plain) + + def test_quote_and_rule(self): + out = tai.render_markdown("> wise\n> words\n\n---", width=40, color=True) + plain = tai.strip_ansi(out) + self.assertIn("│ wise words", plain) + self.assertIn("─" * 40, plain) + + def test_wrap_keeps_styles(self): + out = tai.render_markdown("**word " + " ".join("w%d" % n for n in range(20)) + " end**", width=30, color=True) + lines = out.splitlines() + self.assertGreater(len(lines), 1) + self.assertNotIn("**", out) + for line in lines: + self.assertLessEqual(len(tai.strip_ansi(line)), 30) + self.assertIn(tai.Ansi.BOLD, out) + + def test_escapes_and_breaks(self): + out = tai.render_markdown("a \\*b\\* c
d", width=80, color=True) + plain = tai.strip_ansi(out) + self.assertIn("*b*", plain) + self.assertNotIn(tai.Ansi.DIM, out) + self.assertEqual(plain.splitlines(), ["a *b* c", "d"]) + + def test_run_turn_renders_reply(self): + with tempfile.TemporaryDirectory() as tmp: + old_home = os.environ.get("TAI_HOME") + os.environ["TAI_HOME"] = tmp + try: + + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + store = tai.Store(config, tai.Seal(config.home, "")) + agent = tai.Agent(config, store) + reply = {"role": "assistant", "content": "# Title\n\nHello **bold**", "reasoning": "", "tool_calls": [], "backend": "x"} + with mock.patch.object(agent.chat, "complete", return_value=reply): + with mock.patch.dict(os.environ): + os.environ.pop("NO_COLOR", None) + with mock.patch("sys.stdout") as fake_out: + fake_out.isatty.return_value = True + with mock.patch("builtins.print") as printer: + with mock.patch.object(tai, "Spinner"): + result = agent.run_turn("hi") + self.assertEqual(result, "# Title\n\nHello **bold**") + printed = "\n".join(str(call.args[0]) for call in printer.call_args_list if call.args) + self.assertIn(tai.Ansi.BOLD, printed) + self.assertIn("Title", printed) + self.assertNotIn("# Title", printed) + store.close() + finally: + if old_home is None: + os.environ.pop("TAI_HOME", None) + else: + os.environ["TAI_HOME"] = old_home + + if __name__ == "__main__": unittest.main()