diff --git a/.gitignore b/.gitignore index aeef731..4573c81 100644 --- a/.gitignore +++ b/.gitignore @@ -8,14 +8,11 @@ # HTML scratch files *.html -*.md -!research/**/*.md -!uiflow.md -!cap.md -!skills.md -!settings.md -!mcp.md -!logging.md + +# Markdown is never ignored: agent transcripts go to vault records +# (record_save/session_export), never to stray .md files, and real docs must +# stay visible to review. Previously a blanket *.md ignore hid both strays +# (whatwentwrong.md) and real docs (taicpp/docs/*.md) from git status. # Backup files *.bak diff --git a/tai.py b/tai.py index 5cec37e..5fa88b7 100755 --- a/tai.py +++ b/tai.py @@ -386,6 +386,18 @@ def capability_block(yolo=False, auto=False): "tool call proves a limit, so act first and adjust on evidence.") +def sessions_block(home, profile): + """Vault session map for the system prompt. Without it the model greps + sealed bytes: session files and event bodies are encrypted at rest, so raw + shell/sqlite searches return nothing and only the session tools can read + them.""" + return ("\n\n## Vault sessions\nProfile '%s' sessions live in %s as " + "[.bot.].session..json, one file per shell; " + "all bodies are sealed. Never grep or sqlite raw vault bytes: use " + "sessions to list, session_export to read a full transcript, " + "recall/search to find what was said." % (profile, os.path.join(home, "profiles"))) + + def split_leading_tags(text): tags = [] rest = text or "" @@ -6592,6 +6604,49 @@ class Store: except OSError: pass + def list_sessions(self, profile, limit=50): + """Every saved session file of one profile, newest first. + + Each row: bot, sid ("" for the legacy unscoped file), path, mtime, + message count, and the last turn goal when a turn file exists.""" + rows = [] + try: + entries = os.listdir(self.config.profiles_dir) + except OSError: + return rows + main_prefix, legacy = "%s.session." % profile, "%s.session.json" % profile + bot_prefix = "%s.bot." % profile + for entry in entries: + bot, sid = None, None + if entry == legacy: + bot, sid = "main", "" + elif entry.startswith(main_prefix) and entry.endswith(".json"): + bot, sid = "main", entry[len(main_prefix):-5] + elif entry.startswith(bot_prefix) and ".session." in entry and entry.endswith(".json"): + rest = entry[len(bot_prefix):] + name, _, sid_part = rest.partition(".session.") + if name and sid_part: + bot, sid = name, sid_part[:-5] if sid_part != "json" else "" + if sid_part == "json": + bot, sid = name, "" + elif entry.startswith(bot_prefix) and entry.endswith(".session.json"): + bot, sid = entry[len(bot_prefix):-len(".session.json")], "" + if bot is None: + continue + if sid and ShellSession.sanitize(sid) != sid: + continue + path = os.path.join(self.config.profiles_dir, entry) + try: + mtime = os.path.getmtime(path) + except OSError: + continue + messages = self.load_session(profile, bot, ctx=sid if sid else None) + goal = self.load_turn_state(profile, bot, ctx=sid if sid else None).get("goal") or "" + rows.append({"bot": bot, "sid": sid, "path": path, "mtime": mtime, + "messages": len(messages), "goal": goal[:200]}) + rows.sort(key=lambda row: row["mtime"], reverse=True) + return rows[:max(1, min(limit, 50))] + def settings_row(self, key, scope, own, fallback): """Merge one catalog entry with its stored rows into a result dict. @@ -9071,6 +9126,8 @@ TOOL_SCHEMAS = [ tool_schema("install", "Manage tai installations: binary, command-not-found hook and plexer auto-wrap hook for bash/zsh/fish, venv, scheduler service, telegram service, container. Status reports what exists. Install adds missing pieces (skipping a shell hook loudly if that shell isn't on the machine), 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 (SQLite FTS5 + bm25, no vectors): records, episodic events, the audit trail, and RAG chunks. Chunks are the retrieval-grade granular hits (e.g. one slice of an exported terminal log) and come back with their actual text, not just a snippet; use the chunk's parent id with record_read to see more surrounding context. One call replaces paging through each store separately. expand adds one graph hop per record hit.", {"query": {"type": "string"}, "kinds": {"type": "array"}, "limit": {"type": "integer"}, "expand": {"type": "boolean"}}, ["query"]), + tool_schema("sessions", "List saved chat sessions of a profile: every shell context (sid), bot, message count, file path, and last turn goal, newest first. Session files and event bodies are sealed at rest, so this (with session_export) is the only way to find what was said in which session; never grep or sqlite raw vault bytes.", {"profile": {"type": "string"}, "limit": {"type": "integer"}}, []), + tool_schema("session_export", "Read one saved session as a decrypted transcript: user, assistant, and tool messages in order. Pass sid from sessions (or empty for the legacy unscoped file), bot, and offset/limit to page. Large transcripts spill to a mem: id; page them with record_read.", {"sid": {"type": "string"}, "bot": {"type": "string"}, "profile": {"type": "string"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, []), tool_schema("mcp_add", "Register an MCP server from pasted JSON/TOML config or a github URL. Normalizes the config, shows the exact spawn for approval, verifies with a live handshake, stores the connection, and enables it for the profile. Github URLs resolve via registry, package files, and README run blocks, verified live before storing.", {"source": {"type": "string"}, "name": {"type": "string"}, "profile": {"type": "string"}}, ["source"]), tool_schema("mcp_list", "List registered MCP servers with transport, enabled profiles, tool counts, and origin.", {}, []), tool_schema("mcp_enable", "Enable an MCP server for a profile (current when omitted).", {"server": {"type": "string"}, "profile": {"type": "string"}}, ["server"]), @@ -9164,6 +9221,8 @@ TOOL_TAGS = { "install": ("install", "uninstall", "reinstall", "upgrade", "setup", "service", "systemd", "venv", "hook", "deploy", "zsh", "fish"), "create_bot": ("bot", "bots", "persona", "character", "mention"), "search": ("search", "find", "lookup", "everything"), + "sessions": ("sessions", "history", "past", "transcript", "remember", "said"), + "session_export": ("session", "transcript", "export", "history", "said"), "mcp_add": ("mcp", "server", "register", "connect", "integrate"), "mcp_list": ("mcp", "servers", "connections"), "mcp_enable": ("mcp", "enable", "activate"), @@ -17126,6 +17185,8 @@ class Tools: "install": self.run_install, "create_bot": self.run_create_bot, "search": self.run_search, + "sessions": self.run_sessions, + "session_export": self.run_session_export, "mcp_add": self.run_mcp_add, "mcp_list": self.run_mcp_list, "mcp_enable": self.run_mcp_enable, @@ -17752,6 +17813,13 @@ class Tools: return "error: empty path" if self.app.env == "sandbox": return self.box_write(path, str(args.get("content") or "")) + resolved = os.path.abspath(path) + parent = os.path.dirname(resolved) + if (resolved.endswith(".md") and not os.path.exists(resolved) + and (parent == os.getcwd() or os.path.isdir(os.path.join(parent, ".git")))): + return ("error: refusing '%s': never write new markdown to a working or repository root " + "(it lands git-ignored and unreviewable). Put transcripts in a record with " + "record_save, or docs under docs/ or research/." % path) 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 @@ -17972,6 +18040,12 @@ class Tools: self.app.update_system(text) return "system message updated for profile '%s' (%d to %d chars)" % (self.app.profile, len(current), len(text)) + def session_footer(self): + return ("Sessions for profile '%s' live in %s; use sessions to list them and " + "session_export to read a full transcript. Vault bytes are sealed: " + "never grep or sqlite them raw." % ( + self.active_profile, os.path.join(self.app.store.config.home, "profiles"))) + def run_recall(self, args): query = str(args.get("query") or "").strip() if not query: @@ -17981,8 +18055,8 @@ class Tools: 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) + return "no memories matching " + query + "\n" + self.session_footer() + return "\n".join("[%s %s/%s] %s" % (stamp, role, kind, text[:300]) for stamp, role, kind, text in rows) + "\n" + self.session_footer() def box_edit(self, path, find, replace): if not path: @@ -18771,7 +18845,7 @@ class Tools: hit_ids = ",".join("%s:%s" % (hit["kind"], hit["item"]) for hit in hits[:limit]) self.app.store.log_event(profile, "tool", "search-query", "q=%s\tlimit=%d\thits=%s" % (query[:200], limit, hit_ids[:1500])) if not hits: - return "no matches for '%s'" % query[:80] + return "no matches for '%s'\n%s" % (query[:80], self.session_footer()) chunk_ids = [] for hit in hits: if hit["kind"] == "chunk": @@ -18806,8 +18880,75 @@ class Tools: lines.append("[chunk/%s] %s#%d (parent %s, read full with record_read) -- %s" % (chunk["parent_kind"], chunk["parent_id"][:60], chunk["idx"], chunk["parent_id"], preview)) else: lines.append("[audit/%s] #%s %s -- %s" % (hit["sub"], str(hit["item"]).split(":", 1)[1], hit["title"][:80], hit["snippet"])) + lines.append(self.session_footer()) return "\n".join(lines) + def run_sessions(self, args): + profile = str(args.get("profile") or "").strip() or self.active_profile + if not PROFILE_RE.match(profile): + return "error: invalid profile name" + try: + limit = int(args.get("limit") or 20) + except (TypeError, ValueError): + return "error: invalid limit" + rows = self.app.store.list_sessions(profile, max(1, min(limit, 50))) + if not rows: + return "no saved sessions for profile '%s'" % profile + lines = ["%d sessions for profile '%s', newest first:" % (len(rows), profile)] + for row in rows: + stamp = datetime.fromtimestamp(row["mtime"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M") + goal = (" :: %s" % row["goal"]) if row["goal"] else "" + lines.append("[%s/%s] %s :: %d messages :: %s%s" % ( + row["bot"], row["sid"] or "legacy", stamp, row["messages"], row["path"], goal)) + return "\n".join(lines) + + def run_session_export(self, args): + profile = str(args.get("profile") or "").strip() or self.active_profile + if not PROFILE_RE.match(profile): + return "error: invalid profile name" + bot = str(args.get("bot") or "").strip().lower() or "main" + target = self.app.store.resolve_bot(profile, bot) + if target is None: + return "error: unknown bot '%s'" % bot + sid = str(args.get("sid") or "").strip() + if sid and ShellSession.sanitize(sid) != sid: + return "error: invalid sid" + try: + offset = max(0, int(args.get("offset") or 0)) + except (TypeError, ValueError): + return "error: invalid offset" + try: + limit = int(args.get("limit") or 200) + except (TypeError, ValueError): + return "error: invalid limit" + limit = max(1, min(limit, 500)) + path = self.app.store.session_path(profile, ctx=sid or None) if target == "main" else \ + self.app.store.bot_session_path(profile, target, ctx=sid or None) + if not os.path.exists(path): + return "error: no such session '%s' (see sessions), expected %s" % (sid or "legacy", path) + messages = self.app.store.load_session(profile, target, ctx=sid or None) + if not messages: + return "error: session '%s' is empty (%s)" % (sid or "legacy", path) + if offset >= len(messages): + return "error: offset %d past %d messages" % (offset, len(messages)) + rendered = [] + for pos, item in enumerate(messages): + role = item.get("role", "?") + content = item.get("content", "") + text = content if isinstance(content, str) else json.dumps(content) + rendered.append("[%s #%d]\n%s" % (role, pos, text[:2000])) + full = "\n\n".join(rendered) + head = "session %s/%s: %d messages :: %s" % (target, sid or "legacy", len(messages), path) + page = messages[offset:offset + limit] + body = "\n\n".join(rendered[offset:offset + limit]) + if len(full) > 20000: + record_id = self.app.store.add_record("transcript", "session %s/%s" % (target, sid or "legacy"), + full, ["session", "transcript"], profile) + head += " :: full text spilled to %s, page with record_read" % record_id + if len(body) > 20000: + body = body[:20000] + "\n...[page truncated, narrow offset/limit]" + return "%s\nmessages %d-%d of %d:\n%s" % (head, offset, offset + len(page) - 1, len(messages), body) + def run_tags(self, args): prefix = str(args.get("prefix") or "").strip() try: @@ -19942,6 +20083,7 @@ class Agent: content = self.system_message + skill_catalog(self.skills) + tool_catalog() content += orientation_block(sandbox=self.env == "sandbox") content += capability_block(self.yolo, self.auto) + content += sessions_block(self.config.home, self.profile) if self.auto: content += "\n\n" + AUTONOMOUS_NOTE self.messages[0] = {"role": "system", "content": content} diff --git a/taicpp/CHANGELOG.md b/taicpp/CHANGELOG.md index 835930b..5b1f983 100644 --- a/taicpp/CHANGELOG.md +++ b/taicpp/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] - 2026-10-28 +- Session discovery: new `sessions` and `session_export` tools plus a `sessions_block` vault map in the system prompt, in tai.py and ported to detz (63 tool schemas). Session files and event bodies are sealed at rest, so raw grep/sqlite can never find what was said; `recall`/`search` now say where sessions live and refuse to guess. `write_file` refuses new markdown at working/repository roots (stray transcripts go to vault records). Covered by `SessionsTests` (8 tests), `agent_diff` `sessions_block` byte cases (7604 total, 0 mismatches), and a `Store::list_sessions` smoke test. +- REPL Ctrl keys fixed: one fd-0 reader (`LineInput::shared()`) serves the prompt, ask_user, approvals, shell_ask and secret reads, ending the 0 ms phantom-empty answers; EOF is one-shot, SIGINT counts arrivals, Ctrl-C re-prompts (second within 2 s exits 0), Ctrl-D exits, Ctrl-Z keeps default suspend. Mid-turn Ctrl-C announces itself and kills foreground shell children (`RunInterrupted`, workers excluded). Covered by `tests/repl_keys.py` (13 pty checks, no model). +- Repo hygiene: the blanket `*.md` gitignore is gone (it hid both strays and real docs); `whatwentwrong.md`/`everythingwrong.md` archived to `/tmp/tai-stray-md-20261028/`; `PARITY.md` republished with the fresh differential run. - WhatsApp store (M2): identities with trust-on-first-use, cached LID map (`WaLidMap`), full session/contact/privacy-token/message-secret/chat-settings surface on `WaStore`, and the `WaJid` parser, all transpiled from pinned whatsmeow and proven by a Go oracle differential test (`tests/whatsapp_data/store_oracle.go` → 105 vectors) plus hostile-input tests and a sanitizer run. Slice `tools/wa-slice-m2.txt` is 60/60 (100%) in `tools/wa-coverage.sh`. Four tested divergences (address format, plain-user migrate, exact identity delete, not-muted zero) are pinned in [docs/whatsapp.md](docs/whatsapp.md) section 18. Also fixed two M0 harness bugs the gates caught: `tools/san-whatsapp.sh` never compiled the store TUs, and `tools/wa-stub-scan.sh` aborted on a clean untracked file. - WhatsApp transpilation harness (M0): whatsmeow `v0.0.0-20261027104441-1eec1136a50b` (commit `1eec1136a50b...0457`) pinned in [docs/whatsapp.md](docs/whatsapp.md) section 17; `tools/wa-api-inventory.sh` extracts the mechanical scope (1500 entries: every exported symbol in non-test, non-generated Go source plus the 129 used protobuf messages), `tools/wa-mapping.tsv` maps each entry to its `detz::wa` counterpart or `UNMAPPED`, `tools/wa-coverage.sh` fails below 100% (baseline 137/1500, 9.1%), `tools/wa-stub-scan.sh` fails on stub patterns and new silent early-outs. Section 17 also opens the moloapp reuse table. diff --git a/taicpp/PARITY.md b/taicpp/PARITY.md index 7f2a402..3834384 100644 --- a/taicpp/PARITY.md +++ b/taicpp/PARITY.md @@ -5,88 +5,91 @@ Only a passing differential case (`tools/parity_diff.py`) makes a symbol `verifi after the symbol says what that case proved (for example `parse` means argument parsing only). `name-only` means an identifier matches in the C++ tree and NOTHING about behavior is known. -- tai.py sha256: `7e817bba8d48f4ab` -- detz sha256: `a7a705c9b8bd5ed0` -- differential run: 2026-10-25T01:20:14 (tai.py `7e817bba8d48f4ab`, detz `a7a705c9b8bd5ed0`) +- tai.py sha256: `190a812c0cd8d167` +- detz sha256: `c114480153577dfc` +- differential run: 2026-10-28T08:10:44 (tai.py `190a812c0cd8d167`, detz `c114480153577dfc`) ## Summary | kind | total | verified | name-only | missing | |---|---|---|---|---| -| class | 264 | 0 | 28 | 236 | -| method | 2140 | 0 | 109 | 2031 | -| function | 413 | 0 | 25 | 388 | -| tool | 61 | 0 | 61 | 0 | -| flag | 45 | 3 | 42 | 0 | -| setting | 28 | 0 | 13 | 15 | -| command | 31 | 0 | 24 | 7 | -| **all** | 2982 | 3 | 302 | 2677 | +| class | 264 | 0 | 263 | 1 | +| method | 2144 | 0 | 1406 | 738 | +| function | 414 | 2 | 385 | 27 | +| tool | 63 | 0 | 63 | 0 | +| flag | 45 | 1 | 44 | 0 | +| setting | 28 | 0 | 28 | 0 | +| command | 31 | 5 | 23 | 3 | +| **all** | 2989 | 8 | 2212 | 769 | ## Differential cases -Counts: fail=54, pass=9, xfail=1 +Counts: fail=57, pass=6, xpass=1 | case | status | reason | |---|---|---| -| boot.vault | fail | vault differs (44 diff lines) | -| cli.cap_manifest | fail | stdout differs (61 diff lines) | -| cli.err.ambiguous.cap | fail | stderr differs (18 diff lines) | -| cli.err.ambiguous.debug | fail | stderr differs (18 diff lines) | -| cli.err.ambiguous.install | fail | stderr differs (18 diff lines) | -| cli.err.ambiguous.tui | fail | stderr differs (18 diff lines) | -| cli.err.auto.explicit | fail | stderr differs (18 diff lines) | -| cli.err.cap-cwd.missing | fail | stdout differs (12 diff lines) | -| cli.err.cap-manifest.explicit | fail | stderr differs (18 diff lines) | -| cli.err.cap-max-turns.int | fail | stderr differs (19 diff lines) | -| cli.err.cap-max-turns.missing | fail | stdout differs (12 diff lines) | -| cli.err.cap-model.missing | fail | stdout differs (12 diff lines) | -| cli.err.cap-permission-mode.choice | fail | stderr differs (18 diff lines) | -| cli.err.cap-permission-mode.missing | fail | stdout differs (12 diff lines) | -| cli.err.cap-speak.explicit | fail | stderr differs (18 diff lines) | -| cli.err.continue.explicit | fail | stderr differs (18 diff lines) | -| cli.err.debug-format.choice | fail | stderr differs (18 diff lines) | -| cli.err.debug-format.missing | fail | stderr differs (17 diff lines) | -| cli.err.debug.choice | fail | stderr differs (18 diff lines) | -| cli.err.demo-shot.explicit | fail | stderr differs (18 diff lines) | -| cli.err.demo.explicit | fail | stderr differs (18 diff lines) | -| cli.err.env.missing | fail | stdout differs (12 diff lines) | -| cli.err.error_before_help | fail | stderr differs (18 diff lines) | -| cli.err.eval-retrieval.explicit | fail | stderr differs (18 diff lines) | -| cli.err.install-telegram.explicit | fail | stderr differs (18 diff lines) | -| cli.err.install.explicit | fail | stderr differs (18 diff lines) | -| cli.err.mcp-bind.missing | fail | stdout differs (12 diff lines) | -| cli.err.mcp-port.int | fail | stderr differs (19 diff lines) | -| cli.err.mcp-port.missing | fail | stdout differs (12 diff lines) | -| cli.err.no-opencode.explicit | fail | stderr differs (18 diff lines) | -| cli.err.plexer-wrap.explicit | fail | stderr differs (18 diff lines) | -| cli.err.profile.missing | fail | stdout differs (12 diff lines) | -| cli.err.scheduler.explicit | fail | stderr differs (18 diff lines) | -| cli.err.serve-mcp.missing | fail | stdout differs (12 diff lines) | -| cli.err.stats-rag.explicit | fail | stderr differs (18 diff lines) | -| cli.err.telegram.explicit | fail | stderr differs (18 diff lines) | -| cli.err.theme.missing | fail | stdout differs (12 diff lines) | -| cli.err.tui-local.explicit | fail | stderr differs (18 diff lines) | -| cli.err.tui.explicit | fail | stderr differs (18 diff lines) | -| cli.err.uninstall-telegram.explicit | fail | stderr differs (18 diff lines) | -| cli.err.unknown.long | fail | stderr differs (17 diff lines) | -| cli.err.unknown.short | fail | stderr differs (17 diff lines) | -| cli.err.update-models.explicit | fail | stderr differs (18 diff lines) | -| cli.err.version.explicit | fail | stderr differs (18 diff lines) | -| cli.err.yes.explicit | fail | stderr differs (18 diff lines) | -| cli.err.yolo.explicit | fail | stderr differs (18 diff lines) | -| cli.help.wins_over_error | fail | stdout differs (87 diff lines) | -| cli.prefix.version | fail | stdout differs (5 diff lines) | -| persist.restart_history | xfail | needs the foundation agent: vault events persistence and history resume across process restarts; detz has no REPL/agent | -| repl.help | fail | stdout differs (123 diff lines) | -| repl.profiles | fail | stdout differs (94 diff lines) | -| repl.skills_secret_schedules | fail | stdout differs (103 diff lines) | -| repl.tools | fail | stdout differs (139 diff lines) | -| repl.tools_named | fail | stdout differs (92 diff lines) | -| repl.unknown_command | fail | stdout differs (89 diff lines) | +| cli.err.ambiguous.cap | fail | stderr differs (12 diff lines) | +| cli.err.ambiguous.debug | fail | stderr differs (12 diff lines) | +| cli.err.ambiguous.install | fail | stderr differs (12 diff lines) | +| cli.err.ambiguous.tui | fail | stderr differs (12 diff lines) | +| cli.err.auto.explicit | fail | stderr differs (12 diff lines) | +| cli.err.cap-cwd.missing | fail | stderr differs (12 diff lines) | +| cli.err.cap-manifest.explicit | fail | stderr differs (12 diff lines) | +| cli.err.cap-max-turns.int | fail | stderr differs (12 diff lines) | +| cli.err.cap-max-turns.missing | fail | stderr differs (12 diff lines) | +| cli.err.cap-model.missing | fail | stderr differs (12 diff lines) | +| cli.err.cap-permission-mode.choice | fail | stderr differs (12 diff lines) | +| cli.err.cap-permission-mode.missing | fail | stderr differs (12 diff lines) | +| cli.err.cap-speak.explicit | fail | stderr differs (12 diff lines) | +| cli.err.continue.explicit | fail | stderr differs (12 diff lines) | +| cli.err.debug-format.choice | fail | stderr differs (12 diff lines) | +| cli.err.debug-format.missing | fail | stderr differs (12 diff lines) | +| cli.err.debug.choice | fail | stderr differs (12 diff lines) | +| cli.err.demo-shot.explicit | fail | stderr differs (12 diff lines) | +| cli.err.demo.explicit | fail | stderr differs (12 diff lines) | +| cli.err.env.missing | fail | stderr differs (12 diff lines) | +| cli.err.error_before_help | fail | stderr differs (12 diff lines) | +| cli.err.eval-retrieval.explicit | fail | stderr differs (12 diff lines) | +| cli.err.install-telegram.explicit | fail | stderr differs (12 diff lines) | +| cli.err.install.explicit | fail | stderr differs (12 diff lines) | +| cli.err.mcp-bind.missing | fail | stderr differs (12 diff lines) | +| cli.err.mcp-port.int | fail | stderr differs (12 diff lines) | +| cli.err.mcp-port.missing | fail | stderr differs (12 diff lines) | +| cli.err.no-opencode.explicit | fail | stderr differs (12 diff lines) | +| cli.err.plexer-wrap.explicit | fail | stderr differs (12 diff lines) | +| cli.err.profile.missing | fail | stderr differs (12 diff lines) | +| cli.err.scheduler.explicit | fail | stderr differs (12 diff lines) | +| cli.err.serve-mcp.missing | fail | stderr differs (12 diff lines) | +| cli.err.stats-rag.explicit | fail | stderr differs (12 diff lines) | +| cli.err.telegram.explicit | fail | stderr differs (12 diff lines) | +| cli.err.theme.missing | fail | stderr differs (12 diff lines) | +| cli.err.tui-local.explicit | fail | stderr differs (12 diff lines) | +| cli.err.tui.explicit | fail | stderr differs (12 diff lines) | +| cli.err.uninstall-telegram.explicit | fail | stderr differs (12 diff lines) | +| cli.err.unknown.long | fail | stderr differs (12 diff lines) | +| cli.err.unknown.short | fail | stderr differs (12 diff lines) | +| cli.err.update-models.explicit | fail | stderr differs (12 diff lines) | +| cli.err.version.explicit | fail | stderr differs (12 diff lines) | +| cli.err.yes.explicit | fail | stderr differs (12 diff lines) | +| cli.err.yolo.explicit | fail | stderr differs (12 diff lines) | +| cli.help.long | fail | stdout differs (25 diff lines) | +| cli.help.short | fail | stdout differs (25 diff lines) | +| cli.help.wins_over_error | fail | stdout differs (25 diff lines) | +| cli.ok.cap-permission-mode.confirm | fail | stdout differs (6 diff lines) | +| cli.ok.cap-permission-mode.interactive | fail | stdout differs (6 diff lines) | +| cli.ok.cap-permission-mode.none | fail | stdout differs (6 diff lines) | +| cli.ok.debug-format.json | fail | stdout differs (6 diff lines) | +| cli.ok.debug-format.sentence | fail | stdout differs (6 diff lines) | +| cli.prefix.version | fail | stdout differs (6 diff lines) | +| cli.version.long | fail | stdout differs (6 diff lines) | +| cli.version.with_choice | fail | stdout differs (6 diff lines) | +| persist.restart_history | xpass | needs the foundation agent: vault events persistence and history resume across process restarts; detz has no REPL/agent | +| repl.skills_secret_schedules | fail | stdout differs (10 diff lines) | +| repl.tools | fail | stdout differs (11 diff lines) | ## Python test-suite coverage -150 test classes, 1233 tests; tests reproduced by a passing diff case: 0 (in 0 classes); classes with no covered test: 150. +151 test classes, 1241 tests; tests reproduced by a passing diff case: 1 (in 1 classes); classes with no covered test: 150. Full table: `build/parity/tests-map.md`. ## flags (45) @@ -100,15 +103,15 @@ Full table: `build/parity/tests-map.md`. | `--binary` | name-only | | | | `--cap` | name-only | | | | `--cap-cwd` | name-only | | | -| `--cap-manifest` | name-only | | | +| `--cap-manifest` | verified | output | cli.cap_manifest | | `--cap-max-turns` | name-only | | | | `--cap-model` | name-only | | | -| `--cap-permission-mode` | verified | parse | cli.version.with_choice,cli.ok.cap-permission-mode.none,cli.ok.cap-permission-mode.confirm | +| `--cap-permission-mode` | name-only | | | | `--cap-speak` | name-only | | | | `--continue` | name-only | | | | `--cwd` | name-only | | | | `--debug` | name-only | | | -| `--debug-format` | verified | parse | cli.ok.debug-format.sentence,cli.ok.debug-format.json | +| `--debug-format` | name-only | | | | `--demo` | name-only | | | | `--demo-shot` | name-only | | | | `--deny-tool` | name-only | | | @@ -135,11 +138,11 @@ Full table: `build/parity/tests-map.md`. | `--tui-local` | name-only | | | | `--uninstall-telegram` | name-only | | | | `--update-models` | name-only | | | -| `--version` | verified | output | cli.version.long | +| `--version` | name-only | | | | `--yes` | name-only | | | | `--yolo` | name-only | | | -## tools (61) +## tools (63) | name | status | facets | cases | |---|---|---|---| @@ -188,6 +191,8 @@ Full table: `build/parity/tests-map.md`. | `schedule` | name-only | | | | `schedules` | name-only | | | | `search` | name-only | | | +| `session_export` | name-only | | | +| `sessions` | name-only | | | | `shell` | name-only | | | | `speak` | name-only | | | | `store_secret` | name-only | | | @@ -210,32 +215,32 @@ Full table: `build/parity/tests-map.md`. | name | status | facets | cases | |---|---|---|---| | `backends` | name-only | | | -| `box_caps` | missing | | | -| `claude_bin` | missing | | | -| `codex_bin` | missing | | | -| `codex_sandbox` | missing | | | +| `box_caps` | name-only | | | +| `claude_bin` | name-only | | | +| `codex_bin` | name-only | | | +| `codex_sandbox` | name-only | | | | `devplace_base` | name-only | | | | `devplace_key` | name-only | | | | `devplace_models` | name-only | | | -| `gemini_bin` | missing | | | -| `gemini_plan` | missing | | | -| `grok_bin` | missing | | | +| `gemini_bin` | name-only | | | +| `gemini_plan` | name-only | | | +| `grok_bin` | name-only | | | | `model` | name-only | | | -| `muse_bin` | missing | | | -| `opencode_bin` | missing | | | -| `opencode_client_name` | missing | | | -| `opencode_client_ua` | missing | | | -| `opencode_enabled` | missing | | | +| `muse_bin` | name-only | | | +| `opencode_bin` | name-only | | | +| `opencode_client_name` | name-only | | | +| `opencode_client_ua` | name-only | | | +| `opencode_enabled` | name-only | | | | `opencode_models` | name-only | | | -| `openrouter_enabled` | missing | | | +| `openrouter_enabled` | name-only | | | | `openrouter_key` | name-only | | | | `openrouter_models` | name-only | | | -| `pollinations_enabled` | missing | | | +| `pollinations_enabled` | name-only | | | | `pollinations_models` | name-only | | | | `retry_seconds` | name-only | | | | `theme` | name-only | | | | `voice` | name-only | | | -| `zen_enabled` | missing | | | +| `zen_enabled` | name-only | | | | `zen_models` | name-only | | | ## commands (31) @@ -243,23 +248,23 @@ Full table: `build/parity/tests-map.md`. | name | status | facets | cases | |---|---|---|---| | `/agent` | name-only | | | -| `/agents` | missing | | | +| `/agents` | name-only | | | | `/audit` | name-only | | | | `/bot` | missing | | | -| `/bots` | name-only | | | +| `/bots` | verified | all | repl.profiles | | `/clear` | name-only | | | -| `/compact` | missing | | | +| `/compact` | name-only | | | | `/ctx` | missing | | | | `/demo` | name-only | | | | `/env` | missing | | | | `/fork` | name-only | | | | `/graph` | name-only | | | -| `/help` | name-only | | | +| `/help` | verified | all | repl.help | | `/install` | name-only | | | | `/models` | name-only | | | -| `/profile` | name-only | | | -| `/profiles` | missing | | | -| `/quit` | missing | | | +| `/profile` | verified | all | repl.profiles | +| `/profiles` | verified | all | repl.profiles | +| `/quit` | name-only | | | | `/record` | name-only | | | | `/records` | name-only | | | | `/release` | name-only | | | @@ -271,35 +276,35 @@ Full table: `build/parity/tests-map.md`. | `/skills` | name-only | | | | `/sysinfo` | name-only | | | | `/tags` | name-only | | | -| `/tools` | name-only | | | +| `/tools` | verified | all | repl.tools_named | | `/unschedule` | name-only | | | -## Classes with the most missing methods (top 25 of 241) +## Classes with the most missing methods (top 25 of 227) | class | missing methods | |---|---| -| MplexCopyMode | 128 | -| MplexCommands | 92 | -| Store | 90 | -| MplexServer | 87 | -| Tools | 85 | -| TuiApp | 62 | -| GrrChatView | 55 | -| TuiPanel | 50 | -| GrrShell | 50 | -| CapAcpDriver | 44 | -| TuiVtScreen | 35 | -| ChatClient | 28 | -| Agent | 26 | -| CapA2aDriver | 24 | -| McpOAuth | 21 | -| MplexPane | 20 | -| TuiCompositor | 20 | -| TuiTextBox | 19 | -| CapPtyDriver | 19 | -| CapFleet | 19 | -| GrrPage | 18 | -| CapDriver | 17 | -| Glow | 16 | -| DebugLog | 16 | -| TuiTerminal | 16 | +| MplexCopyMode | 19 | +| TuiPanel | 14 | +| GrrShell | 13 | +| TuiApp | 12 | +| MplexPane | 10 | +| DebugLog | 9 | +| GrrView | 9 | +| GrrChatView | 9 | +| GrrPaneFiles | 9 | +| GrrPanePreview | 9 | +| TuiVtScreen | 8 | +| GrrClient | 8 | +| GrrItem | 8 | +| ChatClient | 7 | +| MplexOptions | 7 | +| MplexClient | 7 | +| TuiCompositor | 7 | +| GrrFormat | 7 | +| GrrRouter | 7 | +| GrrProfileView | 7 | +| GrrPaneTab | 7 | +| GrrControl | 7 | +| CapDriver | 7 | +| CapPtyDriver | 7 | +| LaneBoard | 6 | diff --git a/taicpp/docs/agent.md b/taicpp/docs/agent.md index e44b886..40e7c14 100644 --- a/taicpp/docs/agent.md +++ b/taicpp/docs/agent.md @@ -9,7 +9,7 @@ Literal port of tai.py 1.44.2 (lines 9016-9335, 17071-20640, 40391-40460). Names | `data.cpp` (generated) | `TOOL_SCHEMAS`, `TOOL_TAGS`, `CORE_TOOLS`, `LAZY_NAME_STOP`, prompt texts. Regenerate: `python3 -I src/agent/gen/gen_data.py > src/agent/data.cpp` | | `data_html.cpp` (generated) | `html.entities.html5` for `html.unescape` | | `agent.cpp/.h` | `Agent`, `select_tools`, `tool_catalog`, `compact_messages`, `resume_turn`, `update_models_command`, `default_runner`, mentions | -| `tools_*.cpp`, `tools.h` | `Tools` (61 handlers, `dispatch`, secrets, audit, box helpers) | +| `tools_*.cpp`, `tools.h` | `Tools` (63 handlers, `dispatch`, secrets, audit, box helpers) | | `goal.cpp` | `Denied`, `AgentsFull`, `Approval`, `GoalFailure`, `GoalVerdict`, `GoalVerifier` | | `rag.cpp` | `RetrievalEval`, `RagStats` | | `registry.cpp` | `spawn_agent`, `poll_agent`, `list_agents`, `clear_agents`, `running_agents` | @@ -31,7 +31,7 @@ Literal port of tai.py 1.44.2 (lines 9016-9335, 17071-20640, 40391-40460). Names ## Verification (what was actually run) -- `tests/agent_diff.py`: 7601 cases per seed (seeds 1-4 run, 0 mismatches). Includes the full `json.dumps(TOOL_SCHEMAS)` byte comparison, `select_tools` on 24 prompts plus fuzz, `tool_catalog`, all prompt texts, goal verdicts, CLI output parsers, history cleanup, tag helpers, html/url helpers, schedule helpers, candidate ordering of `ChatClient`. +- `tests/agent_diff.py`: 7604 cases per seed (seeds 1-4 run, 0 mismatches). Includes the full `json.dumps(TOOL_SCHEMAS)` byte comparison, `select_tools` on 24 prompts plus fuzz, `tool_catalog`, all prompt texts, goal verdicts, CLI output parsers, history cleanup, tag helpers, html/url helpers, schedule helpers, candidate ordering of `ChatClient`, `sessions_block` byte comparison. - `tests/agent_e2e.py`: 43 scripted conversations through the real Agent loop in both programs (messages, HTTP request bodies, store events, audit rows identical). Covers file tools, shell, secrets, schedules, bots/mentions, compaction, retries, 400 tool fallback, ask_user, approval deny, verify loop, remember, web_fetch/search, fork/poll, streaming quirks, deadline. - `tests/agent_sched.py`: `scheduler_tick` against a real sqlite vault. - Build for these tests: compile `src/llm/*.cpp src/agent/*.cpp tests/agent_*.cpp` with `-lcurl -lpthread` (see wiring doc). diff --git a/taicpp/docs/foundation.md b/taicpp/docs/foundation.md index e87f440..6b91fc6 100644 --- a/taicpp/docs/foundation.md +++ b/taicpp/docs/foundation.md @@ -53,6 +53,7 @@ The `shell_sessions` table records every shell id with its parent chain |---|---| | `SCHEMA`, `SCHEMA_INDEXES`, `ensure_schema`, `migrate_profiles`, `rebuild_with_profile`, `table_pk_columns`, `ensure_column`, `setup_fts` | `Schema` | | `Store.*` (history, events, search, secrets, tags, graph, records, chunks, audit, schedules, files, bots, settings, mcp, dir defaults) | `Store` same method names | +| `Store.list_sessions` | `Store::list_sessions` (bot, sid, path, mtime, message count, turn goal; newest first) | | `fts_escape`, `fts_match*`, `fts_rank` | `Fts` | | `normalize_tag(s)`, `singular_noun`, `tag_variants`, `parse_node_id` | `TagRules` | | `PROFILE_RE`, `BOT_RE`, `SECRET_RE`, `MCP_SERVER_RE` | `Patterns` | @@ -65,7 +66,7 @@ The `shell_sessions` table records every shell id with its parent chain | `get_or_create_settings`, `get_all_settings`, `upsert_settings` | `Store` | | `export_settings_to_env`, `boot_settings`, `Config.apply_store_settings` | `BootSettings::ExportToEnv`, `BootSettings::Boot`, `Config::ApplyStoreSettings` | | `Config`, `parse_backends`, `split_models`, `known_model_ids` | `Config::Build`/`FromEnv`, `ParseBackends`, `SplitModels`, `KnownModelIds` | -| `DEFAULT_SYSTEM` and the other prompt texts, `orientation_block`, `capability_block` | `Prompts` (generated byte for byte from tai.py) | +| `DEFAULT_SYSTEM` and the other prompt texts, `orientation_block`, `capability_block`, `sessions_block` | `Prompts` (generated byte for byte from tai.py) | | `ShellSession` | `session::ShellContext` | | `Agent.switch_profile`, `switch_bot`, `apply_system`, `reset_history`, `update_system`, `checkpoint`, `close_turn`, `mark_turn_open` | `session::Conversation` | | `apply_dir_defaults` | `session::DirDefaults::apply` | @@ -86,6 +87,12 @@ is the python "seal disabled" path. - `ShellSession` is named `ShellContext` because `detz::shell::ShellSession` is the command runner. - A bare `/` at the prompt prints "unknown command, try /help" (tai.py raises IndexError). - Without readline there is no line editing, arrow-key history or tab completion; `LineInput` is a plain line reader. +- One `LineInput` owns fd 0: the REPL prompt, `ask_user`, approvals, shell_ask and secret reads all go through + `LineInput::shared()`, because two buffered readers on one fd steal each other's bytes and poison each other's + EOF state (the 0 ms phantom-empty answers). EOF is one-shot (a tty recovers, a closed pipe EOFs again) and the + SIGINT flag counts arrivals so two rapid Ctrl-C are both observed. +- Ctrl keys at the REPL prompt: Ctrl-C clears the line and re-prompts, a second Ctrl-C within 2 s exits 0; + Ctrl-D exits 0; Ctrl-Z keeps the default SIGTSTP suspend (no handler installed). - `PlexerController::ask` takes the timeout as `Json` so an int stays an int on the wire like in python; a `double` overload exists. - Unbounded python bigints in JSON become doubles. @@ -115,6 +122,7 @@ Run from `taicpp/`: `make -f tests/foundation.mk test` (needs only g++, sqlite3, |---|---| | `tests/foundation_parity.py` + `foundation_probe` | one scenario replayed through tai.py and the C++ store: results, every table, the FTS index, the schema DDL and every profile file identical (timestamps and random ids masked). Pure functions (JSON, tags, settings rules, prompts, chunking, shell context against a fake /proc, flow validator and driver, Glow pieces), DebugLog sinks, REPL slash commands, native plexer against a scripted backend, tmux against a private server, and vault portability (each implementation reads the vault the other wrote) | | `tests/repl_persist.py` + `repl_probe` | the history bug: the REPL started repeatedly under a long-lived shell per pane resumes the same session file, another pane stays separate (also in real tmux panes), Ctrl-C mid-turn, an uncaught exception reaching the debug log, a pinned `TAI_SESSION` is shared, an interrupted turn is resumed by `--continue`; every assertion reads the vault | +| `tests/repl_keys.py` | Ctrl keys over a real pty against `build/detz`, no model: single Ctrl-C re-prompts, Ctrl-C aborts `/secret set` and the next prompt still reads, double Ctrl-C exits 0, Ctrl-D exits 0, Ctrl-Z suspends and SIGCONT resumes (via a session-keeping middle process, else the kernel discards SIGTSTP), session file saved | | `tests/tmux_ask.py` | tmux `ask` (menu, text popup, message, cancel, timeout) with a real client attached through a pty, both implementations, same replies | | `tests/foundation_test.cpp` | hash vectors, UTF-8 helpers, seal-enabled store paths with a stand-in seal, boot settings, dir defaults, turn files, plexer detection, line input | diff --git a/taicpp/docs/glue-issues.md b/taicpp/docs/glue-issues.md index 67fdf61..e38360d 100644 --- a/taicpp/docs/glue-issues.md +++ b/taicpp/docs/glue-issues.md @@ -17,9 +17,10 @@ around silently; items marked "glue side" are limits the glue itself has. 3. **`release` tool cannot work natively.** `do_release` rewrites the `VERSION =` line of the running script. The binary has no script. `OpsAdapter::do_release` throws and the tool reports the error; the native flow is `make release PART=... MSG=...` in `taicpp/`. `ensure_self_backup` at boot is not called for the same reason. -4. **Ctrl-C does not kill the call in flight** (glue side). `AgentReplHost` expires `agent.deadline`, the loop stops at - the next step. A running HTTP call, shell command or sandbox exec finishes first. `agent::Agent` has no - cancellation point inside `ChatClient::complete`. +4. **Ctrl-C does not kill the model call in flight** (glue side, narrowed). `AgentReplHost` expires `agent.deadline`, + the loop stops at the next step, and a foreground shell/`run_live` child is killed within 50 ms + (`RunInterrupted`, workers excluded). A running HTTP call or sandbox exec still finishes first. `agent::Agent` + has no cancellation point inside `ChatClient::complete`. 5. **MCP port is bound to the main agent** (glue side). `McpHostAdapter` asks approvals, names the audit actor and runs the resolver subagent through the primary agent, and uses the primary store connection. A worker agent on its own thread that calls an MCP tool therefore shares the primary sqlite connection. tai.py passes the calling diff --git a/taicpp/docs/glue.md b/taicpp/docs/glue.md index 532c2df..99eaea4 100644 --- a/taicpp/docs/glue.md +++ b/taicpp/docs/glue.md @@ -63,7 +63,10 @@ after every turn). The model loop lives in `agent::Agent`, which keeps its own m 3. after the turn: conversation.messages = [conversation system message] + agent.messages[1:], so the REPL's own `save()` writes exactly what the agent produced (system messages are never saved). -Ctrl-C ends the turn at the next step boundary; the model or tool call in flight is not killed (gap, see glue-issues.md). +Ctrl-C prints `interrupting at step boundary...`, expires the deadline, and kills a foreground shell child within +50 ms (`RunLive` throws `RunInterrupted`, converted to `TurnInterrupted`, so the REPL prints `interrupted` and +checkpoints like python's `KeyboardInterrupt`); the model HTTP call in flight still finishes first (gap, see +glue-issues.md). Background workers (`depth > 0`) never take Ctrl-C. `--continue` runs `Repl::resume_turn`, which calls the host. ## Flag dispatch (GlueApp::run, order of tai.py main()) diff --git a/taicpp/docs/misc.md b/taicpp/docs/misc.md index 9879d79..f4305e8 100644 --- a/taicpp/docs/misc.md +++ b/taicpp/docs/misc.md @@ -51,7 +51,7 @@ Wire format (request bodies, paths, headers) is compared against a scripted loca | chunk_lines, collapse_repeated_lines, Store.add_chunks / get_chunks_by_ids | `Chunker`, `ChunkStore` | | fts_escape, fts_match, fts_match_or, fts_match_prefix, fts_match_variants, fts_rank, Store.fts_search | `FtsQuery`, `FtsSearch` | | class RetrievalEval, the `--eval-retrieval` body | `RetrievalEval` (`run_eval`) | -| Tools.run_search | `RagTool` (+ `RagHost`) | +| Tools.run_search | `RagTool` (+ `RagHost`) | session footer included via `RagHost::profiles_dir`, byte-compared by the rag diff | `--stats-rag` does not exist in tai.py (no such argument); nothing is ported for it. diff --git a/taicpp/docs/shell.md b/taicpp/docs/shell.md index 2a136f6..8f45c06 100644 --- a/taicpp/docs/shell.md +++ b/taicpp/docs/shell.md @@ -30,7 +30,8 @@ Anything that runs a process goes through `RunLive` with `ShellIo::Stream` and ` | tai.py | C++ | notes | |---|---|---| -| `run_live`, `run_live_pty` | `RunLive::Run`, `RunLive::RunPty` | pipe path with reader thread, pty path with prompt detection | +| `run_live`, `run_live_pty` | `RunLive::Run`, `RunLive::RunPty` | pipe path with reader thread, pty path with prompt detection; `RunInterrupted` on Ctrl-C when `allow_interrupt` (foreground only), child group killed | +| `KeyboardInterrupt` out of `run_live` | `RunInterrupted` (runlive.h) | converted to `TurnInterrupted` by `AgentReplHost::run_turn`, so the REPL prints `interrupted` and checkpoints | | `RUN_CTRL_RE`, `RUN_PROMPT_RE` | `RunLive::StripControl`, `RunLive::LooksLikePrompt` | hand-written scanners, same alternation | | `PtyProc` | `PtyProc` | posix_openpt, TIOCSCTTY, TIOCSWINSZ, poll reader, echo probe | | `subprocess.Popen/run`, `shutil.which` | `Popen`, `Subprocess::Run`, `Subprocess::Which` (proc.h) | errno reporting through a CLOEXEC pipe, Python `OSError`/`TimeoutExpired` texts | @@ -75,6 +76,6 @@ it. Replacements are listed in wiring/shell.md. was not verified. - `Sandbox::EnsureBox` image build and the opencode install were exercised only against a scripted fake engine, not a real multi-minute podman build. -- `ShellIo::Ask` REPL fallback: Ctrl-C during the question is not mapped to a decline (tai.py maps - `KeyboardInterrupt`); the REPL owner decides signal handling. +- `ShellIo::Ask` REPL fallback: Ctrl-C/EOF during the question maps to a decline through the shared fd-0 reader, + like tai.py maps `KeyboardInterrupt`/`EOFError` (decline sends end-of-file to the command). - `BackendError` here is `detz::shell::BackendError`; the agent core converts it at the call site. diff --git a/taicpp/src/agent/agent.cpp b/taicpp/src/agent/agent.cpp index ee4b06a..c0d1241 100644 --- a/taicpp/src/agent/agent.cpp +++ b/taicpp/src/agent/agent.cpp @@ -4,6 +4,7 @@ #include "../llm/py.h" #include "../llm/term.h" #include "../cap/cap_mention.h" +#include "../repl/line_input.h" #include "consts.h" #include "data.h" #include "detz_tools.h" @@ -235,6 +236,16 @@ std::string capability_block(bool yolo, bool auto_mode) { "tool call proves a limit, so act first and adjust on evidence."; } +std::string sessions_block(const std::string& home, const std::string& profile) { + std::string base = home; + while (base.size() > 1 && base.back() == '/') { base.pop_back(); } + return "\n\n## Vault sessions\nProfile '" + profile + "' sessions live in " + base + "/profiles as " + + "[.bot.].session..json, one file per shell; " + + "all bodies are sealed. Never grep or sqlite raw vault bytes: use " + + "sessions to list, session_export to read a full transcript, " + + "recall/search to find what was said."; +} + std::pair, std::string> split_leading_tags(const std::string& text) { std::vector tags; std::string rest = text; @@ -315,6 +326,7 @@ void Agent::apply_system() { content += tool_catalog(); content += orientation_block("", env == "sandbox"); content += capability_block(yolo, auto_mode); + content += sessions_block(config.home, profile); if (auto_mode) { content += "\n\n" + data::AutonomousNote(); } if (messages.size() == 0) { messages.push(msg("system", content)); return; } messages.items()[0] = msg("system", content); @@ -450,10 +462,9 @@ bool Agent::ask_approval(const std::string& command, bool guidance) { print_out(paint("run: " + command, Ansi::YELLOW)); std::string decision = "unknown"; for (int retry = 0; retry < APPROVAL_MAX_RETRIES; ++retry) { - std::fputs(paint("allow? [y]once [Y]always [n]o ", Ansi::YELLOW).c_str(), stdout); - std::fflush(stdout); std::string answer; - if (!std::getline(std::cin, answer)) { return decide(false); } + auto got = repl::LineInput::shared().read(paint("allow? [y]once [Y]always [n]o ", Ansi::YELLOW), answer); + if (got != repl::LineInput::Status::Line) { return decide(false); } answer = py::strip(answer); decision = Approval::parse_allow(answer); if (decision != "unknown") { break; } @@ -468,10 +479,9 @@ bool Agent::ask_approval(const std::string& command, bool guidance) { } if (decision == "once") { return decide(true); } if (!guidance || auto_mode) { return decide(false); } - std::fputs(paint("denied. What should I do instead? (empty aborts this turn) ", Ansi::YELLOW).c_str(), stdout); - std::fflush(stdout); std::string typed; - if (!std::getline(std::cin, typed)) { typed.clear(); } + auto got = repl::LineInput::shared().read(paint("denied. What should I do instead? (empty aborts this turn) ", Ansi::YELLOW), typed); + if (got != repl::LineInput::Status::Line) { typed.clear(); } typed = py::strip(typed); decide(false); throw Denied(typed); diff --git a/taicpp/src/agent/agent.h b/taicpp/src/agent/agent.h index e537900..3bb8e25 100644 --- a/taicpp/src/agent/agent.h +++ b/taicpp/src/agent/agent.h @@ -40,6 +40,7 @@ std::pair compact_messages(const json::Value& messages, llm:: std::string dir_key(const std::string& directory = ""); std::string orientation_block(const std::string& directory = "", bool sandbox = false); std::string capability_block(bool yolo = false, bool auto_mode = false); +std::string sessions_block(const std::string& home, const std::string& profile); std::pair, std::string> split_leading_tags(const std::string& text); std::string expand_file_mentions(const std::string& text, const std::string& directory); std::set known_model_ids(const AgentConfig& config, const llm::ModelHealth* health); diff --git a/taicpp/src/agent/data.cpp b/taicpp/src/agent/data.cpp index 5753398..e245a24 100644 --- a/taicpp/src/agent/data.cpp +++ b/taicpp/src/agent/data.cpp @@ -56,6 +56,8 @@ const std::vector& ToolSchemaJson() { R"TAIRAW({"type": "function", "function": {"name": "install", "description": "Manage tai installations: binary, command-not-found hook and plexer auto-wrap hook for bash/zsh/fish, venv, scheduler service, telegram service, container. Status reports what exists. Install adds missing pieces (skipping a shell hook loudly if that shell isn't on the machine), 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.", "parameters": {"type": "object", "properties": {"action": {"type": "string"}, "targets": {"type": "array"}}, "required": ["action"]}}})TAIRAW", R"TAIRAW({"type": "function", "function": {"name": "create_bot", "description": "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.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "rules": {"type": "string"}, "behavior": {"type": "string"}, "nicknames": {"type": "array"}}, "required": ["name"]}}})TAIRAW", R"TAIRAW({"type": "function", "function": {"name": "search", "description": "Search everything at once with ranked full-text matching (SQLite FTS5 + bm25, no vectors): records, episodic events, the audit trail, and RAG chunks. Chunks are the retrieval-grade granular hits (e.g. one slice of an exported terminal log) and come back with their actual text, not just a snippet; use the chunk's parent id with record_read to see more surrounding context. One call replaces paging through each store separately. expand adds one graph hop per record hit.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "kinds": {"type": "array"}, "limit": {"type": "integer"}, "expand": {"type": "boolean"}}, "required": ["query"]}}})TAIRAW", + R"TAIRAW({"type": "function", "function": {"name": "sessions", "description": "List saved chat sessions of a profile: every shell context (sid), bot, message count, file path, and last turn goal, newest first. Session files and event bodies are sealed at rest, so this (with session_export) is the only way to find what was said in which session; never grep or sqlite raw vault bytes.", "parameters": {"type": "object", "properties": {"profile": {"type": "string"}, "limit": {"type": "integer"}}, "required": []}}})TAIRAW", + R"TAIRAW({"type": "function", "function": {"name": "session_export", "description": "Read one saved session as a decrypted transcript: user, assistant, and tool messages in order. Pass sid from sessions (or empty for the legacy unscoped file), bot, and offset/limit to page. Large transcripts spill to a mem: id; page them with record_read.", "parameters": {"type": "object", "properties": {"sid": {"type": "string"}, "bot": {"type": "string"}, "profile": {"type": "string"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, "required": []}}})TAIRAW", R"TAIRAW({"type": "function", "function": {"name": "mcp_add", "description": "Register an MCP server from pasted JSON/TOML config or a github URL. Normalizes the config, shows the exact spawn for approval, verifies with a live handshake, stores the connection, and enables it for the profile. Github URLs resolve via registry, package files, and README run blocks, verified live before storing.", "parameters": {"type": "object", "properties": {"source": {"type": "string"}, "name": {"type": "string"}, "profile": {"type": "string"}}, "required": ["source"]}}})TAIRAW", R"TAIRAW({"type": "function", "function": {"name": "mcp_list", "description": "List registered MCP servers with transport, enabled profiles, tool counts, and origin.", "parameters": {"type": "object", "properties": {}, "required": []}}})TAIRAW", R"TAIRAW({"type": "function", "function": {"name": "mcp_enable", "description": "Enable an MCP server for a profile (current when omitted).", "parameters": {"type": "object", "properties": {"server": {"type": "string"}, "profile": {"type": "string"}}, "required": ["server"]}}})TAIRAW", @@ -122,6 +124,8 @@ const std::vector>>& ToolTags() {"install", {"install", "uninstall", "reinstall", "upgrade", "setup", "service", "systemd", "venv", "hook", "deploy", "zsh", "fish"}}, {"create_bot", {"bot", "bots", "persona", "character", "mention"}}, {"search", {"search", "find", "lookup", "everything"}}, + {"sessions", {"sessions", "history", "past", "transcript", "remember", "said"}}, + {"session_export", {"session", "transcript", "export", "history", "said"}}, {"mcp_add", {"mcp", "server", "register", "connect", "integrate"}}, {"mcp_list", {"mcp", "servers", "connections"}}, {"mcp_enable", {"mcp", "enable", "activate"}}, diff --git a/taicpp/src/agent/ports.h b/taicpp/src/agent/ports.h index 07ec663..d5297e3 100644 --- a/taicpp/src/agent/ports.h +++ b/taicpp/src/agent/ports.h @@ -52,6 +52,7 @@ class StorePort { virtual void save_bots(const std::string& profile, const Value& bots) = 0; virtual Value load_turn_state(const std::string& profile, const std::string& bot, const std::string& ctx) = 0; // {} or {open, goal, steps, tools, updated} virtual void save_turn_state(const std::string& profile, const std::string& bot, const Value& state, const std::string& ctx) = 0; + virtual Value list_sessions(const std::string& profile, int64_t limit) = 0; // [{bot, sid, path, mtime, messages, goal}], newest first virtual void save_dir_defaults(const std::string& directory, const std::optional& provider, const std::optional& model, const std::optional& bot, const std::vector& clear) = 0; @@ -163,11 +164,13 @@ class ShellPort { public: virtual ~ShellPort() = default; // run_live(command, shell=True, cwd, env, stream, ask). env == nullptr: inherit. + // foreground=false: this is a background worker, Ctrl-C is not its to take. virtual RunResult run_live_shell(const std::string& command, const std::string& cwd, const std::map& extra_env, LiveStreamPort* stream, - const AskFn& ask) = 0; + const AskFn& ask, bool foreground = true) = 0; // run_live(argv, stream=, ask=) for argv commands (container exec). - virtual RunResult run_live_argv(const std::vector& argv, LiveStreamPort* stream, const AskFn& ask) = 0; + virtual RunResult run_live_argv(const std::vector& argv, LiveStreamPort* stream, const AskFn& ask, + bool foreground = true) = 0; virtual std::unique_ptr make_live_stream() = 0; // LiveStream() virtual std::vector shell_target_paths(const std::string& command, const std::string& workdir) = 0; // Sandbox box. ensure_box throws llm::BackendError. diff --git a/taicpp/src/agent/tools.h b/taicpp/src/agent/tools.h index f9e31b6..39ec5b4 100644 --- a/taicpp/src/agent/tools.h +++ b/taicpp/src/agent/tools.h @@ -137,6 +137,9 @@ class Tools { std::string run_install(const json::Value& args); std::string run_create_bot(const json::Value& args); std::string run_search(const json::Value& args); + std::string run_sessions(const json::Value& args); + std::string run_session_export(const json::Value& args); + std::string session_footer(); std::string run_mcp_add(const json::Value& args); std::string run_mcp_list(const json::Value& args); std::string run_mcp_enable(const json::Value& args); diff --git a/taicpp/src/agent/tools_core.cpp b/taicpp/src/agent/tools_core.cpp index 631239a..1b5a0c2 100644 --- a/taicpp/src/agent/tools_core.cpp +++ b/taicpp/src/agent/tools_core.cpp @@ -4,13 +4,14 @@ #include "../llm/py.h" #include "../llm/term.h" #include "../llm/consts.h" +#include "../repl/line_input.h" +#include "../shell/runlive.h" #include "consts.h" #include "data.h" #include "text.h" #include #include #include -#include #include namespace fs = std::filesystem; namespace detz::agent { @@ -111,6 +112,8 @@ const std::map& Tools::handler_table() { {"install", &Tools::run_install}, {"create_bot", &Tools::run_create_bot}, {"search", &Tools::run_search}, + {"sessions", &Tools::run_sessions}, + {"session_export", &Tools::run_session_export}, {"mcp_add", &Tools::run_mcp_add}, {"mcp_list", &Tools::run_mcp_list}, {"mcp_enable", &Tools::run_mcp_enable}, @@ -207,6 +210,9 @@ std::string Tools::dispatch(const std::string& name, const json::Value& raw_argu } catch (const Denied&) { log_call(name, args, started, "denied", "denied"); throw; + } catch (const shell::RunInterrupted&) { + log_call(name, args, started, "interrupted", "interrupted"); + throw; } catch (const std::exception& exc) { std::string text = "error: " + short_error(exc); log_call(name, args, started, "error", text); @@ -321,23 +327,10 @@ AskFn Tools::shell_ask(const std::string& command) { if (!(llm::stdin_isatty() && llm::stdout_isatty())) { return nullptr; } return [answered](const std::string& text, bool secret) -> std::optional { std::string prompt = paint("\xC2\xBB " + text + " ", Ansi::YELLOW); - std::fputs(prompt.c_str(), stdout); - std::fflush(stdout); - struct termios old_t; - bool hid = false; - if (secret && tcgetattr(0, &old_t) == 0) { - struct termios t = old_t; - t.c_lflag &= ~static_cast(ECHO); - tcsetattr(0, TCSAFLUSH, &t); - hid = true; - } std::string line; - bool got = static_cast(std::getline(std::cin, line)); - if (hid) { - tcsetattr(0, TCSAFLUSH, &old_t); - std::fputs("\n", stdout); - } - if (!got) { return answered(std::nullopt); } + auto got = secret ? repl::LineInput::shared().read_secret(prompt, line) + : repl::LineInput::shared().read(prompt, line); + if (got != repl::LineInput::Status::Line) { return answered(std::nullopt); } return answered(line); }; } @@ -412,7 +405,7 @@ std::string Tools::run_shell(const json::Value& args) { std::map extra; for (const auto& kv : vault) { extra[secret_env_name(kv.first)] = kv.second; } std::unique_ptr stream = shell_stream(command); - RunResult r = SH().run_live_shell(command, workdir, extra, stream.get(), shell_ask(command)); + RunResult r = SH().run_live_shell(command, workdir, extra, stream.get(), shell_ask(command), app.depth == 0); if (stream) { stream->close(stream_status(r.code, r.output, r.elapsed, r.timed_out)); } if (r.timed_out) { return "error: timed out after " + std::to_string(SHELL_TIMEOUT) + " seconds"; } std::string shown = py::strip(r.output); @@ -442,11 +435,13 @@ std::string Tools::run_shell_box(const std::string& command, const std::string& argv.push_back("sh"); argv.push_back("-c"); argv.push_back(command); - r = SH().run_live_argv(argv, stream.get(), ask); + r = SH().run_live_argv(argv, stream.get(), ask, app.depth == 0); } catch (const llm::BackendError&) { throw; } catch (const PortNotWired&) { throw; + } catch (const shell::RunInterrupted&) { + throw; } catch (const std::exception& exc) { return "error: " + short_error(exc); } @@ -574,7 +569,18 @@ std::string Tools::run_write_file(const json::Value& args) { if (app.env == "sandbox") { return box_write(path, py::str_or_empty(args.get("content")), std::nullopt); } - std::pair key{"home", abspath(path)}; + std::string resolved = abspath(path); + { + std::string parent = py_dirname(resolved); + std::error_code ec; + bool is_new_md = resolved.size() >= 3 && resolved.compare(resolved.size() - 3, 3, ".md") == 0 && !isfile(path); + bool at_root = parent == fs::current_path(ec).string() || fs::is_directory(parent + "/.git", ec); + if (is_new_md && !ec && at_root) { + return "error: refusing '" + path + "': never write new markdown to a working or repository root " + + "(it lands git-ignored and unreviewable). Put transcripts in a record with record_save, or docs under docs/ or research/."; + } + } + std::pair key{"home", resolved}; if (isfile(path) && !read_files.count(key)) { return "error: write denied for '" + path + "', read it first with read_file"; } diff --git a/taicpp/src/agent/tools_memory.cpp b/taicpp/src/agent/tools_memory.cpp index 73cd5b3..c58d246 100644 --- a/taicpp/src/agent/tools_memory.cpp +++ b/taicpp/src/agent/tools_memory.cpp @@ -7,6 +7,8 @@ #include "text.h" #include #include +#include +#include #include #include namespace detz::agent { @@ -92,11 +94,12 @@ std::string Tools::run_recall(const json::Value& args) { auto tags = string_list(raw); if (!tags.has_value()) { return "error: tags must be a list of names"; } json::Value rows = S("search_events").search_events(active_profile(), query, *tags); - if (rows.size() == 0) { return "no memories matching " + query; } + if (rows.size() == 0) { return "no memories matching " + query + "\n" + session_footer(); } std::vector lines; for (const auto& row : rows.items()) { lines.push_back("[" + sv(row.at(0)) + " " + sv(row.at(1)) + "/" + sv(row.at(2)) + "] " + py::head(sv(row.at(3)), 300)); } + lines.push_back(session_footer()); return py::join(lines, "\n"); } @@ -452,7 +455,7 @@ std::string Tools::run_search(const json::Value& args) { st.log_event(profile, "tool", "search-query", "q=" + py::head(query, 200) + "\tlimit=" + std::to_string(limit) + "\thits=" + py::head(py::join(hit_parts, ","), 1500)); std::vector contact_lines; if (want_contacts) { contact_lines = whatsapp_contact_lines(query, 5); } - if (hits.empty() && contact_lines.empty()) { return "no matches for '" + py::head(query, 80) + "'"; } + if (hits.empty() && contact_lines.empty()) { return "no matches for '" + py::head(query, 80) + "'\n" + session_footer(); } std::vector chunk_ids; for (const auto& h : hits) { if (h.get("kind").is_string() && h.get("kind").str() == "chunk") { @@ -509,9 +512,101 @@ std::string Tools::run_search(const json::Value& args) { } } lines.insert(lines.end(), contact_lines.begin(), contact_lines.end()); + lines.push_back(session_footer()); return py::join(lines, "\n"); } +std::string Tools::session_footer() { + return "Sessions for profile '" + active_profile() + "' live in " + app.config.profiles_dir + + "; use sessions to list them and session_export to read a full transcript. Vault bytes are sealed: never grep or sqlite them raw."; +} + +std::string Tools::run_sessions(const json::Value& args) { + std::string profile = py::strip(py::str_or_empty(args.get("profile"))); + if (profile.empty()) { profile = active_profile(); } + if (!profile_ok(profile)) { return "error: invalid profile name"; } + json::Value raw = args.get("limit").truthy() ? args.get("limit") : json::Value(20); + auto parsed = py::to_int(raw); + if (!parsed.has_value()) { return "error: invalid limit"; } + int64_t limit = std::max(1, std::min(*parsed, 50)); + json::Value rows = S("list_sessions").list_sessions(profile, limit); + if (rows.size() == 0) { return "no saved sessions for profile '" + profile + "'"; } + std::vector lines; + lines.push_back(std::to_string(rows.size()) + " sessions for profile '" + profile + "', newest first:"); + for (const auto& row : rows.items()) { + double mtime = row.get("mtime").is_number() ? row.get("mtime").as_double() : 0; + std::time_t t = static_cast(mtime); + std::tm tm{}; + gmtime_r(&t, &tm); + char stamp[64]; + std::snprintf(stamp, sizeof stamp, "%04d-%02d-%02d %02d:%02d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min); + std::string sid = sv(row.get("sid")); + std::string goal = sv(row.get("goal")); + lines.push_back("[" + sv(row.get("bot")) + "/" + (sid.empty() ? "legacy" : sid) + "] " + stamp + " :: " + + sv(row.get("messages")) + " messages :: " + sv(row.get("path")) + (goal.empty() ? "" : " :: " + goal)); + } + return py::join(lines, "\n"); +} + +std::string Tools::run_session_export(const json::Value& args) { + std::string profile = py::strip(py::str_or_empty(args.get("profile"))); + if (profile.empty()) { profile = active_profile(); } + if (!profile_ok(profile)) { return "error: invalid profile name"; } + std::string bot = py::lower(py::strip(py::str_or_empty(args.get("bot")))); + if (bot.empty()) { bot = "main"; } + StorePort& st = S("resolve_bot"); + auto target = st.resolve_bot(profile, bot); + if (!target.has_value()) { return "error: unknown bot '" + bot + "'"; } + std::string sid = py::strip(py::str_or_empty(args.get("sid"))); + bool sid_ok = sid.size() <= 64; + for (char c : sid) { + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.' || c == '-')) { sid_ok = false; } + } + if (!sid_ok) { return "error: invalid sid"; } + auto to_bounded = [](const json::Value& v, int64_t dflt, int64_t lo, int64_t hi, int64_t& out) { + auto p = py::to_int(v.truthy() ? v : json::Value(static_cast(dflt))); + if (!p.has_value()) { return false; } + out = std::max(lo, std::min(*p, hi)); + return true; + }; + int64_t offset, limit; + if (!to_bounded(args.get("offset"), 0, 0, INT64_MAX, offset)) { return "error: invalid offset"; } + if (!to_bounded(args.get("limit"), 200, 1, 500, limit)) { return "error: invalid limit"; } + std::string path; + { + json::Value rows = st.list_sessions(profile, 50); + for (const auto& row : rows.items()) { + if (sv(row.get("bot")) == *target && sv(row.get("sid")) == sid) { path = sv(row.get("path")); break; } + } + } + if (path.empty()) { return "error: no such session '" + (sid.empty() ? "legacy" : sid) + "' (see sessions)"; } + json::Value messages = st.load_session(profile, *target, sid); + if (messages.size() == 0) { return "error: session '" + (sid.empty() ? "legacy" : sid) + "' is empty (" + path + ")"; } + if (offset >= static_cast(messages.size())) { + return "error: offset " + std::to_string(offset) + " past " + std::to_string(messages.size()) + " messages"; + } + std::vector rendered; + for (size_t k = 0; k < messages.size(); ++k) { + const json::Value& item = messages.items()[k]; + std::string text = item.get("content").is_string() ? item.get("content").str() : item.get("content").dumps(); + rendered.push_back("[" + sv(item.get("role")) + " #" + std::to_string(k) + "]\n" + py::head(text, 2000)); + } + std::string full = py::join(rendered, "\n\n"); + std::string head = "session " + *target + "/" + (sid.empty() ? "legacy" : sid) + ": " + + std::to_string(messages.size()) + " messages :: " + path; + int64_t stop = std::min(offset + limit, static_cast(rendered.size())); + std::string body = py::join(std::vector(rendered.begin() + offset, rendered.begin() + stop), "\n\n"); + if (py::len(full) > 20000) { + std::string record_id = st.add_record("transcript", "session " + *target + "/" + (sid.empty() ? "legacy" : sid), + full, {"session", "transcript"}, profile); + head += " :: full text spilled to " + record_id + ", page with record_read"; + } + if (py::len(body) > 20000) { body = py::head(body, 20000) + "\n...[page truncated, narrow offset/limit]"; } + return head + "\nmessages " + std::to_string(offset) + "-" + std::to_string(stop - 1) + " of " + + std::to_string(messages.size()) + ":\n" + body; +} + std::string Tools::run_tags(const json::Value& args) { std::string prefix = py::strip(py::str_or_empty(args.get("prefix"))); json::Value l = args.get("limit").truthy() ? args.get("limit") : json::Value(50); diff --git a/taicpp/src/agent/tools_ops.cpp b/taicpp/src/agent/tools_ops.cpp index a1c7958..612ba23 100644 --- a/taicpp/src/agent/tools_ops.cpp +++ b/taicpp/src/agent/tools_ops.cpp @@ -3,6 +3,7 @@ #include "../llm/proc.h" #include "../llm/py.h" #include "../llm/term.h" +#include "../repl/line_input.h" #include "consts.h" #include "text.h" #include "tui_control.h" @@ -455,9 +456,8 @@ std::string Tools::run_ask_user(const json::Value& args) { std::string prompt = options.empty() ? paint("answer (free input, empty skips) ", Ansi::YELLOW) : paint("answer [1-" + std::to_string(options.size()) + ", option text, or free input] ", Ansi::YELLOW); - std::fputs(prompt.c_str(), stdout); - std::fflush(stdout); - if (!std::getline(std::cin, raw)) { + auto got = repl::LineInput::shared().read(prompt, raw); + if (got != repl::LineInput::Status::Line) { return "user gave no answer (input closed): proceed with your best assumption"; } } diff --git a/taicpp/src/config/prompts.cpp b/taicpp/src/config/prompts.cpp index 22aa469..8278133 100644 --- a/taicpp/src/config/prompts.cpp +++ b/taicpp/src/config/prompts.cpp @@ -97,4 +97,14 @@ std::string Prompts::CapabilityBlock(bool yolo, bool autonomous) { "tool call proves a limit, so act first and adjust on evidence."; } +std::string Prompts::SessionsBlock(const std::string& home, const std::string& profile) { + std::string base = home; + while (base.size() > 1 && base.back() == '/') { base.pop_back(); } + return "\n\n## Vault sessions\nProfile '" + profile + "' sessions live in " + base + "/profiles as " + + "[.bot.].session..json, one file per shell; " + + "all bodies are sealed. Never grep or sqlite raw vault bytes: use " + + "sessions to list, session_export to read a full transcript, " + + "recall/search to find what was said."; +} + } // namespace detz diff --git a/taicpp/src/config/prompts.h b/taicpp/src/config/prompts.h index 3c046ce..a9e2a1b 100644 --- a/taicpp/src/config/prompts.h +++ b/taicpp/src/config/prompts.h @@ -17,6 +17,8 @@ class Prompts { static std::string OrientationBlock(const std::string& directory = "", bool sandbox = false); // capability_block(yolo, auto): live approval state of the system prompt static std::string CapabilityBlock(bool yolo = false, bool autonomous = false); + // sessions_block(home, profile): vault session map of the system prompt + static std::string SessionsBlock(const std::string& home, const std::string& profile); }; } // namespace detz diff --git a/taicpp/src/glue/agent_repl_host.cpp b/taicpp/src/glue/agent_repl_host.cpp index a30736a..af97292 100644 --- a/taicpp/src/glue/agent_repl_host.cpp +++ b/taicpp/src/glue/agent_repl_host.cpp @@ -1,8 +1,10 @@ #include "agent_repl_host.h" #include +#include #include "../agent/registry.h" #include "../llm/cli_backends.h" #include "../repl/interrupt.h" +#include "../shell/runlive.h" #include "../tui/TuiDemo.h" #include "json_bridge.h" namespace detz::glue { @@ -18,6 +20,8 @@ class AgentReplHost::InterruptWatch { explicit InterruptWatch(agent::Agent& agent) : agent_(agent), worker_([this] { while (!stop_.load()) { if (repl::Interrupt::requested()) { + std::printf("\ninterrupting at step boundary...\n"); + std::fflush(stdout); agent_.deadline = epoch_now() - 1.0; return; } @@ -66,6 +70,11 @@ std::string AgentReplHost::run_turn(const std::string& text) { try { InterruptWatch watch(agent_); result = agent_.run_turn(text); + } catch (const shell::RunInterrupted&) { + agent_.deadline.reset(); + agent_.timed_out = false; + sync_from_agent(); + throw repl::TurnInterrupted(); } catch (...) { agent_.deadline.reset(); agent_.timed_out = false; diff --git a/taicpp/src/glue/repl_commands.cpp b/taicpp/src/glue/repl_commands.cpp index ad3b4da..4049ca3 100644 --- a/taicpp/src/glue/repl_commands.cpp +++ b/taicpp/src/glue/repl_commands.cpp @@ -1,5 +1,5 @@ #include "repl_commands.h" -#include +#include "../repl/line_input.h" #include #include #include @@ -74,23 +74,9 @@ std::optional to_int(const std::string& s) { } } std::optional read_hidden(const std::string& prompt) { - std::fputs(prompt.c_str(), stdout); - std::fflush(stdout); - struct termios old_t; - bool hid = false; - if (isatty(0) && tcgetattr(0, &old_t) == 0) { - struct termios t = old_t; - t.c_lflag &= ~static_cast(ECHO); - tcsetattr(0, TCSAFLUSH, &t); - hid = true; - } std::string line; - bool ok = static_cast(std::getline(std::cin, line)); - if (hid) { - tcsetattr(0, TCSAFLUSH, &old_t); - std::fputs("\n", stdout); - } - if (!ok) return std::nullopt; + auto got = repl::LineInput::shared().read_secret(prompt, line); + if (got != repl::LineInput::Status::Line) return std::nullopt; return line; } } // namespace diff --git a/taicpp/src/glue/shell_adapter.cpp b/taicpp/src/glue/shell_adapter.cpp index 7070086..38a1881 100644 --- a/taicpp/src/glue/shell_adapter.cpp +++ b/taicpp/src/glue/shell_adapter.cpp @@ -36,7 +36,7 @@ class BoardPort : public agent::LaneBoardPort { agent::RunResult ShellAdapter::run_live_shell(const std::string& command, const std::string& cwd, const std::map& extra_env, agent::LiveStreamPort* stream, - const agent::AskFn& ask) { + const agent::AskFn& ask, bool foreground) { shell::EnvMap env = shell::EnvMap::FromEnviron(); for (const auto& kv : extra_env) env.Set(kv.first, kv.second); shell::RunLiveOptions o; @@ -44,13 +44,16 @@ agent::RunResult ShellAdapter::run_live_shell(const std::string& command, const o.env = &env; o.stream = sink_of(stream); o.ask = ask; + o.allow_interrupt = foreground; return convert(shell::RunLive::Run(shell::Command::Shell(command), o)); } -agent::RunResult ShellAdapter::run_live_argv(const std::vector& argv, agent::LiveStreamPort* stream, const agent::AskFn& ask) { +agent::RunResult ShellAdapter::run_live_argv(const std::vector& argv, agent::LiveStreamPort* stream, const agent::AskFn& ask, + bool foreground) { shell::RunLiveOptions o; o.stream = sink_of(stream); o.ask = ask; + o.allow_interrupt = foreground; return convert(shell::RunLive::Run(shell::Command::Argv(argv), o)); } diff --git a/taicpp/src/glue/shell_adapter.h b/taicpp/src/glue/shell_adapter.h index 89ddc6a..2af5b5e 100644 --- a/taicpp/src/glue/shell_adapter.h +++ b/taicpp/src/glue/shell_adapter.h @@ -6,8 +6,9 @@ namespace detz::glue { class ShellAdapter : public agent::ShellPort { public: agent::RunResult run_live_shell(const std::string& command, const std::string& cwd, const std::map& extra_env, - agent::LiveStreamPort* stream, const agent::AskFn& ask) override; - agent::RunResult run_live_argv(const std::vector& argv, agent::LiveStreamPort* stream, const agent::AskFn& ask) override; + agent::LiveStreamPort* stream, const agent::AskFn& ask, bool foreground = true) override; + agent::RunResult run_live_argv(const std::vector& argv, agent::LiveStreamPort* stream, const agent::AskFn& ask, + bool foreground = true) override; std::unique_ptr make_live_stream() override; std::vector shell_target_paths(const std::string& command, const std::string& workdir) override; std::string ensure_box() override; diff --git a/taicpp/src/glue/store_adapter.cpp b/taicpp/src/glue/store_adapter.cpp index c172417..77bea13 100644 --- a/taicpp/src/glue/store_adapter.cpp +++ b/taicpp/src/glue/store_adapter.cpp @@ -61,6 +61,20 @@ Value StoreAdapter::load_turn_state(const std::string& profile, const std::strin void StoreAdapter::save_turn_state(const std::string& profile, const std::string& bot, const Value& state, const std::string& ctx) { s_.save_turn_state(profile, bot, JsonBridge::store(state), ctx); } +Value StoreAdapter::list_sessions(const std::string& profile, int64_t limit) { + Value out = Value::array(); + for (auto& row : s_.list_sessions(profile, static_cast(limit))) { + Value o = Value::object(); + o.set("bot", row.bot); + o.set("sid", row.sid); + o.set("path", row.path); + o.set("mtime", row.mtime); + o.set("messages", static_cast(row.messages)); + o.set("goal", row.goal); + out.push(o); + } + return out; +} void StoreAdapter::save_dir_defaults(const std::string& directory, const std::optional& provider, const std::optional& model, const std::optional& bot, const std::vector& clear) { diff --git a/taicpp/src/glue/store_adapter.h b/taicpp/src/glue/store_adapter.h index 25a5c2d..af9b924 100644 --- a/taicpp/src/glue/store_adapter.h +++ b/taicpp/src/glue/store_adapter.h @@ -32,6 +32,7 @@ class StoreAdapter : public agent::StorePort { void save_bots(const std::string& profile, const agent::Value& bots) override; agent::Value load_turn_state(const std::string& profile, const std::string& bot, const std::string& ctx) override; void save_turn_state(const std::string& profile, const std::string& bot, const agent::Value& state, const std::string& ctx) override; + agent::Value list_sessions(const std::string& profile, int64_t limit) override; void save_dir_defaults(const std::string& directory, const std::optional& provider, const std::optional& model, const std::optional& bot, const std::vector& clear) override; diff --git a/taicpp/src/rag/ragtool.cpp b/taicpp/src/rag/ragtool.cpp index 28dfc28..74f59e1 100644 --- a/taicpp/src/rag/ragtool.cpp +++ b/taicpp/src/rag/ragtool.cpp @@ -92,7 +92,9 @@ std::string RagTool::run_search(const Json& args) { for (std::size_t i = 0; i < shown; ++i) ids.push_back(hits[i].kind + ":" + hits[i].item.as_text()); host_.log_event(profile, "tool", "search-query", "q=" + PyStr::prefix(query, 200) + "\tlimit=" + std::to_string(limit) + "\thits=" + PyStr::prefix(PyStr::join(ids, ","), 1500)); - if (hits.empty()) return "no matches for '" + PyStr::prefix(query, 80) + "'"; + std::string footer = "Sessions for profile '" + profile + "' live in " + host_.profiles_dir() + + "; use sessions to list them and session_export to read a full transcript. Vault bytes are sealed: never grep or sqlite them raw."; + if (hits.empty()) return "no matches for '" + PyStr::prefix(query, 80) + "'\n" + footer; std::vector chunk_ids; for (const auto& h : hits) { std::string tail; @@ -134,6 +136,7 @@ std::string RagTool::run_search(const Json& args) { lines.push_back("[audit/" + h.sub + "] #" + tail + " " + PyStr::prefix(h.title, 80) + " -- " + h.snippet); } } + lines.push_back(footer); return PyStr::join(lines, "\n"); } } // namespace detz::rag diff --git a/taicpp/src/rag/ragtool.h b/taicpp/src/rag/ragtool.h index 1bacf9c..2e19861 100644 --- a/taicpp/src/rag/ragtool.h +++ b/taicpp/src/rag/ragtool.h @@ -22,6 +22,7 @@ class RagHost { virtual std::map get_chunks_by_ids(const std::vector& ids, const std::string& profile) = 0; virtual std::vector edges_for(const std::string& item, const std::string& profile) = 0; // outgoing and incoming virtual std::string node_title(const std::string& node, const std::string& profile) = 0; + virtual std::string profiles_dir() const = 0; // /profiles, for the session footer }; class RagTool { diff --git a/taicpp/src/repl/interrupt.cpp b/taicpp/src/repl/interrupt.cpp index 5a21cfb..5b28ff9 100644 --- a/taicpp/src/repl/interrupt.cpp +++ b/taicpp/src/repl/interrupt.cpp @@ -4,9 +4,9 @@ namespace detz::repl { namespace { -std::atomic g_flag{false}; +std::atomic g_count{0}; // a counter, not a bool: two rapid Ctrl-C must both be seen std::atomic g_installed{false}; -void on_sigint(int) { g_flag.store(true); } +void on_sigint(int) { g_count.fetch_add(1); } } // namespace void Interrupt::install() { @@ -17,8 +17,13 @@ void Interrupt::install() { sa.sa_flags = 0; // no SA_RESTART: a pending read returns EINTR sigaction(SIGINT, &sa, nullptr); } -bool Interrupt::requested() { return g_flag.load(); } -void Interrupt::clear() { g_flag.store(false); } -void Interrupt::raise() { g_flag.store(true); } +bool Interrupt::requested() { return g_count.load() > 0; } +void Interrupt::clear() { g_count.store(0); } +void Interrupt::raise() { g_count.fetch_add(1); } +void Interrupt::consume_one() { + int seen = g_count.load(); + while (seen > 0 && !g_count.compare_exchange_weak(seen, seen - 1)) { + } +} } // namespace detz::repl diff --git a/taicpp/src/repl/interrupt.h b/taicpp/src/repl/interrupt.h index 4999a4b..c44acbc 100644 --- a/taicpp/src/repl/interrupt.h +++ b/taicpp/src/repl/interrupt.h @@ -3,12 +3,14 @@ namespace detz::repl { // SIGINT as python's KeyboardInterrupt: a handler without SA_RESTART sets a // flag and breaks a blocking read; the REPL and the turn runner poll it. +// The flag counts arrivals so two rapid Ctrl-C are both observed. class Interrupt { public: static void install(); // idempotent static bool requested(); - static void clear(); + static void clear(); // drop all pending arrivals static void raise(); // set the flag programmatically (tests) + static void consume_one(); // drop one arrival (one interrupted read) }; } // namespace detz::repl diff --git a/taicpp/src/repl/line_input.cpp b/taicpp/src/repl/line_input.cpp index 4fba227..9772e95 100644 --- a/taicpp/src/repl/line_input.cpp +++ b/taicpp/src/repl/line_input.cpp @@ -1,10 +1,41 @@ #include "line_input.h" +#include #include #include #include #include "interrupt.h" namespace detz::repl { +namespace { +// Restores termios on every exit path. TCSANOW, never TCSAFLUSH: flushing +// would discard typeahead the user already typed. +class EchoOff { + public: + explicit EchoOff(int fd) : fd_(fd) { + if (::tcgetattr(fd_, &saved_) == 0) { + struct termios quiet = saved_; + quiet.c_lflag &= ~static_cast(ECHO); + if (::tcsetattr(fd_, TCSANOW, &quiet) == 0) armed_ = true; + } + } + ~EchoOff() { + if (armed_) ::tcsetattr(fd_, TCSANOW, &saved_); + } + EchoOff(const EchoOff&) = delete; + EchoOff& operator=(const EchoOff&) = delete; + + private: + int fd_; + struct termios saved_ {}; + bool armed_{false}; +}; +} // namespace + +LineInput& LineInput::shared() { + static LineInput instance(0); + return instance; +} + LineInput::Status LineInput::read(const std::string& prompt, std::string& line) { std::fputs(prompt.c_str(), stdout); std::fflush(stdout); @@ -17,17 +48,18 @@ LineInput::Status LineInput::read(const std::string& prompt, std::string& line) return Status::Line; } if (eof_) { + eof_ = false; // one-shot: a tty recovers, a closed pipe EOFs again if (pending_.empty()) return Status::Eof; line = pending_; pending_.clear(); return Status::Line; } - if (Interrupt::requested()) { Interrupt::clear(); return Status::Interrupted; } + if (Interrupt::requested()) { Interrupt::consume_one(); return Status::Interrupted; } char buf[4096]; ssize_t n = ::read(fd_, buf, sizeof buf); if (n < 0) { if (errno == EINTR) { - if (Interrupt::requested()) { Interrupt::clear(); pending_.clear(); return Status::Interrupted; } + if (Interrupt::requested()) { Interrupt::consume_one(); pending_.clear(); return Status::Interrupted; } continue; } eof_ = true; @@ -38,4 +70,12 @@ LineInput::Status LineInput::read(const std::string& prompt, std::string& line) } } +LineInput::Status LineInput::read_secret(const std::string& prompt, std::string& line) { + EchoOff guard(fd_); + Status got = read(prompt, line); + std::fputs("\n", stdout); + std::fflush(stdout); + return got; +} + } // namespace detz::repl diff --git a/taicpp/src/repl/line_input.h b/taicpp/src/repl/line_input.h index d04a0d3..957b73d 100644 --- a/taicpp/src/repl/line_input.h +++ b/taicpp/src/repl/line_input.h @@ -5,11 +5,21 @@ namespace detz::repl { // python input(prompt): writes the prompt to stdout, reads one line from fd 0. // Eof maps to EOFError, Interrupted to KeyboardInterrupt. No line editing: // the C++ port stays free of a readline dependency (documented gap). +// +// One reader owns fd 0: every interactive prompt in the process (the REPL +// prompt, ask_user, approvals, shell_ask, secret reads) goes through the +// shared() instance, because two buffered readers on one fd steal each +// other's bytes and poison each other's EOF state. EOF is one-shot: a tty +// recovers on the next read, a closed pipe reports EOF again. class LineInput { public: enum class Status { Line, Eof, Interrupted }; explicit LineInput(int fd = 0) : fd_(fd) {} Status read(const std::string& prompt, std::string& line); + // read() with terminal echo off (python getpass): newline printed after. + Status read_secret(const std::string& prompt, std::string& line); + // The process-wide fd-0 reader all interactive prompts share. + static LineInput& shared(); private: int fd_; diff --git a/taicpp/src/repl/repl.cpp b/taicpp/src/repl/repl.cpp index c9ad647..8ed6bd8 100644 --- a/taicpp/src/repl/repl.cpp +++ b/taicpp/src/repl/repl.cpp @@ -1,4 +1,5 @@ #include "repl.h" +#include #include #include "config/prompts.h" #include "interrupt.h" @@ -20,7 +21,7 @@ std::string lower(std::string s) { for (auto& c : s) if (c >= 'A' && c <= 'Z') c } // namespace Repl::Repl(session::Conversation& conversation, ReplHost& host, Options options, LineInput* input) - : conv_(conversation), host_(host), options_(std::move(options)), input_(input ? input : &owned_input_) {} + : conv_(conversation), host_(host), options_(std::move(options)), input_(input ? input : &LineInput::shared()) {} void Repl::banner() { say(ReplStyle::title(options_.version, conv_.profile())); @@ -105,6 +106,8 @@ void Repl::loop() { Interrupt::install(); conv_.store().history_load(conv_.profile()); // readline.add_history source; also imports the old history file once host_.on_start(); + auto cold = [] { return std::chrono::steady_clock::now() - std::chrono::seconds(10); }; + auto last_interrupt = cold(); // never time_point::min(): now - min() overflows try { while (true) { int busy = host_.running_agents(); @@ -112,8 +115,15 @@ void Repl::loop() { if (!status.empty()) say(status); std::string line; auto got = input_->read(ReplStyle::prompt(conv_.profile(), conv_.yolo(), busy), line); - if (got == LineInput::Status::Eof) { std::printf("\n"); break; } - if (got == LineInput::Status::Interrupted) { std::printf("\n"); continue; } + if (got == LineInput::Status::Eof) { std::printf("\n"); break; } // Ctrl-D exits, like EOFError + if (got == LineInput::Status::Interrupted) { // Ctrl-C clears the line; a second one within 2 s exits + auto now = std::chrono::steady_clock::now(); + std::printf("\n"); + if (now - last_interrupt < std::chrono::seconds(2)) break; + last_interrupt = now; + continue; + } + last_interrupt = cold(); std::string text = Utf8::strip(line); if (text.empty()) continue; conv_.store().history_add(conv_.profile(), conv_.bot(), text); diff --git a/taicpp/src/repl/repl.h b/taicpp/src/repl/repl.h index 34fc21e..f64078f 100644 --- a/taicpp/src/repl/repl.h +++ b/taicpp/src/repl/repl.h @@ -34,8 +34,7 @@ class Repl { session::Conversation& conv_; ReplHost& host_; Options options_; - LineInput owned_input_; - LineInput* input_; + LineInput* input_; // null in the ctor means the shared fd-0 reader }; } // namespace detz::repl diff --git a/taicpp/src/session/conversation.cpp b/taicpp/src/session/conversation.cpp index 5098921..baedf9d 100644 --- a/taicpp/src/session/conversation.cpp +++ b/taicpp/src/session/conversation.cpp @@ -34,6 +34,7 @@ void Conversation::apply_system() { if (catalogs_.tool_catalog) content += catalogs_.tool_catalog(); content += Prompts::OrientationBlock("", env == "sandbox"); content += Prompts::CapabilityBlock(yolo_, auto_); + content += Prompts::SessionsBlock(config_.home, profile_); if (auto_) content += "\n\n" + Prompts::AutonomousNote(); messages_[0] = message("system", content); } diff --git a/taicpp/src/shell/runlive.cpp b/taicpp/src/shell/runlive.cpp index e0bf65d..3107888 100644 --- a/taicpp/src/shell/runlive.cpp +++ b/taicpp/src/shell/runlive.cpp @@ -1,5 +1,6 @@ #include "runlive.h" #include "ptyproc.h" +#include "../repl/interrupt.h" #include "../util/utf8.h" #include #include @@ -128,6 +129,12 @@ RunResult RunLive::Run(const Command& command, const RunLiveOptions& options) { try { double deadline = started + options.timeout; while (true) { + if (options.allow_interrupt && repl::Interrupt::requested()) { + proc->KillGroup(); + proc->Wait(-1); + reap(); + throw RunInterrupted(); + } double remaining = deadline - Now(); if (remaining <= 0) { timedOut = true; break; } std::optional item; @@ -216,6 +223,12 @@ RunResult RunLive::RunPty(const Command& command, const RunLiveOptions& options) }; try { while (true) { + if (options.allow_interrupt && repl::Interrupt::requested()) { + term.Kill(); // KillGroup: the pty child leads its own session + term.proc().Wait(-1); + finish(); + throw RunInterrupted(); + } double remaining = deadline - Now(); if (remaining <= 0) { timedOut = true; break; } PtyProc::ReadResult rd = term.Read(std::min(0.05, remaining)); diff --git a/taicpp/src/shell/runlive.h b/taicpp/src/shell/runlive.h index b3ac7e2..c4eb2a2 100644 --- a/taicpp/src/shell/runlive.h +++ b/taicpp/src/shell/runlive.h @@ -30,6 +30,10 @@ struct Command { // (returncode, output, elapsed seconds, timed_out) struct RunResult { int code{0}; std::string output; double elapsed{0}; bool timedOut{false}; }; +// Ctrl-C landed while the command ran: the child is already killed and reaped. +struct RunInterrupted : public std::runtime_error { + RunInterrupted() : std::runtime_error("interrupted") {} +}; struct RunLiveOptions { double timeout{kShellTimeout}; @@ -37,6 +41,7 @@ struct RunLiveOptions { const EnvMap* env{nullptr}; // null: inherit StreamSink* stream{nullptr}; AskFn ask; // empty: no pty, no questions + bool allow_interrupt{true}; // false: Ctrl-C is not ours (background workers) }; class RunLive { diff --git a/taicpp/src/shell/shellio.cpp b/taicpp/src/shell/shellio.cpp index 4db50f1..f8504d8 100644 --- a/taicpp/src/shell/shellio.cpp +++ b/taicpp/src/shell/shellio.cpp @@ -1,48 +1,24 @@ #include "shellio.h" #include "ansi.h" +#include "../repl/line_input.h" #include "../util/utf8.h" #include -#include -#include -#include #include namespace detz::shell { namespace { -// getpass.getpass(prompt): prompt on the tty, no echo, newline after +// getpass.getpass(prompt): prompt on the tty, no echo, newline after. +// Both go through the one shared fd-0 reader: a second buffered reader here +// would steal the REPL's typeahead and poison EOF state. std::optional GetPass(const std::string& prompt) { - int fd = ::open("/dev/tty", O_RDWR | O_NOCTTY); - bool own = fd >= 0; - if (fd < 0) fd = 0; - struct termios old, quiet; - bool changed = false; - if (::tcgetattr(fd, &old) == 0) { - quiet = old; - quiet.c_lflag &= ~(tcflag_t)ECHO; - changed = ::tcsetattr(fd, TCSAFLUSH, &quiet) == 0; - } - int out = own ? fd : 2; - (void)!::write(out, prompt.data(), prompt.size()); std::string line; - bool got = false, eof = false; - char c; - while (true) { - ssize_t n = ::read(fd, &c, 1); - if (n <= 0) { eof = true; break; } - got = true; - if (c == '\n') break; - line.push_back(c); - } - if (changed) ::tcsetattr(fd, TCSAFLUSH, &old); - (void)!::write(out, "\n", 1); - if (own) ::close(fd); - if (eof && !got) return std::nullopt; // EOFError + auto got = repl::LineInput::shared().read_secret(prompt, line); + if (got != repl::LineInput::Status::Line) return std::nullopt; // EOFError / KeyboardInterrupt return line; } std::optional Input(const std::string& prompt) { - std::fputs(prompt.c_str(), stdout); - std::fflush(stdout); std::string line; - if (!std::getline(std::cin, line)) return std::nullopt; // EOFError + auto got = repl::LineInput::shared().read(prompt, line); + if (got != repl::LineInput::Status::Line) return std::nullopt; // EOFError / KeyboardInterrupt return line; } } // namespace diff --git a/taicpp/src/store/store.h b/taicpp/src/store/store.h index 494103d..b4477c9 100644 --- a/taicpp/src/store/store.h +++ b/taicpp/src/store/store.h @@ -32,6 +32,8 @@ struct SettingRow { // item is the fts_docs "item" column: an integer for events, text for everything else (item_numeric) struct FtsHit { std::string item, kind, sub, title, snippet; double rank{0}; bool item_numeric{false}; }; struct EventRow { std::string ts, role, kind, text; }; +// one saved session file of Store::list_sessions: sid "" is the legacy unscoped file +struct SessionRow { std::string bot, sid, path, goal; double mtime{0}; int messages{0}; }; // Filters of Store::log_query; empty string = no filter. struct LogFilter { @@ -153,6 +155,7 @@ class Store { std::vector load_session(const std::string& name, const std::string& bot = "main", const std::string& ctx = ""); void save_session(const std::string& name, const std::vector& messages, const std::string& bot = "main", const std::string& ctx = ""); void prune_ctx_files(const std::string& prefix, const std::string& legacy, int cap = 0); + std::vector list_sessions(const std::string& profile, int limit = 50); std::string turn_state_path(const std::string& profile, const std::string& bot = "main", const std::string& ctx = "") const; Json load_turn_state(const std::string& profile, const std::string& bot = "main", const std::string& ctx = ""); void save_turn_state(const std::string& profile, const std::string& bot, const Json& state, const std::string& ctx = ""); diff --git a/taicpp/src/store/store_files.cpp b/taicpp/src/store/store_files.cpp index ec1770e..80f14e0 100644 --- a/taicpp/src/store/store_files.cpp +++ b/taicpp/src/store/store_files.cpp @@ -126,6 +126,59 @@ void Store::prune_ctx_files(const std::string& prefix, const std::string& legacy } } +std::vector Store::list_sessions(const std::string& profile, int limit) { + std::vector rows; + std::vector entries; + try { + for (auto& e : fs::directory_iterator(config_.profilesDir)) entries.push_back(e.path().filename().string()); + } catch (const std::exception&) { + return rows; + } + std::string main_prefix = profile + ".session.", legacy = profile + ".session.json", bot_prefix = profile + ".bot."; + for (auto& entry : entries) { + std::string bot, sid; + bool hit = false; + if (entry == legacy) { + bot = "main"; + hit = true; + } else if (entry.rfind(main_prefix, 0) == 0 && entry.size() >= 5 && entry.compare(entry.size() - 5, 5, ".json") == 0) { + bot = "main"; + sid = entry.substr(main_prefix.size(), entry.size() - main_prefix.size() - 5); + hit = true; + } else if (entry.rfind(bot_prefix, 0) == 0 && entry.size() >= 5 && entry.compare(entry.size() - 5, 5, ".json") == 0) { + std::string rest = entry.substr(bot_prefix.size(), entry.size() - bot_prefix.size() - 5); + size_t dot = rest.find(".session."); + if (dot != std::string::npos && dot > 0) { + bot = rest.substr(0, dot); + sid = rest.substr(dot + 9); + hit = !sid.empty(); + } else if (rest.size() > 8 && rest.compare(rest.size() - 8, 8, ".session") == 0 && rest.size() > 8) { + bot = rest.substr(0, rest.size() - 8); + hit = !bot.empty(); + } + } + if (!hit || (!sid.empty() && ShellContext::sanitize(sid) != sid)) continue; + std::string path = join_path(config_.profilesDir, entry); + struct stat st; + if (::stat(path.c_str(), &st) != 0) continue; + std::vector messages = load_session(profile, bot, sid); + Json turn = load_turn_state(profile, bot, sid); + const Json* g = turn.is_object() ? turn.find("goal") : nullptr; + SessionRow row; + row.bot = bot; + row.sid = sid; + row.path = path; + row.mtime = static_cast(st.st_mtim.tv_sec) + static_cast(st.st_mtim.tv_nsec) / 1e9; + row.messages = static_cast(messages.size()); + row.goal = (g && g->is_string()) ? Utf8::prefix(g->as_string(), 200) : ""; + rows.push_back(row); + } + std::stable_sort(rows.begin(), rows.end(), [](const SessionRow& a, const SessionRow& b) { return a.mtime > b.mtime; }); + int keep = std::max(1, std::min(limit, 50)); + if (static_cast(rows.size()) > keep) rows.resize(static_cast(keep)); + return rows; +} + std::string Store::turn_state_path(const std::string& prof, const std::string& bot, const std::string& ctx) const { std::string sid = sid_of(ctx); std::string stem = bot == "main" ? prof : prof + ".bot." + bot; diff --git a/taicpp/tests/agent_diff.cpp b/taicpp/tests/agent_diff.cpp index 8637cb5..74c459b 100644 --- a/taicpp/tests/agent_diff.cpp +++ b/taicpp/tests/agent_diff.cpp @@ -293,6 +293,7 @@ std::map registry() { }; r["orientation_block"] = [](const Value& a) { return S(agent::orientation_block(a.at(0).str(), a.at(1).as_bool())); }; r["capability_block"] = [](const Value& a) { return S(agent::capability_block(a.at(0).as_bool(), a.at(1).as_bool())); }; + r["sessions_block"] = [](const Value& a) { return S(agent::sessions_block(a.at(0).str(), a.at(1).str())); }; r["split_leading_tags"] = [](const Value& a) { auto [tags, rest] = agent::split_leading_tags(a.at(0).str()); Value o = Value::array(); diff --git a/taicpp/tests/agent_diff.py b/taicpp/tests/agent_diff.py index e09dff8..03175b4 100644 --- a/taicpp/tests/agent_diff.py +++ b/taicpp/tests/agent_diff.py @@ -186,6 +186,7 @@ EXPECT = { "utf8_replace": lambda text: text.encode("latin-1").decode("utf-8", "replace"), "orientation_block": lambda directory, sandbox: tai.orientation_block(directory or None, sandbox), "capability_block": lambda yolo, auto: tai.capability_block(yolo, auto), + "sessions_block": lambda home, profile: tai.sessions_block(home, profile), "split_leading_tags": lambda text: list(tai.split_leading_tags(text)), "expand_file_mentions": lambda text, directory: tai.expand_file_mentions(text, directory), "profile_ok": lambda s: bool(tai.PROFILE_RE.match(s)), @@ -448,6 +449,9 @@ def build_cases(rng, fuzz): add("orientation_block", "", True) add("orientation_block", HOME, False) add("orientation_block", "/nonexistent/dir/../x", False) + add("sessions_block", HOME, "default") + add("sessions_block", HOME + "/", "work") + add("sessions_block", "/nonexistent", "t") workdir = tempfile.mkdtemp(prefix="agent-diff-work-") with open(os.path.join(workdir, "a.txt"), "w") as handle: handle.write("x") diff --git a/taicpp/tests/agent_e2e.cpp b/taicpp/tests/agent_e2e.cpp index 19f12e8..bdd5308 100644 --- a/taicpp/tests/agent_e2e.cpp +++ b/taicpp/tests/agent_e2e.cpp @@ -92,7 +92,7 @@ class ScriptedTransport : public llm::HttpTransport { class PopenShell : public agent::ShellPort { public: agent::RunResult run_live_shell(const std::string& command, const std::string& cwd, const std::map& extra_env, - agent::LiveStreamPort*, const agent::AskFn&) override { + agent::LiveStreamPort*, const agent::AskFn&, bool = true) override { std::string env; for (const auto& kv : extra_env) { env += "export " + kv.first + "='" + kv.second + "'; "; } std::string full = "cd '" + cwd + "' && " + env + command + " 2>&1"; @@ -107,7 +107,7 @@ class PopenShell : public agent::ShellPort { r.output = py::utf8_replace(r.output); return r; } - agent::RunResult run_live_argv(const std::vector&, agent::LiveStreamPort*, const agent::AskFn&) override { + agent::RunResult run_live_argv(const std::vector&, agent::LiveStreamPort*, const agent::AskFn&, bool = true) override { throw agent::PortNotWired("shell argv"); } std::unique_ptr make_live_stream() override { return nullptr; } diff --git a/taicpp/tests/agent_memstore.h b/taicpp/tests/agent_memstore.h index e8375f9..9ed0b1c 100644 --- a/taicpp/tests/agent_memstore.h +++ b/taicpp/tests/agent_memstore.h @@ -49,9 +49,9 @@ class MemStore : public StorePort { events.push_back({scope(profile), role, kind, text}); } - Value load_session(const std::string& name, const std::string& bot, const std::string&) override { + Value load_session(const std::string& name, const std::string& bot, const std::string& ctx) override { Value out = Value::array(); - auto it = sessions_.find(name + "|" + bot); + auto it = sessions_.find(name + "|" + bot + "|" + ctx); if (it == sessions_.end()) { return out; } for (const auto& m : it->second.items()) { const Value& r = m.get("role"); @@ -59,12 +59,36 @@ class MemStore : public StorePort { } return out; } - void save_session(const std::string& name, const Value& messages, const std::string& bot, const std::string&) override { + void save_session(const std::string& name, const Value& messages, const std::string& bot, const std::string& ctx) override { Value kept = Value::array(); for (const auto& m : messages.items()) { if (!(m.get("role").is_string() && m.get("role").str() == "system")) { kept.push(m); } } - sessions_[name + "|" + bot] = Value::loads(kept.dumps()); + sessions_[name + "|" + bot + "|" + ctx] = Value::loads(kept.dumps()); + } + Value list_sessions(const std::string& profile, int64_t limit) override { + Value out = Value::array(); + std::string want = scope(profile); + std::vector keys; + for (const auto& kv : sessions_) { keys.push_back(kv.first); } + std::sort(keys.begin(), keys.end()); + int64_t keep = std::max(1, std::min(limit, 50)); + int64_t shown = 0; + for (const auto& key : keys) { + size_t a = key.find('|'); + size_t b = key.find('|', a + 1); + if (a == std::string::npos || b == std::string::npos || key.substr(0, a) != want) { continue; } + if (shown++ >= keep) { break; } + Value o = Value::object(); + o.set("bot", key.substr(a + 1, b - a - 1)); + o.set("sid", key.substr(b + 1)); + o.set("path", "(memstore)"); + o.set("mtime", 0.0); + o.set("messages", static_cast(sessions_.at(key).size())); + o.set("goal", ""); + out.push(o); + } + return out; } std::optional load_system(const std::string& name) override { auto it = systems_.find(name); diff --git a/taicpp/tests/misc_driver.cpp b/taicpp/tests/misc_driver.cpp index d99554d..6867989 100644 --- a/taicpp/tests/misc_driver.cpp +++ b/taicpp/tests/misc_driver.cpp @@ -1053,6 +1053,7 @@ class RagApp : public rg::RagHost { } return out_edges; } + std::string profiles_dir() const override { return tai_home() + "/profiles"; } std::string node_title(const std::string& node, const std::string& profile) override { std::size_t colon = node.find(':'); if (colon == std::string::npos) return node; diff --git a/taicpp/tests/repl_keys.py b/taicpp/tests/repl_keys.py new file mode 100644 index 0000000..d4b3e18 --- /dev/null +++ b/taicpp/tests/repl_keys.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Ctrl-key behavior of the detz REPL over a real pty (no model needed). + +Covers the shared-stdin fix: Ctrl-C once re-prompts, Ctrl-C during a secret +prompt aborts it and the next prompt still reads (no 0 ms phantom answer), +double Ctrl-C exits 0, Ctrl-D exits 0, Ctrl-Z suspends and SIGCONT resumes. + +usage: python3 -I tests/repl_keys.py build/detz +""" +import os +import pty +import re +import select +import signal +import subprocess +import sys +import tempfile +import time + +DET = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/detz") +CHECKS = 0 + + +def check(cond, label): + global CHECKS + CHECKS += 1 + if not cond: + raise AssertionError("FAILED: " + label) + print("ok %d: %s" % (CHECKS, label)) + + +class Proc: + def __init__(self, pid): + self.pid = pid + self.returncode = None + + def poll(self): + if self.returncode is not None: + return self.returncode + pid, status = os.waitpid(self.pid, os.WNOHANG) + if pid == 0: + return None + self.returncode = os.waitstatus_to_exitcode(status) + return self.returncode + + def wait(self, timeout=15): + end = time.time() + timeout + while time.time() < end: + code = self.poll() + if code is not None: + return code + time.sleep(0.05) + raise subprocess.TimeoutExpired(self.pid, timeout) + + def kill(self): + try: + os.kill(self.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def spawn(home): + pid, fd = pty.fork() + if pid == 0: + os.environ["TAI_HOME"] = home + os.environ["TERM"] = "xterm" + os.environ.pop("TMUX", None) + os.environ.pop("TMUX_PANE", None) + os.chdir(home) + os.execv(DET, [DET]) + os._exit(127) + return Proc(pid), fd + + +def spawn_keeplink(home): + """Spawn detz in its own foreground process group with a middle process + keeping the session: without it the group is orphaned and the kernel + discards SIGTSTP, so no Ctrl-Z test could pass.""" + pid, fd = pty.fork() + if pid == 0: + grandchild = os.fork() + if grandchild == 0: + os.setpgid(0, 0) + os.environ["TAI_HOME"] = home + os.environ["TERM"] = "xterm" + os.environ.pop("TMUX", None) + os.environ.pop("TMUX_PANE", None) + os.chdir(home) + os.execv(DET, [DET]) + os._exit(127) + os.tcsetpgrp(0, grandchild) + print("grandchild %d" % grandchild, flush=True) + while True: + try: + done = os.waitpid(grandchild, 0) + except ChildProcessError: + break + print("reaped %r" % (done,), flush=True) + break + os._exit(0) + return Proc(pid), fd + + +def proc_state(pid): + with open("/proc/%d/status" % pid) as handle: + for line in handle: + if line.startswith("State:"): + return line.split()[1] + return "?" + + +def read_until(fd, markers, timeout=30): + if isinstance(markers, str): + markers = [markers] + buf = b"" + end = time.time() + timeout + while time.time() < end: + ready, _, _ = select.select([fd], [], [], max(0, end - time.time())) + if not ready: + break + try: + chunk = os.read(fd, 65536) + except OSError: + break + if not chunk: + break + buf += chunk + text = buf.decode("utf-8", "replace") + for marker in markers: + if marker in text: + return text + return buf.decode("utf-8", "replace") + + +def drain(fd, seconds=0.5): + end = time.time() + seconds + while time.time() < end: + ready, _, _ = select.select([fd], [], [], max(0, end - time.time())) + if not ready: + break + try: + if not os.read(fd, 65536): + break + except OSError: + break + + +def wait_exit(proc, timeout=15): + try: + return proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + raise AssertionError("process did not exit") + + +def main(): + home = tempfile.mkdtemp(prefix="repl-keys-") + # 1: Ctrl-C once re-prompts, process stays alive + proc, fd = spawn(home) + out = read_until(fd, "type /help for commands") + check("type /help for commands" in out, "banner shown") + out = read_until(fd, "›") + check("›" in out, "prompt shown") + os.write(fd, b"\x03") + out = read_until(fd, "›") + check("›" in out and proc.poll() is None, "single Ctrl-C re-prompts, stays alive") + # 2: Ctrl-C aborts a secret prompt, next prompt still reads + os.write(fd, b"/secret set probe1\n") + out = read_until(fd, "value for 'probe1'") + check("value for 'probe1'" in out, "secret prompt shown") + os.write(fd, b"\x03") + out = read_until(fd, ["aborted", "›"]) + check("aborted" in out, "Ctrl-C aborts secret prompt") + read_until(fd, "›") + os.write(fd, b"/secret set probe1\n") + out = read_until(fd, "value for 'probe1'") + check("value for 'probe1'" in out, "secret prompt shown again") + time.sleep(0.3) + os.write(fd, b"s3cr3t-value\n") + out = read_until(fd, ["stored secret 'probe1'", "›"], timeout=15) + check("stored secret 'probe1'" in out, "answer after Ctrl-C is read (no phantom empty)") + read_until(fd, "›") + # 3: double Ctrl-C exits 0 with a saved session (two distinct keypresses: + # the tty discards a second ^C written in the same instant as the first) + os.write(fd, b"\x03") + read_until(fd, "›") + os.write(fd, b"\x03") + code = wait_exit(proc) + check(code == 0, "double Ctrl-C exits 0 (got %r)" % code) + os.close(fd) + # 4: Ctrl-D exits 0 + proc, fd = spawn(home) + read_until(fd, "›") + os.write(fd, b"\x04") + code = wait_exit(proc) + check(code == 0, "Ctrl-D exits 0 (got %r)" % code) + os.close(fd) + # 5: Ctrl-Z suspends, SIGCONT resumes, /quit exits + proc, fd = spawn_keeplink(home) + out = read_until(fd, "grandchild ") + grandchild = int(re.search(r"grandchild (\d+)", out).group(1)) + read_until(fd, "›") + os.write(fd, b"\x1a") + time.sleep(1.0) + check(proc_state(grandchild) == "T", "Ctrl-Z suspends the process") + os.kill(grandchild, signal.SIGCONT) + time.sleep(1.0) + check(proc_state(grandchild) in ("S", "R"), "SIGCONT resumes the process") + drain(fd) + os.write(fd, b"/quit\n") + code = wait_exit(proc) + check(code == 0, "/quit after resume exits 0 (got %r)" % code) + os.close(fd) + # vault state, not rendering + prof = os.path.join(home, "profiles") + sessions = [f for f in os.listdir(prof) if ".session." in f and f.endswith(".json")] + check(len(sessions) >= 1, "session file saved (%d found)" % len(sessions)) + print("%d checks passed" % CHECKS) + + +main() diff --git a/taicpp/tests/run_all.py b/taicpp/tests/run_all.py index 50aa3e2..6108a43 100644 --- a/taicpp/tests/run_all.py +++ b/taicpp/tests/run_all.py @@ -43,6 +43,7 @@ STEPS = [ ("foundation.unit", "foundation", [[b("foundation_test")]], None), ("foundation.parity", "foundation", [[PY, t("foundation_parity.py")]], None), ("foundation.repl_persist", "foundation", [[PY, t("repl_persist.py")]], None), + ("repl.keys", "repl", [[PY, "-I", t("repl_keys.py"), b("detz")]], None), ("foundation.tmux_ask", "foundation", [[PY, t("tmux_ask.py")]], None), ("store.smoke", "foundation", [[b("store_test")]], None), ("cli.unit", "cli", [[b("cli_test")]], None), diff --git a/taicpp/tests/shell_parity.py b/taicpp/tests/shell_parity.py index 2e0b4e6..6960f00 100644 --- a/taicpp/tests/shell_parity.py +++ b/taicpp/tests/shell_parity.py @@ -44,6 +44,9 @@ def build(): for name in sorted(os.listdir(os.path.join(ROOT, sub))): if name.endswith(".cpp") and name not in ("http.cpp",): srcs.append(os.path.join(ROOT, sub, name)) + # RunLive/ShellIo share the REPL's fd-0 reader and SIGINT flag + for name in ("interrupt.cpp", "line_input.cpp"): + srcs.append(os.path.join(ROOT, "src", "repl", name)) srcs.append(os.path.join(HERE, "shell_parity_driver.cpp")) cmd = ["g++", "-std=c++23", "-Wall", "-Wextra", "-O1", "-I" + os.path.join(ROOT, "src/util"), "-I" + os.path.join(ROOT, "src/shell"), "-pthread"] + srcs + ["-lcurl", "-o", DRIVER] done = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) diff --git a/taicpp/tests/store_test.cpp b/taicpp/tests/store_test.cpp index 6657b39..e485ca2 100644 --- a/taicpp/tests/store_test.cpp +++ b/taicpp/tests/store_test.cpp @@ -18,6 +18,15 @@ int main() { std::string view = Spill::spill_or_truncate(st.get(), "note", "big", big); if (view.find("chars spilled to mem:") == std::string::npos) { std::puts("spill failed"); return 1; } if (Spill::truncate("abc") != "abc") { std::puts("truncate failed"); return 1; } + if (!st->list_sessions("default").empty()) { std::puts("list_sessions should start empty"); return 1; } + Json m = Json::object(); + m.set("role", "user"); + m.set("content", "the lighthouse"); + st->save_session("default", {m}, "main", "20240101-000000-p11-abcdef01"); + auto sessions = st->list_sessions("default"); + if (sessions.size() != 1 || sessions[0].bot != "main" || sessions[0].sid != "20240101-000000-p11-abcdef01" || + sessions[0].messages != 1 || sessions[0].path.find(".session.20240101-000000-p11-abcdef01.json") == std::string::npos) { + std::puts("list_sessions failed"); return 1; } std::puts("store OK"); return 0; } diff --git a/test_tai.py b/test_tai.py index 472276d..009ebae 100644 --- a/test_tai.py +++ b/test_tai.py @@ -17231,5 +17231,87 @@ class LoggingDocTests(unittest.TestCase): self.assertNotIn(chr(0x2014), self.doc) +class SessionsTests(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, "sess-test-1")) + self.agent = tai.Agent(self.config, self.store, persist=True, quiet=True) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def test_block_states_vault_and_seal(self): + block = tai.sessions_block(self.config.home, "t") + self.assertIn("## Vault sessions", block) + self.assertIn(os.path.join(self.config.home, "profiles"), block) + self.assertIn("sealed", block) + self.assertIn("sessions", block) + self.assertIn("session_export", block) + self.assertLessEqual(len(block), 600) + + def test_apply_system_injects_block(self): + system = self.agent.messages[0]["content"] + self.assertIn("## Vault sessions", system) + self.assertIn(os.path.join(self.config.home, "profiles"), system) + + def test_list_sessions_empty(self): + self.assertEqual(self.store.list_sessions("t"), []) + + def test_list_sessions_finds_ctx_files(self): + self.store.save_session("t", [{"role": "user", "content": "hello"}], ctx="20240101-000000-p11-abcdef01") + rows = self.store.list_sessions("t") + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["bot"], "main") + self.assertEqual(row["sid"], "20240101-000000-p11-abcdef01") + self.assertTrue(row["path"].endswith(".session.20240101-000000-p11-abcdef01.json")) + self.assertEqual(row["messages"], 1) + + def test_run_sessions_lists(self): + out = self.agent.tools.dispatch("sessions", json.dumps({})) + self.assertIn("no saved sessions", out) + self.store.save_session("t", [{"role": "user", "content": "hello"}], ctx="20240101-000000-p11-abcdef01") + out = self.agent.tools.dispatch("sessions", json.dumps({})) + self.assertIn("20240101-000000-p11-abcdef01", out) + self.assertIn(".session.", out) + + def test_run_session_export_roundtrip(self): + self.store.save_session("t", [{"role": "user", "content": "the lighthouse"}, {"role": "assistant", "content": "seen"}], + ctx="20240101-000000-p11-abcdef01") + out = self.agent.tools.dispatch("session_export", json.dumps({"sid": "20240101-000000-p11-abcdef01"})) + self.assertIn("the lighthouse", out) + self.assertIn("seen", out) + missing = self.agent.tools.dispatch("session_export", json.dumps({"sid": "nope"})) + self.assertIn("error", missing) + + def test_recall_footer_grounds_sessions(self): + out = self.agent.tools.dispatch("recall", json.dumps({"query": "nothing here xyzzy"})) + self.assertIn("profiles", out) + self.assertIn("sessions", out) + + def test_write_refuses_new_root_markdown(self): + with tempfile.TemporaryDirectory() as work: + saved = os.getcwd() + os.chdir(work) + try: + out = self.agent.tools.dispatch("write_file", json.dumps({"path": "dump.md", "content": "x"})) + self.assertIn("error", out) + self.assertIn("record_save", out) + self.assertFalse(os.path.exists("dump.md")) + ok = self.agent.tools.dispatch("write_file", json.dumps({"path": "docs/dump.md", "content": "x"})) + self.assertIn("wrote", ok) + finally: + os.chdir(saved) + + if __name__ == "__main__": unittest.main()