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:
+5
-8
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user