diff --git a/README.md b/README.md index 12ea1d4..9ee2f61 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ A **profile** is a complete identity: system message plus session history under A **bot** is a lighter-weight thing inside one profile: just a system message plus its own history, with the whole vault shared. Every profile starts with `main`; `create_bot` adds named bots from a description, rules, behavior, and optional nicknames (lowercased, with a short prefix auto-registering). `/bot` switches roles and resumes exactly where that bot left off; `@name` routes a single turn to another bot and files the question and answer into *both* histories, so the bot you're actually talking to stays aware a detour even happened. -Leading `@tags` also select the backend: `@provider` pins a provider (`muse`, `opencode`, `ollama`, ...), `@model-id` pins a model, and tags combine freely (`@opencode @coder fix this`). A bare tag with no prompt just switches (`@coder` moves to that bot). Every selection is saved as the default for the current directory in the vault (`dir_defaults` table) and re-applied on the next boot there; `@auto` clears the pins. `@path` anywhere in the text references a file by absolute path for the agent to read. All of it Tab-completes in the REPL and in TUI boxes. +Leading `@tags` also select the backend: `@provider` pins a provider (`muse`, `opencode`, `ollama`, ...), `@model-id` pins a model, and tags combine freely (`@opencode @coder fix this`). A bare tag with no prompt just switches (`@coder` moves to that bot). Every selection is saved as the default for the current directory in the vault (`dir_defaults` table) and re-applied on the next boot there; `@auto` clears the pins. `@path` anywhere in the text references a file by absolute path for the agent to read. All of it Tab-completes in the REPL and in TUI boxes. Typed input is kept in the vault too (`input_history` table, sealed, 1000 entries per profile): the REPL loads it into readline and the `--tui` composer recalls it with Up/Down, so both front ends share one history. /profile show current profile /profile [name] switch profile, creating it when missing diff --git a/tai.py b/tai.py index 4c2ef4c..b1181a5 100755 --- a/tai.py +++ b/tai.py @@ -97,7 +97,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.30.0" +VERSION = "1.31.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -2627,6 +2627,7 @@ SCHEMA = ( ("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"), ()), ("chunks", ("id INTEGER PRIMARY KEY", "parent_kind TEXT", "parent_id TEXT", "idx INTEGER", "text TEXT", "profile TEXT", "created TEXT"), ()), ("dir_defaults", ("dir TEXT PRIMARY KEY", "provider TEXT", "model TEXT", "bot TEXT", "updated TEXT"), ()), + ("input_history", ("id INTEGER PRIMARY KEY", "profile TEXT", "bot TEXT", "ts TEXT", "text TEXT"), ()), ) SCHEMA_INDEXES = ( @@ -2641,6 +2642,7 @@ SCHEMA_INDEXES = ( ("idx_audit_profile", "audit(profile)"), ("idx_chunks_parent", "chunks(parent_kind, parent_id)"), ("idx_chunks_profile", "chunks(profile)"), + ("idx_input_history_profile", "input_history(profile, id)"), ) @@ -2664,6 +2666,10 @@ def ensure_schema(db): return notes +INPUT_HISTORY_CAP = 1000 +INPUT_HISTORY_MAX_CHARS = 4000 + + class Store: def __init__(self, config, seal): self.config = config @@ -2685,6 +2691,7 @@ class Store: 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.execute("UPDATE input_history SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") self.db.commit() self.seal_files() else: @@ -2697,6 +2704,9 @@ class Store: planned = self.db.execute("SELECT COUNT(*) FROM schedules WHERE prompt LIKE 'tai1$%'").fetchone()[0] if planned: raise SealError("schedules are sealed, set TAI_PASSPHRASE") + typed = self.db.execute("SELECT COUNT(*) FROM input_history WHERE text LIKE 'tai1$%'").fetchone()[0] + if typed: + raise SealError("input history is sealed, set TAI_PASSPHRASE") self.memdb = None self.mem_events_max = 0 if seal.enabled and self.fts_ok: @@ -2747,6 +2757,56 @@ class Store: except OSError: pass + def history_last(self, profile): + row = self.db.execute("SELECT %s FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT 1" % ("tai_dec(text)" if self.seal.enabled else "text"), (profile,)).fetchone() + return row[0] if row else None + + def history_add(self, profile, bot, text): + """Record one typed input for the profile. Sealed like events, one + profile list shared by every bot (readline habit), the bot kept as a + column for filtering. Skips empty text and an immediate repeat of the + last entry; trims the profile to INPUT_HISTORY_CAP rows in the same + transaction. Returns True when a row was written.""" + value = str(text or "").strip()[:INPUT_HISTORY_MAX_CHARS] + if not value: + return False + try: + if self.history_last(profile) == value: + return False + if self.seal.enabled: + self.db.execute("INSERT INTO input_history (profile, bot, ts, text) VALUES (?, ?, ?, tai_enc(?))", (profile, bot, now_iso(), value)) + else: + self.db.execute("INSERT INTO input_history (profile, bot, ts, text) VALUES (?, ?, ?, ?)", (profile, bot, now_iso(), value)) + self.db.execute("DELETE FROM input_history WHERE profile = ? AND id <= COALESCE((SELECT id FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)", (profile, profile, INPUT_HISTORY_CAP)) + self.db.commit() + return True + except sqlite3.Error: + return False + + def history_load(self, profile, limit=INPUT_HISTORY_CAP): + """Entries oldest to newest. A profile with no rows yet imports the + old plain-text readline file once, so earlier REPL history is kept.""" + limit = max(1, min(int(limit), INPUT_HISTORY_CAP)) + try: + count = self.db.execute("SELECT COUNT(*) FROM input_history WHERE profile = ?", (profile,)).fetchone()[0] + if count == 0: + self.history_import_file(profile, self.config.history_path) + rows = self.db.execute("SELECT %s FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT ?" % ("tai_dec(text)" if self.seal.enabled else "text"), (profile, limit)).fetchall() + except sqlite3.Error: + return [] + return [row[0] for row in reversed(rows)] + + def history_import_file(self, profile, path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + lines = [line.rstrip("\n") for line in handle if line.strip()][-INPUT_HISTORY_CAP:] + except OSError: + return 0 + written = 0 + for line in lines: + written += 1 if self.history_add(profile, "main", line) else 0 + return written + def log_event(self, profile, role, kind, text, tags=()): value = (text or "")[:2000] stamped = " " + " ".join([role, kind] + normalize_tags(tags)) + " " @@ -9735,7 +9795,6 @@ def boot(args): TUI_FPS = 30 -TUI_HISTORY_CAP = 1000 TUI_INPUT_PAINT_GAP = 0.008 @@ -12087,26 +12146,47 @@ def tui_complete_command(prefix): return found +class VaultHistory: + """History backend over the vault's input_history table: load() gives + the profile's entries oldest to newest, append(text) records one. The + REPL and the chat scene both use it, so they share one sealed list.""" + + def __init__(self, store, profile, bot=lambda: "main"): + self.store = store + self.profile = profile + self.bot = bot + + @staticmethod + def for_agent(agent): + store = getattr(agent, "store", None) + if store is None or not hasattr(store, "history_add"): + return None + return VaultHistory(store, agent.profile, lambda: agent.bot) + + def load(self): + return self.store.history_load(self.profile) + + def append(self, text): + return self.store.history_add(self.profile, self.bot(), text) + + class TuiHistory: """Input history with readline semantics: up recalls older entries, down - walks back toward the unfinished draft you started from. Optionally - backed by a plain one-entry-per-line file, the same file the REPL's - readline uses, so both front ends share it. Capped at TUI_HISTORY_CAP - entries; multi-line entries persist with newlines folded to spaces - because the file format is one line per entry.""" + walks back toward the unfinished draft you started from. Persistence is + a pluggable backend with load() and append(text) (VaultHistory); without + one it lives in memory only. Capped at INPUT_HISTORY_CAP entries.""" - def __init__(self, path=None, cap=TUI_HISTORY_CAP): - self.path = path + def __init__(self, backend=None, cap=INPUT_HISTORY_CAP): + self.backend = backend self.cap = max(1, int(cap)) self.entries = [] self.pos = None self.draft = "" self.write_errors = 0 - if path: + if backend is not None: try: - with open(path, "r", encoding="utf-8", errors="replace") as handle: - self.entries = [line.rstrip("\n") for line in handle if line.strip()][-self.cap:] - except OSError: + self.entries = [str(item) for item in backend.load()][-self.cap:] + except Exception: self.entries = [] def add(self, text): @@ -12116,11 +12196,10 @@ class TuiHistory: return self.entries.append(text) del self.entries[:-self.cap] - if self.path: + if self.backend is not None: try: - with open(self.path, "a", encoding="utf-8") as handle: - handle.write(text.replace("\n", " ") + "\n") - except OSError: + self.backend.append(text) + except Exception: self.write_errors += 1 def reset(self): @@ -14066,7 +14145,7 @@ def tui_build_chat(app, agent): mentions=lambda prefix: tui_complete_mention(prefix, bots=tuple(refs["bots"])), send_on_enter=True, on_send=lambda value: do_send(value), max_chars=TUI_CHAT_INPUT_CAP, placeholder="Write a message...", - history=TuiHistory(getattr(getattr(agent, "config", None), "history_path", None))) + history=TuiHistory(VaultHistory.for_agent(agent))) send_btn = TuiButton("Send ➤", brackets=False, w=10, h=1, border=True, rounded=True, border_style=TuiStyle(bg="role:bg", fg="role:accent"), focus_border_style=TuiStyle(bg="role:bg", fg="role:accent2"), @@ -14684,10 +14763,8 @@ def resume_turn(agent): def repl(agent): if readline is not None: - try: - readline.read_history_file(agent.config.history_path) - except OSError: - pass + for entry in agent.store.history_load(agent.profile): + readline.add_history(entry.replace("\n", " ")) readline.set_completer(lambda text, state: complete_input(text, state, agent)) readline.parse_and_bind("tab: complete") scheduler_stop = start_scheduler(agent.config, agent.store.seal) @@ -14711,6 +14788,7 @@ def repl(agent): text = line.strip() if not text: continue + agent.store.history_add(agent.profile, agent.bot, text) if text.startswith("/"): if not handle_command(agent, text): break @@ -14722,11 +14800,6 @@ def repl(agent): agent.checkpoint() finally: scheduler_stop.set() - if readline is not None: - try: - readline.write_history_file(agent.config.history_path) - except OSError: - pass agent.store.save_session(agent.profile, agent.messages, agent.bot) diff --git a/test_tai.py b/test_tai.py index 4c661b8..6693762 100644 --- a/test_tai.py +++ b/test_tai.py @@ -6172,23 +6172,94 @@ class TuiChatTests(unittest.TestCase): self.assertEqual(hist.newer(), "half typed") self.assertIsNone(hist.newer()) - def test_history_cap_and_persistence_shared_file(self): - path = os.path.join(tempfile.mkdtemp(), "history") - with open(path, "w", encoding="utf-8") as handle: - handle.write("from repl\n\nsecond\n") - hist = tai.TuiHistory(path) - self.assertEqual(hist.entries, ["from repl", "second"]) - hist.add("multi\nline") - again = tai.TuiHistory(path) - self.assertEqual(again.entries, ["from repl", "second", "multi line"]) + def vault(self, passphrase="hist-test-1", profile="t"): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + old = os.environ.get("TAI_HOME") + os.environ["TAI_HOME"] = tmp.name + self.addCleanup(lambda: os.environ.pop("TAI_HOME", None) if old is None else os.environ.update(TAI_HOME=old)) + + class FakeArgs: + yes = True + + FakeArgs.profile = profile + config = tai.Config(FakeArgs()) + store = tai.Store(config, tai.Seal(config.home, passphrase)) + self.addCleanup(store.close) + return store, config + + def test_vault_history_roundtrip_dedup_and_cap(self): + store, _config = self.vault() + self.assertTrue(store.history_add("t", "main", "one")) + self.assertFalse(store.history_add("t", "main", "one")) + self.assertFalse(store.history_add("t", "main", " ")) + store.history_add("t", "coder", "multi\nline") + self.assertEqual(store.history_load("t"), ["one", "multi\nline"]) + for n in range(tai.INPUT_HISTORY_CAP + 25): + store.history_add("t", "main", "m%d" % n) + loaded = store.history_load("t") + self.assertEqual(len(loaded), tai.INPUT_HISTORY_CAP) + self.assertEqual(loaded[-1], "m%d" % (tai.INPUT_HISTORY_CAP + 24)) + count = store.db.execute("SELECT COUNT(*) FROM input_history").fetchone()[0] + self.assertEqual(count, tai.INPUT_HISTORY_CAP) + + def test_vault_history_is_sealed_on_disk_and_scoped_per_profile(self): + store, _config = self.vault() + store.history_add("t", "main", "my secret plan") + store.history_add("other", "main", "elsewhere") + raw = store.db.execute("SELECT text FROM input_history WHERE profile = 't'").fetchone()[0] + self.assertTrue(raw.startswith("tai1$")) + self.assertNotIn("secret", raw) + self.assertEqual(store.history_load("t"), ["my secret plan"]) + self.assertEqual(store.history_load("other"), ["elsewhere"]) + bots = [row[0] for row in store.db.execute("SELECT bot FROM input_history ORDER BY id")] + self.assertEqual(bots, ["main", "main"]) + + def test_vault_history_unsealed_store_and_sealed_rows_refuse(self): + store, config = self.vault(passphrase="") + store.history_add("t", "main", "plain") + self.assertEqual(store.history_load("t"), ["plain"]) + store.db.execute("UPDATE input_history SET text = 'tai1$abc'") + store.db.commit() + with self.assertRaises(tai.SealError): + tai.Store(config, tai.Seal(config.home, "")) + + def test_vault_history_imports_old_readline_file_once(self): + store, config = self.vault() + with open(config.history_path, "w", encoding="utf-8") as handle: + handle.write("old one\n\nold two\n") + self.assertEqual(store.history_load("t"), ["old one", "old two"]) + with open(config.history_path, "a", encoding="utf-8") as handle: + handle.write("added later\n") + self.assertEqual(store.history_load("t"), ["old one", "old two"]) + + def test_tui_history_uses_vault_backend_and_survives_restart(self): + store, _config = self.vault() + agent = type("A", (), {})() + agent.store, agent.profile, agent.bot = store, "t", "main" + hist = tai.TuiHistory(tai.VaultHistory.for_agent(agent)) + hist.add("persisted") + hist.add("persisted") + again = tai.TuiHistory(tai.VaultHistory.for_agent(agent)) + self.assertEqual(again.entries, ["persisted"]) + self.assertIsNone(tai.VaultHistory.for_agent(FakeChatAgent())) + + def test_tui_history_counts_backend_failures_and_keeps_memory(self): + class Broken: + def load(self): + raise RuntimeError("down") + + def append(self, text): + raise RuntimeError("down") + + hist = tai.TuiHistory(Broken()) + hist.add("x") + self.assertEqual(hist.entries, ["x"]) + self.assertEqual(hist.write_errors, 1) small = tai.TuiHistory(cap=3) for n in range(10): small.add("m%d" % n) self.assertEqual(small.entries, ["m7", "m8", "m9"]) - blocked = tai.TuiHistory(os.path.join(path, "nope", "history")) - blocked.add("x") - self.assertEqual(blocked.entries, ["x"]) - self.assertEqual(blocked.write_errors, 1) def test_up_down_recall_history_in_composer(self): app, _agent, _root, refs = self.make_chat() diff --git a/tui.md b/tui.md index ce50665..2b996c6 100644 --- a/tui.md +++ b/tui.md @@ -182,11 +182,16 @@ callbacks; exceptions in callbacks never break the loop. Press event); `on_close(event)` fires on close. - `TuiQuestion(prompt, options, on_done, multi)` - prompt plus options; single-select answers on first pick, multi-select via OK. -- `TuiHistory(path, cap)` - readline-style input history: `older(current)` - / `newer()`, duplicate-of-last skipped, capped at `TUI_HISTORY_CAP` - (1000), optionally backed by a one-entry-per-line file (the chat scene - shares the REPL's `history` file; multi-line entries persist with - newlines folded to spaces). Pass it as `TuiTextBox(history=...)`. +- `TuiHistory(backend, cap)` - readline-style input history: + `older(current)` / `newer()`, an immediate repeat is skipped, capped at + `INPUT_HISTORY_CAP` (1000). The backend is any object with `load()` and + `append(text)`; `VaultHistory(store, profile, bot)` stores it in the + vault's sealed `input_history` table (one list per profile, shared by + every bot, the bot kept as a column), the same table the REPL reads + into readline and writes to. A profile with no rows imports the old + plain-text `history` file once. A backend failure is counted in + `write_errors` and the history keeps working in memory. Pass it as + `TuiTextBox(history=...)`. - `TuiTextBox(text, complete, mentions)` - multiline editor; arrows/ home/end/pgup/pgdn move (no line wrap), Enter splits, Backspace/Delete join, click positions the cursor, Escape bubbles