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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user