Input history lives in the sealed vault (input_history table) shared by the REPL and chat TUI; one-time import of the old history file

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:56:46 +02:00
co-authored by Claude Sonnet 5.5
parent 305b8f747e
commit 3c3c498b49
4 changed files with 195 additions and 46 deletions
+1 -1
View File
@@ -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
+100 -27
View File
@@ -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)
+84 -13
View File
@@ -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()
+10 -5
View File
@@ -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