sessions: vault discovery tools, shared stdin, ctrl-key fixes

New sessions/session_export tools plus a sessions_block vault map in the system prompt (tai.py, ported to detz); recall/search carry a session footer instead of letting the model grep sealed bytes. write_file refuses new markdown at working/repo roots, and the blanket *.md gitignore is gone so docs stay visible. One LineInput owns fd 0 for every prompt (ask, approvals, shell_ask, secrets), ending phantom-empty answers; Ctrl-C re-prompts (double exits), Ctrl-D exits, Ctrl-Z suspends; mid-turn Ctrl-C announces itself and kills foreground shell children via RunInterrupted. Covered by SessionsTests, repl_keys.py pty checks, agent_diff sessions_block cases.
This commit is contained in:
2026-10-28 13:11:45 +01:00
parent e5a6e05ab5
commit 4eff597be7
50 changed files with 1026 additions and 250 deletions
+5 -8
View File
@@ -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
+145 -3
View File
@@ -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 "
"<profile>[.bot.<bot>].session.<sid>.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:<hash> 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}
+3
View File
@@ -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.
+127 -122
View File
@@ -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 |
+2 -2
View File
@@ -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).
+9 -1
View File
@@ -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 |
+4 -3
View File
@@ -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
+4 -1
View File
@@ -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())
+1 -1
View File
@@ -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.
+4 -3
View File
@@ -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.
+16 -6
View File
@@ -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 " +
"<profile>[.bot.<bot>].session.<sid>.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::vector<std::string>, std::string> split_leading_tags(const std::string& text) {
std::vector<std::string> 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);
+1
View File
@@ -40,6 +40,7 @@ std::pair<json::Value, bool> 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::vector<std::string>, std::string> split_leading_tags(const std::string& text);
std::string expand_file_mentions(const std::string& text, const std::string& directory);
std::set<std::string> known_model_ids(const AgentConfig& config, const llm::ModelHealth* health);
+4
View File
@@ -56,6 +56,8 @@ const std::vector<std::string>& 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:<hash> 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<std::pair<std::string, std::vector<std::string>>>& 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"}},
+5 -2
View File
@@ -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<std::string>& provider,
const std::optional<std::string>& model, const std::optional<std::string>& bot,
const std::vector<std::string>& 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<std::string, std::string>& 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<std::string>& argv, LiveStreamPort* stream, const AskFn& ask) = 0;
virtual RunResult run_live_argv(const std::vector<std::string>& argv, LiveStreamPort* stream, const AskFn& ask,
bool foreground = true) = 0;
virtual std::unique_ptr<LiveStreamPort> make_live_stream() = 0; // LiveStream()
virtual std::vector<std::string> shell_target_paths(const std::string& command, const std::string& workdir) = 0;
// Sandbox box. ensure_box throws llm::BackendError.
+3
View File
@@ -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);
+26 -20
View File
@@ -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 <algorithm>
#include <filesystem>
#include <iostream>
#include <termios.h>
#include <unistd.h>
namespace fs = std::filesystem;
namespace detz::agent {
@@ -111,6 +112,8 @@ const std::map<std::string, Tools::Handler>& 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> {
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<tcflag_t>(ECHO);
tcsetattr(0, TCSAFLUSH, &t);
hid = true;
}
std::string line;
bool got = static_cast<bool>(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<std::string, std::string> extra;
for (const auto& kv : vault) { extra[secret_env_name(kv.first)] = kv.second; }
std::unique_ptr<LiveStreamPort> 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<std::string, std::string> 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<std::string, std::string> key{"home", resolved};
if (isfile(path) && !read_files.count(key)) {
return "error: write denied for '" + path + "', read it first with read_file";
}
+97 -2
View File
@@ -7,6 +7,8 @@
#include "text.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <regex>
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<std::string> 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<std::string> 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<int64_t> 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<int64_t>(1, std::min<int64_t>(*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<std::string> 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<std::time_t>(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<int>(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<int64_t>(messages.size())) {
return "error: offset " + std::to_string(offset) + " past " + std::to_string(messages.size()) + " messages";
}
std::vector<std::string> 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<int64_t>(offset + limit, static_cast<int64_t>(rendered.size()));
std::string body = py::join(std::vector<std::string>(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);
+3 -3
View File
@@ -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";
}
}
+10
View File
@@ -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 " +
"<profile>[.bot.<bot>].session.<sid>.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
+2
View File
@@ -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
+9
View File
@@ -1,8 +1,10 @@
#include "agent_repl_host.h"
#include <chrono>
#include <cstdio>
#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;
+3 -17
View File
@@ -1,5 +1,5 @@
#include "repl_commands.h"
#include <termios.h>
#include "../repl/line_input.h"
#include <unistd.h>
#include <algorithm>
#include <chrono>
@@ -74,23 +74,9 @@ std::optional<long long> to_int(const std::string& s) {
}
}
std::optional<std::string> 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<tcflag_t>(ECHO);
tcsetattr(0, TCSAFLUSH, &t);
hid = true;
}
std::string line;
bool ok = static_cast<bool>(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
+5 -2
View File
@@ -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<std::string, std::string>& 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<std::string>& argv, agent::LiveStreamPort* stream, const agent::AskFn& ask) {
agent::RunResult ShellAdapter::run_live_argv(const std::vector<std::string>& 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));
}
+3 -2
View File
@@ -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<std::string, std::string>& extra_env,
agent::LiveStreamPort* stream, const agent::AskFn& ask) override;
agent::RunResult run_live_argv(const std::vector<std::string>& 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<std::string>& argv, agent::LiveStreamPort* stream, const agent::AskFn& ask,
bool foreground = true) override;
std::unique_ptr<agent::LiveStreamPort> make_live_stream() override;
std::vector<std::string> shell_target_paths(const std::string& command, const std::string& workdir) override;
std::string ensure_box() override;
+14
View File
@@ -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<int>(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<int64_t>(row.messages));
o.set("goal", row.goal);
out.push(o);
}
return out;
}
void StoreAdapter::save_dir_defaults(const std::string& directory, const std::optional<std::string>& provider,
const std::optional<std::string>& model, const std::optional<std::string>& bot,
const std::vector<std::string>& clear) {
+1
View File
@@ -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<std::string>& provider, const std::optional<std::string>& model,
const std::optional<std::string>& bot, const std::vector<std::string>& clear) override;
+4 -1
View File
@@ -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<int64_t> 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
+1
View File
@@ -22,6 +22,7 @@ class RagHost {
virtual std::map<int64_t, ChunkRow> get_chunks_by_ids(const std::vector<int64_t>& ids, const std::string& profile) = 0;
virtual std::vector<GraphEdge> 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; // <home>/profiles, for the session footer
};
class RagTool {
+10 -5
View File
@@ -4,9 +4,9 @@
namespace detz::repl {
namespace {
std::atomic<bool> g_flag{false};
std::atomic<int> g_count{0}; // a counter, not a bool: two rapid Ctrl-C must both be seen
std::atomic<bool> 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
+3 -1
View File
@@ -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
+42 -2
View File
@@ -1,10 +1,41 @@
#include "line_input.h"
#include <termios.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#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<tcflag_t>(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
+10
View File
@@ -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_;
+13 -3
View File
@@ -1,4 +1,5 @@
#include "repl.h"
#include <chrono>
#include <cstdio>
#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);
+1 -2
View File
@@ -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
+1
View File
@@ -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);
}
+13
View File
@@ -1,5 +1,6 @@
#include "runlive.h"
#include "ptyproc.h"
#include "../repl/interrupt.h"
#include "../util/utf8.h"
#include <algorithm>
#include <condition_variable>
@@ -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<std::string> 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));
+5
View File
@@ -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 {
+8 -32
View File
@@ -1,48 +1,24 @@
#include "shellio.h"
#include "ansi.h"
#include "../repl/line_input.h"
#include "../util/utf8.h"
#include <cstdio>
#include <fcntl.h>
#include <iostream>
#include <termios.h>
#include <unistd.h>
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<std::string> 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<std::string> 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
+3
View File
@@ -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<Json> 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<Json>& 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<SessionRow> 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 = "");
+53
View File
@@ -126,6 +126,59 @@ void Store::prune_ctx_files(const std::string& prefix, const std::string& legacy
}
}
std::vector<SessionRow> Store::list_sessions(const std::string& profile, int limit) {
std::vector<SessionRow> rows;
std::vector<std::string> 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<Json> 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<double>(st.st_mtim.tv_sec) + static_cast<double>(st.st_mtim.tv_nsec) / 1e9;
row.messages = static_cast<int>(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<int>(rows.size()) > keep) rows.resize(static_cast<size_t>(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;
+1
View File
@@ -293,6 +293,7 @@ std::map<std::string, Fn> 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();
+4
View File
@@ -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")
+2 -2
View File
@@ -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<std::string, std::string>& 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<std::string>&, agent::LiveStreamPort*, const agent::AskFn&) override {
agent::RunResult run_live_argv(const std::vector<std::string>&, agent::LiveStreamPort*, const agent::AskFn&, bool = true) override {
throw agent::PortNotWired("shell argv");
}
std::unique_ptr<agent::LiveStreamPort> make_live_stream() override { return nullptr; }
+28 -4
View File
@@ -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<std::string> keys;
for (const auto& kv : sessions_) { keys.push_back(kv.first); }
std::sort(keys.begin(), keys.end());
int64_t keep = std::max<int64_t>(1, std::min<int64_t>(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<int>(sessions_.at(key).size()));
o.set("goal", "");
out.push(o);
}
return out;
}
std::optional<std::string> load_system(const std::string& name) override {
auto it = systems_.find(name);
+1
View File
@@ -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;
+222
View File
@@ -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()
+1
View File
@@ -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),
+3
View File
@@ -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)
+9
View File
@@ -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;
}
+82
View File
@@ -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()