From f15d253e640ace1b52e942cd38e63691a2d3a1f0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 24 Jun 2026 14:53:18 +0200 Subject: [PATCH] feat(shell): add interactive cya shell session (CYA-WP-0007) Implement the full interactive shell REPL with session persistence, opt-in capped/redacted shell history, State Hub orientation and explicit slash-command writes, orchestrator/safety wiring, end-session learning hooks, weakness hints, docs, and tests. --- AGENTS.md | 21 + README.md | 54 ++ SCOPE.md | 17 +- docs/cya-config.example.toml | 9 +- docs/cya-interactive-shell-session-design.md | 172 ++++ docs/cya-shell-operator-session.md | 43 + src/cya/cli/main.py | 184 +++- src/cya/config.py | 89 ++ src/cya/memory/__init__.py | 2 + src/cya/orchestrator.py | 101 +- src/cya/shell_session.py | 862 ++++++++++++++++++ tests/conftest.py | 36 + tests/test_shell_session.py | 213 +++++ .../CYA-WP-0007-interactive-shell-session.md | 47 +- 14 files changed, 1819 insertions(+), 31 deletions(-) create mode 100644 docs/cya-interactive-shell-session-design.md create mode 100644 docs/cya-shell-operator-session.md create mode 100644 src/cya/shell_session.py create mode 100644 tests/test_shell_session.py diff --git a/AGENTS.md b/AGENTS.md index 457e702..93f0700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,3 +217,24 @@ To create a new workplan: 1. Write the file following the format above 2. Notify the custodian operator to run `make fix-consistency REPO=can-you-assist` (or send a message to the hub agent via `POST /messages/`) +--- + +## `cya shell` Command Reference + +Interactive shell sessions are an owned repo capability. Use them for manual +operator assistance, not autonomous command execution. + +```bash +cya shell # start a stateful shell session +cya shell --offline --no-hub # local smoke without hub HTTP calls +cya shell --with-history # opt in to capped, redacted history context +``` + +Session artifacts live at `~/.config/cya/sessions/.jsonl`. +Shell history is off by default and remains capped/redacted when enabled. + +Slash commands: `/help`, `/explain`, `/hub`, `/hub log "summary"`, `/inbox`, +`/inbox read `, `/export-session`, `/learn`, `/exit`. + +State Hub writes are never automatic. `/hub log` requires interactive +confirmation; `/inbox read ` is the explicit mark-read action. diff --git a/README.md b/README.md index 0a9b359..7d5195c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,60 @@ cya --explain-context "explain the changes in the last commit" The output includes a structured suggestion, rationale, and (when relevant) a clear preview + confirmation prompt. Nothing executes without your explicit yes. +## Interactive Shell Sessions + +Start a stateful console session when you want continuity across turns: + +```bash +cya shell +cya shell --offline --no-hub # local smoke / air-gapped mode +cya shell --with-history # opt in to capped, redacted shell history +``` + +Each session writes a user-owned JSONL artifact under: + +```text +~/.config/cya/sessions/.jsonl +``` + +Useful slash commands: + +```text +/help show shell commands +/explain show last turn context, risk, history, hub, hints +/hub show State Hub workstreams and inbox orientation +/hub log "summary" post a progress note after confirmation +/inbox show unread State Hub messages +/export-session write a redacted JSON session summary +/learn capture Profile 1 reflections now +/exit close the session +``` + +Shell history is off by default. `--with-history` or `[shell_history]` config +includes at most 50 recent lines from `$HISTFILE` or common shell history files, +redacts secret-like values, and exposes provenance in `/explain`. One-shot +`cya "..."` requests remain history-free by default. + +Example transcript: + +```text +$ cya shell --offline --no-hub +cya> summarize recent git changes +... standard orchestrator response ... +cya> /explain +... context envelope, memory, shell history status, hub summary, and hints ... +cya> /export-session +Exported session summary: ~/.config/cya/sessions/cya-...-summary.json +cya> /exit +Session saved: ~/.config/cya/sessions/cya-...jsonl +``` + +State Hub writes never happen automatically. `/hub log` and `/inbox read` are +explicit operator actions; `/hub log` also asks for confirmation. + +See `docs/cya-interactive-shell-session-design.md` and +`docs/cya-shell-operator-session.md` for the full design and operator example. + ## Safety (core product behavior) - Genuine rule-based assessment is the primary mechanism. diff --git a/SCOPE.md b/SCOPE.md index 213d2d3..f25f5dc 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -6,7 +6,7 @@ It allows users to express intent in natural language from the terminal and receive safe, explainable, context-aware assistance while keeping memory, history, preferences, and adaptation under explicit user control. -## Current Status (Post CYA-WP-0004 Packaging & Distribution Slice) +## Current Status (Post CYA-WP-0007 Interactive Shell Session Slice) Four implementation slices have been delivered: @@ -16,6 +16,7 @@ Four implementation slices have been delivered: - **Profile 0 baseline (post-0003, formalized in CYA-WP-0005 T02)**: The current shipped memory implementation (local JSON + kinds + activation_context + provenance + retrospection helper) is now explicitly documented as **Profile 0** — the stable, high-quality foundation for future self-improving profiles 1–3. See MemoryVision.md for the full baseline description. - **CYA-WP-0005 (Agentic Memory Profiles + first self-improvement capability)**: Complete profile model (Profile 0 baseline + detailed definitions + integration plans + Capability Matrix for Profiles 1–3) plus initial **Profile 1** delivery. **CYA-WP-0006** hardened Profile 1 to production quality: guided 1–3 lesson capture with preview in `cya retrospect`, `cya memory reflections`, near-duplicate compaction, and surfacing in responses / `--explain-context`. See MemoryVision.md and `docs/CYA-WP-0006-profile-1-gap-checklist.md`. - **CYA-WP-0004 (Dev-Head Install & Release Packaging)**: Reliable installation from development head (`make dev-install`, direct `git+` installs), dynamic versioning via `setuptools_scm`, clean distribution package building (`python -m build` + verification), lightweight release process, and supporting documentation/Makefile. +- **CYA-WP-0007 (Interactive Shell Session)**: `cya shell` REPL, session JSONL artifacts, optional capped/redacted shell history, read-only State Hub orientation, explicit hub slash-command writes, end-session learning/export, and deterministic weakness hints. Core capabilities now include: - Natural language request handling via clean Typer CLI. @@ -26,7 +27,8 @@ Core capabilities now include: - Automatic memory activation based on working directory/git root. - `cya retrospect` for structured reflection and goal setting, with production Profile 1 verbal lesson capture, review (`cya memory reflections`), and compaction. - Full developer workflow: dev-head install, testing, building distribution packages, and a documented release process. -- Transparent, inspectable behavior via `--explain-context`. +- Transparent, inspectable behavior via `--explain-context` and shell `/explain`. +- Stateful `cya shell` sessions with local JSONL artifacts, slash commands, optional history context, hub orientation, export, and opt-in learning. All LLM interaction flows through the documented adapter seam. Memory flows through explicit ports. Packaging and distribution are now first-class concerns with a clear path forward. No production path bypasses the defined boundaries. @@ -39,7 +41,8 @@ All LLM interaction flows through the documented adapter seam. Memory flows thro - Orchestration of the request → context → safety → LLM adapter → response pipeline. - The stable `LLMAdapter` Protocol and the contract for how `cya` talks to LLM backends. - Explicit, now real (persisting) integration with user-controlled memory via `phase-memory` ports. -- Transparent, inspectable behavior (especially via `--explain-context`). +- Transparent, inspectable behavior (especially via `--explain-context` and shell `/explain`). +- Interactive `cya shell` sessions: prompt loop, session state, local artifacts, slash commands, optional shell-history context, hub orientation, export, and end-session learning prompts. - User-facing documentation, examples, and safety guarantees for the CLI tool. ## Does Not Own @@ -51,7 +54,7 @@ All LLM interaction flows through the documented adapter seam. Memory flows thro - Deep repository indexing, embeddings, or large-scale content analysis (explicit non-goal of the first slice). - Voice, speech, phone-bridge, or non-terminal interfaces (future work). - Production PyPI publishing and automated release CI (documented process and local tooling exist; actual publication is future work). -- Long-lived conversational REPL or session state (one-shot + very lightweight session only). +- Hosted, team-shared, or background session state; `cya shell` artifacts remain local and user-owned. ## Integrates With @@ -80,7 +83,7 @@ See the individual workplans for detailed scope per slice. - Deep llm-connect features beyond basic `execute_prompt` delegation (adaptive routing, cost dashboards, structured output schemas). - Deep semantic repository understanding or large-scale content analysis. - Automatic command execution (even "safe" suggestions) — explicit user confirmation remains mandatory for anything non-safe. -- Rich multi-turn conversational state beyond lightweight scoped memory + retrospection. +- Hosted/team-shared shell sessions or autonomous background agents. - Cost tracking, token budgeting, or usage dashboards. - Team/shared memory or collaboration features. - Plugin system or domain-specific extensions. @@ -93,6 +96,7 @@ See the individual workplans for detailed scope per slice. - `cya/safety/risk.py` — the `_RULES` table and `classify()` function (memory-aware). - `cya/context/collector.py` — collection functions and ignore policy. - `cya/orchestrator.py` — the main coordination entry point. +- `cya/shell_session.py` — interactive shell runtime, slash commands, session artifacts, optional history, hub orientation, and weakness hints. - Packaging & distribution: `Makefile`, `pyproject.toml`, `docs/release-process.md`, and `MANIFEST.in` (first-class concern with registered future work). ## Success Criteria (Current State) @@ -101,6 +105,7 @@ A new user or contributor can: - Install the latest development code easily (`make dev-install` or direct git+) and use `cya` for realistic tasks after reading the README. - Understand exactly what context and memory influenced a response via `--explain-context`. - Trust that dangerous actions will never execute without explicit confirmation. +- Start `cya shell` for multi-turn help, inspect `/explain`, export a redacted session, and opt in explicitly before shell history or session-turn memory is used. - Use `cya retrospect` to reflect on usage and set goals that influence future behavior. - Build and verify distribution packages locally. - See a clear path for how real `llm-connect`, deeper `phase-memory`, and future PyPI releases will integrate. @@ -109,7 +114,7 @@ Sibling project owners (llm-connect, phase-memory, State Hub) can read the workp --- -**This SCOPE document reflects the state after CYA-WP-0008 (llm-connect Adapter Integration).** +**This SCOPE document reflects the state after CYA-WP-0007 (Interactive Shell Session) and CYA-WP-0008 (llm-connect Adapter Integration).** It remains intentionally narrower than the long-term vision in INTENT.md and MemoryVision.md, but now incorporates significant advances in contextual memory activation, user-driven retrospection/optimization loops, and proper packaging & distribution capabilities. diff --git a/docs/cya-config.example.toml b/docs/cya-config.example.toml index dffe0f6..7f89047 100644 --- a/docs/cya-config.example.toml +++ b/docs/cya-config.example.toml @@ -6,4 +6,11 @@ backend = "openrouter" model = "anthropic/claude-sonnet-4" temperature = 0.3 max_tokens = 2000 -api_key_env = "OPENROUTER_API_KEY" \ No newline at end of file +api_key_env = "OPENROUTER_API_KEY" + +# Optional interactive shell history context. Off by default. +# Values are capped/redacted before they enter session context. +[shell_history] +enabled = false +limit = 50 +# histfile = "~/.bash_history" diff --git a/docs/cya-interactive-shell-session-design.md b/docs/cya-interactive-shell-session-design.md new file mode 100644 index 0000000..20ea71e --- /dev/null +++ b/docs/cya-interactive-shell-session-design.md @@ -0,0 +1,172 @@ +# cya Interactive Shell Session Design + +Status: implemented for CYA-WP-0007 +Date: 2026-06-23 + +## Scope Alignment + +This design moves `cya shell` from the old deferred REPL note in SCOPE.md into +owned product scope. It preserves the core INTENT principle: `cya` helps from +inside the console while the user keeps control of context, history, memory, +backend choice, and safety decisions. + +Reviewed against: + +- INTENT.md: console-native, user-controlled, explainable assistance. +- SCOPE.md: `cya` owns UX, orchestration, safety, context, and documentation; + State Hub remains non-runtime coordination. +- MemoryVision.md: session turns are an episodic precursor with explicit + `kind: session_turn`, user-triggered export, and opt-in persistence to memory. +- AGENTS.md / State Hub boundary: hub reads are allowed for orientation; + hub writes require explicit operator confirmation. + +## REPL UX + +Command: + +```bash +cya shell +cya shell --offline --no-hub +cya shell --with-history +``` + +Prompt: `cya> ` + +The shell is intentionally still a helper alongside the user's shell. It never +auto-executes commands. Natural-language turns are passed through the existing +orchestrator path: context collection, memory recall, risk classification, +mandatory confirmation for non-safe levels, adapter call, and rendering. + +Slash commands: + +- `/help` - show the shell command list. +- `/exit` or `/quit` - close the shell session. +- `/explain` - show the last turn's risk, context envelope, shell history + provenance, hub summary, and weakness hints. +- `/hub` - show loaded active workstreams, open tasks, inbox, and hub errors. +- `/hub log "summary"` - post a State Hub progress note only after interactive + confirmation. +- `/inbox` - show unread messages. +- `/inbox read ` - explicitly mark a message read. +- `/export-session` - write a redacted JSON session summary. +- `/learn` - capture Profile 1 reflections immediately. + +EOF exits cleanly. Ctrl-C interrupts the current prompt and keeps the session +alive. Non-TTY input is accepted for smoke/scripted use; end-session learning +prompts are skipped unless the session is interactive. + +## Session File Layout + +Session artifacts are user-owned and local: + +```text +~/.config/cya/sessions/.jsonl +~/.config/cya/sessions/-summary.json +``` + +Session ids use `cya-YYYYMMDD-HHMMSS-`. + +JSONL event kinds: + +- `session_start`: session metadata, cwd, loaded history context, compact hub + orientation. +- `session_turn`: one redacted turn with user text, assistant text, risk, + cancellation/dry-run flags, weakness hints, and redaction count. +- `session_export`: emitted when `/export-session` writes a summary file. +- `session_end`: final turn count and timestamp. + +`session_turn` is also registered as a memory kind. End-of-session memory writes +are opt-in and store only redacted turn summaries through the existing memory +port, not a new private store. + +## Shell History Model + +History is off by default. + +Opt-in paths: + +```bash +cya shell --with-history +``` + +or: + +```toml +[shell_history] +enabled = true +limit = 50 +histfile = "~/.bash_history" +``` + +Rules: + +- Default hard cap: 50 lines. +- Source: `$HISTFILE`, then `.bash_history`, `.zsh_history`, or fish history. +- Per-line provenance: source path, line number, and `shell_history.histfile`. +- Redaction before session context or persistence: API keys, tokens, passwords, + common provider key forms, and secret-like env assignments. +- `/explain` shows whether history was enabled, how many lines were included, + redaction count, and provenance. +- One-shot `cya "..."` remains history-free by default; no collector invariant + changes are required for one-shot mode. + +## State Hub Boundary + +Session start reads: + +- `.custodian-brief.md` if present. +- `GET /workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active`. +- `GET /messages/?to_agent=can-you-assist&unread_only=true`. + +Remote hub failures are non-fatal and are recorded in the session context. + +Writes are explicit slash commands only: + +- `/hub log "summary"` posts a progress note after confirmation. +- `/inbox read ` marks one message read by explicit command. + +No automatic State Hub writes occur on shell start, normal turns, export, or +session close. + +## Weakness Hints v1 + +Hints are deterministic informational panels. They never change the risk level, +never skip confirmation, and never execute fixes. + +Rules: + +- `credential-routing`: secret-like topics without `warden route` guidance. +- `ops-warden-secret-anti-pattern`: requests that imply ops-warden should vend + API keys or passwords. +- `remote-exec-review`: `curl`/`wget` piped to interpreters. +- `repeated-destructive-intent`: repeated destructive or mass-edit turns. +- `state-hub-alignment`: requested workplan id not visible in loaded hub + summary, reminding the user that local workplan files are source of truth. + +## End-of-Session Learning + +On interactive close, the shell offers: + +1. Profile 1 lesson capture using the same reflection helpers as + `cya retrospect`. +2. Optional redacted `session_turn` memory persistence. + +Skipping either path writes no reflection or session-turn memory records. +`/export-session` remains separate and always writes only a redacted JSON +summary under the sessions directory. + +## Gate Checklist + +- T02 REPL skeleton: `cya shell` command, prompt loop, JSONL session file, + `/help`, `/exit`, `/explain`, EOF/Ctrl-C handling. +- T03 history: off by default, `--with-history`, config support, caps, + redaction, provenance, tests. +- T04 hub: brief + active workstreams + inbox, graceful offline behavior, + `/hub`, `/hub log`, `/inbox`, explicit mark-read. +- T05 orchestrator/safety: every turn uses `handle_request`; non-safe levels + still require confirmation; `/explain` shows the full session context. +- T06 learning/export: Profile 1 capture, `/export-session`, opt-in + `session_turn` memory persistence. +- T07 hints: at least three deterministic rules with tests; advisory only. +- T08 docs/tests: README, AGENTS command reference, operator example, and + pytest coverage. diff --git a/docs/cya-shell-operator-session.md b/docs/cya-shell-operator-session.md new file mode 100644 index 0000000..7dc4ee9 --- /dev/null +++ b/docs/cya-shell-operator-session.md @@ -0,0 +1,43 @@ +# Example Operator Session + +This example uses offline mode and skips remote hub calls for a local smoke. +In normal repo work, omit `--no-hub` so the shell reads State Hub orientation. + +```text +$ cya shell --offline --no-hub +cya shell +Session: cya-20260623-101500-1a2b3c4d +Artifact: ~/.config/cya/sessions/cya-20260623-101500-1a2b3c4d.jsonl + +State Hub remote orientation not available. +History lines: 0 + +Type /help for commands, /exit to finish. +cya> summarize what changed in this repo +... standard orchestrator response ... +cya> /explain +... context envelope, memory, shell history status, hub summary, and hints ... +cya> /hub +... active workstreams or offline notes ... +cya> /export-session +Exported session summary: ~/.config/cya/sessions/cya-...-summary.json +cya> /exit +Session saved: ~/.config/cya/sessions/cya-...jsonl +``` + +For history-aware continuity, opt in explicitly: + +```bash +cya shell --with-history +``` + +The history context is capped, redacted, and shown in `/explain`. Secret values +should still be routed through the approved custody path, not pasted into the +session. + +State Hub writes require a slash command and interactive confirmation: + +```text +cya> /hub log "Implemented CYA-WP-0007 shell skeleton and tests" +Post progress note to http://127.0.0.1:8000? [y/N]: y +``` diff --git a/src/cya/cli/main.py b/src/cya/cli/main.py index 92f379e..801ae17 100644 --- a/src/cya/cli/main.py +++ b/src/cya/cli/main.py @@ -115,6 +115,52 @@ def main( ) +@app.command("shell") +def shell_command( + with_history: bool = typer.Option( + False, + "--with-history", + help="Opt in to capped, redacted shell-history context for this session.", + ), + history_limit: int | None = typer.Option( + None, + "--history-limit", + help="Maximum history lines to include, capped at the configured safe limit.", + ), + offline: bool = typer.Option( + False, + "--offline", + help="Use the deterministic FakeLLMAdapter for this shell session.", + ), + hub_url: str = typer.Option( + "http://127.0.0.1:8000", + "--hub-url", + help="State Hub base URL for read-only session orientation.", + ), + no_hub: bool = typer.Option( + False, + "--no-hub", + help="Skip State Hub HTTP orientation; still reads .custodian-brief.md if present.", + ), + offer_session_lessons: bool = typer.Option( + True, + "--session-lessons/--no-session-lessons", + help="Offer end-of-session Profile 1 reflection and session_turn memory capture.", + ), +) -> None: + """Start an interactive, stateful cya shell session.""" + from cya.shell_session import run_shell + + run_shell( + with_history=with_history, + history_limit=history_limit, + offline=offline, + hub_url=hub_url, + no_hub=no_hub, + offer_end_learning=offer_session_lessons, + ) + + memory_app = typer.Typer( help="Inspect and manage user-controlled memory (Profile 0 + Profile 1).", rich_markup_mode="rich", @@ -195,8 +241,136 @@ def retrospect( run_retrospection(scope=scope, limit=limit) -if __name__ == "__main__": - app() +def _dispatch_shell_argv(argv: list[str] | None = None) -> bool: + """Handle `cya shell ...` before Typer's root REQUEST argument sees it.""" + import argparse + + argv = list(sys.argv if argv is None else argv) + if len(argv) < 2 or argv[1] != "shell": + return False + + parser = argparse.ArgumentParser( + prog=f"{argv[0]} shell", + description="Start an interactive, stateful cya shell session.", + ) + parser.add_argument( + "--with-history", + action="store_true", + help="Opt in to capped, redacted shell-history context for this session.", + ) + parser.add_argument( + "--history-limit", + type=int, + default=None, + help="Maximum history lines to include, capped at the configured safe limit.", + ) + parser.add_argument( + "--offline", + action="store_true", + help="Use the deterministic FakeLLMAdapter for this shell session.", + ) + parser.add_argument( + "--hub-url", + default="http://127.0.0.1:8000", + help="State Hub base URL for read-only session orientation.", + ) + parser.add_argument( + "--no-hub", + action="store_true", + help="Skip State Hub HTTP orientation; still reads .custodian-brief.md if present.", + ) + parser.add_argument( + "--session-lessons", + dest="offer_session_lessons", + action="store_true", + default=True, + help="Offer end-of-session Profile 1 reflection and session_turn memory capture.", + ) + parser.add_argument( + "--no-session-lessons", + dest="offer_session_lessons", + action="store_false", + help="Skip end-of-session learning prompts.", + ) + args = parser.parse_args(argv[2:]) + + from cya.shell_session import run_shell + + run_shell( + with_history=args.with_history, + history_limit=args.history_limit, + offline=args.offline, + hub_url=args.hub_url, + no_hub=args.no_hub, + offer_end_learning=args.offer_session_lessons, + ) + return True + + +def _dispatch_memory_argv(argv: list[str] | None = None) -> bool: + """Handle `cya memory ...` before the root REQUEST argument sees it.""" + import argparse + + argv = list(sys.argv if argv is None else argv) + if len(argv) < 2 or argv[1] != "memory": + return False + + parser = argparse.ArgumentParser( + prog=f"{argv[0]} memory", + description="Inspect and manage user-controlled memory.", + ) + sub = parser.add_subparsers(dest="command", required=True) + reflections = sub.add_parser("reflections", help="List or export Profile 1 reflections.") + reflections.add_argument( + "--scope", + "-s", + default=".", + help="Directory or scope to list reflections for.", + ) + reflections.add_argument( + "--json", + dest="export_json", + action="store_true", + help="Export reflections as JSON.", + ) + args = parser.parse_args(argv[2:]) + + if args.command == "reflections": + memory_reflections(scope=args.scope, export_json=args.export_json) + return True + return False + + +def _dispatch_retrospect_argv(argv: list[str] | None = None) -> bool: + """Handle `cya retrospect ...` before the root REQUEST argument sees it.""" + import argparse + + argv = list(sys.argv if argv is None else argv) + if len(argv) < 2 or argv[1] != "retrospect": + return False + + parser = argparse.ArgumentParser( + prog=f"{argv[0]} retrospect", + description="Start a guided retrospection session.", + ) + parser.add_argument( + "--scope", + "-s", + default=".", + help="Directory or scope to run retrospection on.", + ) + parser.add_argument( + "--limit", + type=int, + default=8, + help="Number of recent memory items to review.", + ) + args = parser.parse_args(argv[2:]) + + from cya.orchestrator import run_retrospection + + run_retrospection(scope=args.scope, limit=args.limit) + return True def run() -> None: @@ -206,4 +380,10 @@ def run() -> None: Using a thin wrapper around app() lets us keep the full-featured @app.callback(invoke_without_command=True) + ctx signature for Typer/Click. """ + if _dispatch_shell_argv() or _dispatch_memory_argv() or _dispatch_retrospect_argv(): + return app() + + +if __name__ == "__main__": + run() diff --git a/src/cya/config.py b/src/cya/config.py index 38cb559..ef64c51 100644 --- a/src/cya/config.py +++ b/src/cya/config.py @@ -18,6 +18,7 @@ _PROJECT_CONFIG_NAME = ".cya.toml" # Session context bounds (CYA-WP-0008-T04) — documented in docs/llm-connect-integration.md MAX_SESSION_TURNS = 10 MAX_SESSION_CHARS = 4000 +DEFAULT_SHELL_HISTORY_LINES = 50 def _load_toml(path: Path) -> dict[str, Any]: @@ -50,6 +51,15 @@ def _merge_llm_sections(*sources: dict[str, Any]) -> dict[str, Any]: return merged +def _merge_shell_history_sections(*sources: dict[str, Any]) -> dict[str, Any]: + merged: dict[str, Any] = {} + for source in sources: + section = source.get("shell_history") + if isinstance(section, dict): + merged.update(section) + return merged + + @dataclass class LLMSettings: """Resolved LLM adapter settings.""" @@ -77,6 +87,23 @@ class LLMSettings: return hints +@dataclass +class ShellHistorySettings: + """Resolved shell-history collection settings. + + History is intentionally off unless enabled by the CLI or config. The line + limit is capped so a session cannot accidentally ingest an unbounded shell + history file. + """ + + enabled: bool = False + limit: int = DEFAULT_SHELL_HISTORY_LINES + histfile: str | None = None + source: str = "default" + requested_limit: int | None = None + notes: list[str] = field(default_factory=list) + + def _coerce_float(value: Any, default: float) -> float: try: return float(value) @@ -91,6 +118,19 @@ def _coerce_int(value: Any, default: int) -> int: return default +def _coerce_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return default + + def load_llm_settings(*, offline: bool = False) -> LLMSettings: """Resolve LLM settings from env, user config, and project config.""" if offline: @@ -150,6 +190,55 @@ def load_llm_settings(*, offline: bool = False) -> LLMSettings: return base +def load_shell_history_settings( + *, + with_history: bool = False, + history_limit: int | None = None, +) -> ShellHistorySettings: + """Resolve optional shell-history collection settings. + + The default is always disabled. Config can enable history with: + + ``[shell_history] enabled = true`` + + The CLI ``--with-history`` flag also enables it for just that shell session. + """ + user_data = _load_toml(_USER_CONFIG) + project_path = _find_project_config() + project_data = _load_toml(project_path) if project_path else {} + merged = _merge_shell_history_sections(user_data, project_data) + + settings = ShellHistorySettings() + if merged: + source = str(project_path or _USER_CONFIG) + settings.enabled = _coerce_bool(merged.get("enabled"), False) + settings.limit = _coerce_int(merged.get("limit"), settings.limit) + settings.histfile = str(merged["histfile"]) if merged.get("histfile") else None + settings.source = source + + if with_history: + settings.enabled = True + settings.source = "--with-history" + + if history_limit is not None: + settings.requested_limit = history_limit + settings.limit = _coerce_int(history_limit, settings.limit) + if with_history: + settings.source = "--with-history" + + if settings.limit < 0: + settings.notes.append("Negative history limit was treated as 0.") + settings.limit = 0 + + if settings.limit > DEFAULT_SHELL_HISTORY_LINES: + settings.notes.append( + f"History limit capped at {DEFAULT_SHELL_HISTORY_LINES} lines." + ) + settings.limit = DEFAULT_SHELL_HISTORY_LINES + + return settings + + def bound_session_turns( turns: list[dict[str, str]] | None, *, diff --git a/src/cya/memory/__init__.py b/src/cya/memory/__init__.py index 1d38000..fb5c2f5 100644 --- a/src/cya/memory/__init__.py +++ b/src/cya/memory/__init__.py @@ -39,6 +39,7 @@ KIND_PREFERENCE = "preference" KIND_RETROSPECTION = "retrospection" KIND_INTERACTION_GOAL = "interaction_goal" KIND_REFLECTION = "reflection" # Profile 1 (Reflexion-style verbal lessons) — T05 minimal spike +KIND_SESSION_TURN = "session_turn" # CYA-WP-0007 episodic shell turn precursor def _warn_not_connected(feature: str) -> None: @@ -311,5 +312,6 @@ __all__ = [ "KIND_RETROSPECTION", "KIND_INTERACTION_GOAL", "KIND_REFLECTION", + "KIND_SESSION_TURN", ] diff --git a/src/cya/orchestrator.py b/src/cya/orchestrator.py index e1e2e36..877c75a 100644 --- a/src/cya/orchestrator.py +++ b/src/cya/orchestrator.py @@ -23,7 +23,9 @@ See workplan CYA-WP-0001 (core) + CYA-WP-0003 (memory wiring + retrospect) from __future__ import annotations +from dataclasses import dataclass, field from pathlib import Path +from typing import Any import typer from rich.console import Console @@ -56,6 +58,41 @@ from cya.llm.factory import get_adapter console = Console() +@dataclass +class OrchestratorResult: + """Structured result from a single assistance turn. + + CLI callers still receive the rich terminal rendering. Interactive clients + use this object to persist session turns and explain the last turn. + """ + + user_request: str + assistant: str + explanation: str = "" + rationale: str = "" + context: dict[str, Any] = field(default_factory=dict) + memory: dict[str, Any] = field(default_factory=dict) + risk: dict[str, Any] = field(default_factory=dict) + cancelled: bool = False + dry_run: bool = False + + +def _build_assistance_context( + envelope: Any, + memory: dict[str, Any], + *, + session_turns: list[dict[str, str]] | None = None, + extra_context: dict[str, Any] | None = None, +) -> dict[str, Any]: + ctx = (envelope.to_dict() if envelope else {}) or {} + ctx["memory"] = memory + if session_turns: + ctx["session_turns"] = bound_session_turns(session_turns) + if extra_context: + ctx["session"] = extra_context + return ctx + + def handle_request( user_request: str, *, @@ -63,7 +100,8 @@ def handle_request( dry_run: bool = False, offline: bool = False, session_turns: list[dict[str, str]] | None = None, -) -> None: + extra_context: dict[str, Any] | None = None, +) -> OrchestratorResult: """Primary orchestrator entry point. This is what the CLI (and future tests / other front-ends) should call. @@ -135,8 +173,29 @@ def handle_request( except Exception: pass + if explain_context and extra_context: + try: + import json + + console.print( + Panel( + json.dumps(extra_context, indent=2, default=str), + title="Shell Session Context", + border_style="cyan", + padding=(0, 1), + ) + ) + except Exception: + pass + # 2. Risk classification + mandatory confirmation (T03 safety; T04 memory signals) assessment = classify(user_request, envelope, memory=memory) + ctx = _build_assistance_context( + envelope, + memory, + session_turns=session_turns, + extra_context=extra_context, + ) if assessment.requires_confirmation: from rich.table import Table @@ -155,19 +214,31 @@ def handle_request( console.print(table) if not get_user_confirmation(assessment): - console.print("[yellow]Action cancelled by user. No changes made.[/yellow]") - return + msg = "Action cancelled by user. No changes made." + console.print(f"[yellow]{msg}[/yellow]") + return OrchestratorResult( + user_request=user_request, + assistant=msg, + context=ctx, + memory=memory, + risk=assessment.to_dict(), + cancelled=True, + ) if dry_run: - console.print("[green]--dry-run acknowledged.[/green] No side-effects.") - return + msg = "--dry-run acknowledged. No side-effects." + console.print(f"[green]{msg}[/green]") + return OrchestratorResult( + user_request=user_request, + assistant=msg, + context=ctx, + memory=memory, + risk=assessment.to_dict(), + dry_run=True, + ) # 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008) adapter = get_adapter(offline=offline) - ctx = (envelope.to_dict() if envelope else {}) or {} - ctx["memory"] = memory # T03: memory now in context passed to LLM (for personalization + explain) - if session_turns: - ctx["session_turns"] = bound_session_turns(session_turns) llm_request = AssistanceRequest( user_request=user_request, context=ctx, @@ -197,6 +268,16 @@ def handle_request( "[green]✓[/green] Request processed by orchestrator (T02+T03+T04 coordinated by T06)." ) + return OrchestratorResult( + user_request=user_request, + assistant=llm_response.suggestion, + explanation=llm_response.explanation, + rationale=llm_response.rationale, + context=ctx, + memory=memory, + risk=assessment.to_dict(), + ) + def run_retrospection(scope: str = ".", limit: int = 8) -> None: """Guided retrospection session (T04 of CYA-WP-0003). @@ -388,4 +469,4 @@ def _offer_reflection_compaction(scope: str) -> None: ) -__all__ = ["handle_request", "run_retrospection"] +__all__ = ["OrchestratorResult", "handle_request", "run_retrospection"] diff --git a/src/cya/shell_session.py b/src/cya/shell_session.py new file mode 100644 index 0000000..2472e8b --- /dev/null +++ b/src/cya/shell_session.py @@ -0,0 +1,862 @@ +"""Interactive shell session runtime for ``cya shell`` (CYA-WP-0007). + +The shell is a thin interactive client for the existing orchestrator. It owns +session-local state, optional history context, State Hub orientation, slash +commands, JSONL persistence, and user-triggered learning/export flows. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import sys +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable +from urllib import parse, request + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from cya.config import ShellHistorySettings, load_shell_history_settings +from cya.memory import KIND_SESSION_TURN, remember_preference +from cya.memory.reflections import ( + REFLECTION_CAPTURE_PROMPTS, + collect_lessons_from_answers, + preview_lessons, + save_reflection_lessons, +) +from cya.orchestrator import OrchestratorResult, handle_request + +TOPIC_ID = "64418556-3206-457a-ba29-6884b5b12cf3" +DEFAULT_AGENT = "can-you-assist" +DEFAULT_HUB_URL = os.environ.get("CYA_STATE_HUB_URL", "http://127.0.0.1:8000") + + +@dataclass +class ShellHistoryContext: + enabled: bool + source: str + limit: int + histfile: str | None = None + lines: list[dict[str, Any]] = field(default_factory=list) + redactions: int = 0 + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class HubOrientation: + base_url: str + topic_id: str + agent: str + brief: str | None = None + active_workstreams: list[dict[str, Any]] = field(default_factory=list) + inbox: list[dict[str, Any]] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + remote_checked: bool = False + + @property + def hub_available(self) -> bool: + return self.remote_checked and not self.errors + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["hub_available"] = self.hub_available + return data + + def compact(self) -> dict[str, Any]: + return { + "base_url": self.base_url, + "topic_id": self.topic_id, + "agent": self.agent, + "hub_available": self.hub_available, + "active_workstream_count": len(self.active_workstreams), + "inbox_count": len(self.inbox), + "errors": self.errors, + "workstreams": [ + { + "id": w.get("id") or w.get("workstream_id"), + "title": w.get("title") or w.get("name"), + "status": w.get("status"), + } + for w in self.active_workstreams[:5] + ], + } + + +_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile( + r"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET)[A-Z0-9_]*)" + r"(\s*=\s*)([^\s;]+)" + ), + re.compile(r"(?i)\b(--(?:api-key|token|password|secret)\s+)([^\s;]+)"), + re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"), + re.compile(r"\b(gh[pousr]_[A-Za-z0-9_]{12,})\b"), + re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]{12,})\b"), + re.compile(r"\b(AKIA[0-9A-Z]{12,})\b"), +) + + +InputFunc = Callable[[str], str] + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def make_session_id() -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + return f"cya-{stamp}-{uuid.uuid4().hex[:8]}" + + +def session_root() -> Path: + root = Path.home() / ".config" / "cya" / "sessions" + root.mkdir(parents=True, exist_ok=True) + return root + + +def default_session_path(session_id: str) -> Path: + return session_root() / f"{session_id}.jsonl" + + +def redact_secrets(text: str) -> tuple[str, int]: + redacted = text + count = 0 + + def _replace(match: re.Match[str]) -> str: + groups = match.groups() + if len(groups) >= 3: + return f"{groups[0]}{groups[1]}[REDACTED]" + return "[REDACTED_SECRET]" + + for pattern in _SECRET_PATTERNS: + redacted, n = pattern.subn(_replace, redacted) + count += n + return redacted, count + + +def _normalize_history_line(line: str) -> str: + text = line.strip() + if text.startswith(": ") and ";" in text: + return text.split(";", 1)[1].strip() + if text.startswith("- cmd: "): + return text.removeprefix("- cmd: ").strip() + return text + + +def _default_histfile(env: dict[str, str], home: Path) -> Path | None: + explicit = env.get("HISTFILE") + if explicit: + return Path(explicit).expanduser() + for candidate in ( + home / ".bash_history", + home / ".zsh_history", + home / ".local" / "share" / "fish" / "fish_history", + ): + if candidate.is_file(): + return candidate + return None + + +def collect_shell_history( + settings: ShellHistorySettings, + *, + env: dict[str, str] | None = None, + home: Path | None = None, +) -> ShellHistoryContext: + env = env or os.environ + home = home or Path.home() + ctx = ShellHistoryContext( + enabled=settings.enabled, + source=settings.source, + limit=settings.limit, + histfile=settings.histfile, + notes=list(settings.notes), + ) + + if not settings.enabled: + ctx.notes.append("Shell history disabled; no history was collected.") + return ctx + if settings.limit <= 0: + ctx.notes.append("Shell history enabled with limit 0; no lines collected.") + return ctx + + histfile = Path(settings.histfile).expanduser() if settings.histfile else _default_histfile(env, home) + if not histfile: + ctx.notes.append("No shell history file found from HISTFILE or known fallbacks.") + return ctx + + ctx.histfile = str(histfile) + try: + raw = histfile.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError as exc: + ctx.notes.append(f"Could not read history file: {exc}") + return ctx + + normalized = [_normalize_history_line(line) for line in raw] + normalized = [line for line in normalized if line] + start = max(0, len(normalized) - settings.limit) + for offset, line in enumerate(normalized[start:], start=start + 1): + clean, count = redact_secrets(line[:1000]) + ctx.redactions += count + ctx.lines.append( + { + "line_number": offset, + "command": clean, + "source": str(histfile), + "provenance": "shell_history.histfile", + } + ) + if ctx.redactions: + ctx.notes.append(f"Redacted {ctx.redactions} secret-like value(s).") + return ctx + + +def render_shell_history_explanation(history: ShellHistoryContext) -> str: + lines = [ + f"enabled: {history.enabled}", + f"source: {history.source}", + f"limit: {history.limit}", + f"histfile: {history.histfile or '(none)'}", + f"lines: {len(history.lines)}", + ] + if history.notes: + lines.append("notes:") + lines.extend(f" - {note}" for note in history.notes) + if history.lines: + lines.append("commands:") + for item in history.lines: + lines.append( + f" {item['line_number']}: {item['command']} ({item['provenance']})" + ) + return "\n".join(lines) + + +def _read_json_url(url: str, *, timeout: float) -> Any: + req = request.Request(url, headers={"User-Agent": "cya-shell/0.1"}) + with request.urlopen(req, timeout=timeout) as resp: + data = resp.read().decode("utf-8") + if not data.strip(): + return [] + return json.loads(data) + + +def _post_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any: + body = json.dumps(payload).encode("utf-8") + req = request.Request( + url, + data=body, + method="POST", + headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"}, + ) + with request.urlopen(req, timeout=timeout) as resp: + data = resp.read().decode("utf-8") + if not data.strip(): + return {} + return json.loads(data) + + +def _patch_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any: + body = json.dumps(payload).encode("utf-8") + req = request.Request( + url, + data=body, + method="PATCH", + headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"}, + ) + with request.urlopen(req, timeout=timeout) as resp: + data = resp.read().decode("utf-8") + if not data.strip(): + return {} + return json.loads(data) + + +def _as_list(value: Any) -> list[dict[str, Any]]: + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if isinstance(value, dict): + for key in ("items", "results", "data"): + if isinstance(value.get(key), list): + return [item for item in value[key] if isinstance(item, dict)] + return [] + + +def load_hub_orientation( + *, + cwd: Path | None = None, + base_url: str = DEFAULT_HUB_URL, + topic_id: str = TOPIC_ID, + agent: str = DEFAULT_AGENT, + timeout: float = 1.5, + include_remote: bool = True, +) -> HubOrientation: + cwd = cwd or Path.cwd() + orientation = HubOrientation(base_url=base_url.rstrip("/"), topic_id=topic_id, agent=agent) + + brief = cwd / ".custodian-brief.md" + if brief.is_file(): + try: + orientation.brief = brief.read_text(encoding="utf-8", errors="replace")[:6000] + except OSError as exc: + orientation.errors.append(f"brief read failed: {exc}") + + if not include_remote: + orientation.errors.append("State Hub remote orientation skipped by --no-hub.") + return orientation + + try: + qs = parse.urlencode({"topic_id": topic_id, "status": "active"}) + data = _read_json_url(f"{orientation.base_url}/workstreams/?{qs}", timeout=timeout) + orientation.active_workstreams = _as_list(data) + orientation.remote_checked = True + except Exception as exc: + orientation.errors.append(f"workstreams unavailable: {exc}") + + try: + qs = parse.urlencode({"to_agent": agent, "unread_only": "true"}) + data = _read_json_url(f"{orientation.base_url}/messages/?{qs}", timeout=timeout) + orientation.inbox = _as_list(data) + orientation.remote_checked = True + except Exception as exc: + orientation.errors.append(f"inbox unavailable: {exc}") + + return orientation + + +def render_hub_summary(orientation: HubOrientation) -> str: + lines: list[str] = [] + if orientation.brief: + for line in orientation.brief.splitlines(): + clean = line.strip("# ").strip() + if clean: + lines.append(clean) + break + if orientation.remote_checked: + lines.append(f"Active workstreams: {len(orientation.active_workstreams)}") + lines.append(f"Unread inbox messages: {len(orientation.inbox)}") + else: + lines.append("State Hub remote orientation not available.") + if orientation.errors: + lines.append("Notes: " + "; ".join(orientation.errors[:2])) + return "\n".join(lines) + + +def render_hub_details(orientation: HubOrientation) -> str: + lines = [render_hub_summary(orientation), ""] + if orientation.active_workstreams: + lines.append("Workstreams:") + for ws in orientation.active_workstreams[:8]: + title = ws.get("title") or ws.get("name") or "(untitled)" + ident = ws.get("id") or ws.get("workstream_id") or "?" + status = ws.get("status") or "?" + lines.append(f" - {title} [{status}] {ident}") + tasks = ws.get("tasks") or ws.get("open_tasks") or [] + if isinstance(tasks, list): + for task in tasks[:5]: + if isinstance(task, dict): + t_title = task.get("title") or task.get("name") or "(task)" + t_status = task.get("status") or "?" + lines.append(f" * {t_title} [{t_status}]") + if orientation.inbox: + lines.append("\nInbox:") + for msg in orientation.inbox[:10]: + title = msg.get("title") or msg.get("summary") or msg.get("subject") or "(message)" + ident = msg.get("id") or "?" + lines.append(f" - {ident}: {title}") + if orientation.errors: + lines.append("\nErrors:") + lines.extend(f" - {err}" for err in orientation.errors) + return "\n".join(lines).strip() + + +def post_progress(summary: str, orientation: HubOrientation) -> dict[str, Any]: + return _post_json_url( + f"{orientation.base_url}/progress/", + {"summary": summary, "event_type": "note", "author": "cya-shell"}, + ) + + +def mark_message_read(message_id: str, orientation: HubOrientation) -> dict[str, Any]: + return _patch_json_url(f"{orientation.base_url}/messages/{message_id}/read", {}) + + +def detect_weakness_hints( + user_text: str, + assistant_text: str = "", + *, + risk: dict[str, Any] | None = None, + turn_history: list[dict[str, Any]] | None = None, + hub: HubOrientation | None = None, +) -> list[dict[str, str]]: + text = f"{user_text}\n{assistant_text}".lower() + risk = risk or {} + turn_history = turn_history or [] + hints: list[dict[str, str]] = [] + + secret_terms = ("api key", "token", "password", "secret", "openrouter", "ops_hub_key") + if any(term in text for term in secret_terms) and "warden route" not in text: + hints.append( + { + "rule": "credential-routing", + "title": "Credential routing", + "message": "Route secret custody first with warden route/OpenBao; do not paste keys into prompts, Git, State Hub, or logs.", + } + ) + + if "ops-warden" in text and any(term in text for term in secret_terms): + hints.append( + { + "rule": "ops-warden-secret-anti-pattern", + "title": "ops-warden boundary", + "message": "ops-warden issues SSH certificates only; API keys and passwords belong to their routed owner.", + } + ) + + if re.search(r"\b(curl|wget)\b.*\|\s*(bash|sh|zsh|python)", text, re.S): + hints.append( + { + "rule": "remote-exec-review", + "title": "Remote execution review", + "message": "Inspect downloaded content before execution and keep the safety confirmation gate intact.", + } + ) + + level = str(risk.get("level", "")).lower() + previous_destructive = any( + str(turn.get("risk", "")).lower() in {"destructive", "mass_edit"} + for turn in turn_history[-3:] + ) + if level in {"destructive", "mass_edit"} and previous_destructive: + hints.append( + { + "rule": "repeated-destructive-intent", + "title": "Repeated destructive intent", + "message": "Repeated destructive or mass-edit turns are a cue to slow down and verify scope before proceeding.", + } + ) + + wp_tokens = set(re.findall(r"\b[A-Z]{2,5}-WP-\d{4}\b", user_text.upper())) + if wp_tokens and hub is not None: + haystack = json.dumps(hub.compact(), default=str).upper() + missing = sorted(token for token in wp_tokens if token not in haystack) + if missing: + hints.append( + { + "rule": "state-hub-alignment", + "title": "State Hub alignment", + "message": f"{', '.join(missing)} was not visible in the loaded hub summary; confirm the local workplan file is the source of truth.", + } + ) + + return hints + + +def render_weakness_hints(hints: list[dict[str, str]]) -> str: + lines = [] + for hint in hints: + lines.append(f"[{hint['rule']}] {hint['message']}") + return "\n".join(lines) + + +class ShellSession: + def __init__( + self, + *, + session_id: str | None = None, + offline: bool = False, + history: ShellHistoryContext | None = None, + hub: HubOrientation | None = None, + console: Console | None = None, + input_func: InputFunc | None = None, + interactive: bool | None = None, + offer_end_learning: bool = True, + ) -> None: + self.session_id = session_id or make_session_id() + self.path = default_session_path(self.session_id) + self.offline = offline + self.history = history or ShellHistoryContext(False, "default", 0) + self.hub = hub or HubOrientation(DEFAULT_HUB_URL, TOPIC_ID, DEFAULT_AGENT) + self.console = console or Console() + self.input_func = input_func or input + self.interactive = sys.stdin.isatty() if interactive is None else interactive + self.offer_end_learning = offer_end_learning + self.turns: list[dict[str, Any]] = [] + self.last_result: OrchestratorResult | None = None + self.last_hints: list[dict[str, str]] = [] + self._started = False + + def append_event(self, event: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(event, default=str) + "\n") + + def start(self) -> None: + if self._started: + return + self._started = True + self.append_event( + { + "kind": "session_start", + "session_id": self.session_id, + "created_at": utc_now(), + "cwd": str(Path.cwd()), + "history": self.history.to_dict(), + "hub": self.hub.compact(), + } + ) + body = ( + f"Session: {self.session_id}\n" + f"Artifact: {self.path}\n\n" + f"{render_hub_summary(self.hub)}\n\n" + f"History lines: {len(self.history.lines)}" + ) + self.console.print(Panel(body, title="cya shell", border_style="cyan")) + self.console.print("Type /help for commands, /exit to finish.") + try: + import readline # noqa: F401 + except Exception: + pass + + def run(self) -> Path: + self.start() + while True: + try: + line = self.input_func("cya> ") + except KeyboardInterrupt: + self.console.print("[yellow]Interrupted. Use /exit to end the session.[/yellow]") + continue + except EOFError: + self.console.print("[dim]EOF received; ending session.[/dim]") + break + + text = line.strip() + if not text: + continue + if text.startswith("/"): + keep_going = self.handle_command(text) + if not keep_going: + break + continue + self.handle_turn(text) + + self.close() + return self.path + + def handle_turn(self, text: str) -> OrchestratorResult: + extra_context = { + "session_id": self.session_id, + "session_path": str(self.path), + "shell_history": self.history.to_dict(), + "hub_orientation": self.hub.compact(), + } + result = handle_request( + text, + offline=self.offline, + session_turns=list(self.turns), + extra_context=extra_context, + ) + hints = detect_weakness_hints( + text, + result.assistant, + risk=result.risk, + turn_history=self.turns, + hub=self.hub, + ) + if hints: + self.console.print( + Panel(render_weakness_hints(hints), title="Weakness Hints", border_style="yellow") + ) + + clean_user, red_user = redact_secrets(text) + clean_assistant, red_assistant = redact_secrets(result.assistant) + risk_level = result.risk.get("level", "safe") + turn = { + "user": text, + "assistant": result.assistant, + "risk": risk_level, + } + self.turns.append(turn) + self.last_result = result + self.last_hints = hints + self.append_event( + { + "kind": KIND_SESSION_TURN, + "session_id": self.session_id, + "turn_index": len(self.turns), + "created_at": utc_now(), + "user": clean_user, + "assistant": clean_assistant, + "risk": result.risk, + "cancelled": result.cancelled, + "dry_run": result.dry_run, + "weakness_hints": hints, + "redactions": red_user + red_assistant, + } + ) + return result + + def handle_command(self, text: str) -> bool: + try: + parts = shlex.split(text) + except ValueError as exc: + self.console.print(f"[red]Could not parse command: {exc}[/red]") + return True + if not parts: + return True + + cmd = parts[0].lower() + if cmd in {"/exit", "/quit"}: + return False + if cmd == "/help": + self.show_help() + return True + if cmd == "/explain": + self.show_explain() + return True + if cmd == "/hub": + self.handle_hub(parts) + return True + if cmd == "/inbox": + self.handle_inbox(parts) + return True + if cmd == "/export-session": + self.export_session() + return True + if cmd == "/learn": + self.capture_reflections() + return True + + self.console.print(f"[yellow]Unknown command {parts[0]!r}. Try /help.[/yellow]") + return True + + def show_help(self) -> None: + self.console.print( + Panel( + "\n".join( + [ + "/help - show commands", + "/explain - show context, risk, history, hub, and hints for the last turn", + "/hub - show active State Hub workstreams and tasks", + "/hub log \"summary\" - post a progress note after confirmation", + "/inbox - show unread State Hub messages", + "/inbox read - explicitly mark a message read", + "/export-session - write a redacted session summary JSON", + "/learn - capture Profile 1 reflections now", + "/exit - close the shell session", + ] + ), + title="cya shell commands", + border_style="cyan", + ) + ) + + def show_explain(self) -> None: + if not self.last_result: + self.console.print("[yellow]No turns yet.[/yellow]") + self.console.print( + Panel( + render_shell_history_explanation(self.history), + title="Shell History Context", + border_style="cyan", + ) + ) + return + payload = { + "session_id": self.session_id, + "risk": self.last_result.risk, + "context": self.last_result.context, + "weakness_hints": self.last_hints, + } + self.console.print( + Panel( + json.dumps(payload, indent=2, default=str), + title="Last Turn Explanation", + border_style="green", + ) + ) + + def handle_hub(self, parts: list[str]) -> None: + if len(parts) == 1: + self.console.print(Panel(render_hub_details(self.hub), title="State Hub")) + return + if len(parts) >= 3 and parts[1].lower() == "log": + summary = " ".join(parts[2:]).strip() + if not summary: + self.console.print("[yellow]Usage: /hub log \"summary\"[/yellow]") + return + if not self.interactive: + self.console.print("[yellow]Hub writes require an interactive confirmation.[/yellow]") + return + if not typer.confirm(f"Post progress note to {self.hub.base_url}?", default=False): + self.console.print("[yellow]Progress note not posted.[/yellow]") + return + try: + result = post_progress(summary, self.hub) + self.console.print(f"[green]Posted progress note.[/green] {result}") + except Exception as exc: + self.console.print(f"[red]Progress post failed: {exc}[/red]") + return + self.console.print("[yellow]Usage: /hub or /hub log \"summary\"[/yellow]") + + def handle_inbox(self, parts: list[str]) -> None: + if len(parts) == 1: + body = "No unread messages." if not self.hub.inbox else render_hub_details(self.hub) + self.console.print(Panel(body, title="State Hub Inbox")) + return + if len(parts) == 3 and parts[1].lower() == "read": + message_id = parts[2] + if not self.interactive: + self.console.print("[yellow]Mark-read requires an interactive session.[/yellow]") + return + try: + result = mark_message_read(message_id, self.hub) + self.console.print(f"[green]Marked message read.[/green] {result}") + except Exception as exc: + self.console.print(f"[red]Mark-read failed: {exc}[/red]") + return + self.console.print("[yellow]Usage: /inbox or /inbox read [/yellow]") + + def export_session(self) -> Path: + out = self.path.with_name(f"{self.session_id}-summary.json") + turns = [] + for i, turn in enumerate(self.turns, 1): + user, user_redactions = redact_secrets(str(turn.get("user", ""))) + assistant, assistant_redactions = redact_secrets(str(turn.get("assistant", ""))) + turns.append( + { + "turn_index": i, + "user": user, + "assistant": assistant, + "risk": turn.get("risk"), + "redactions": user_redactions + assistant_redactions, + } + ) + payload = { + "kind": "session_export", + "session_id": self.session_id, + "exported_at": utc_now(), + "session_path": str(self.path), + "turn_count": len(turns), + "turns": turns, + "history": self.history.to_dict(), + "hub": self.hub.compact(), + } + out.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + self.console.print(f"[green]Exported session summary:[/green] {out}") + self.append_event( + {"kind": "session_export", "session_id": self.session_id, "path": str(out), "created_at": utc_now()} + ) + return out + + def capture_reflections(self) -> int: + if not self.interactive: + self.console.print("[yellow]Reflection capture needs an interactive terminal.[/yellow]") + return 0 + answers: dict[str, str] = {} + for prompt_key, label in REFLECTION_CAPTURE_PROMPTS: + answers[prompt_key] = typer.prompt(label, default="", show_default=False) + lessons = collect_lessons_from_answers(answers) + if not lessons: + self.console.print("[yellow]No lessons captured.[/yellow]") + return 0 + self.console.print(Panel(preview_lessons(lessons), title="Preview lessons")) + if not typer.confirm("Save these lessons?", default=False): + self.console.print("[yellow]Lessons discarded.[/yellow]") + return 0 + count = save_reflection_lessons( + lessons, + ".", + provenance={ + "session_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + "scope": ".", + "source": "cya shell", + "session_id": self.session_id, + }, + ) + self.console.print(f"[green]Saved {count} reflection(s).[/green]") + return count + + def remember_turns(self) -> int: + count = 0 + for i, turn in enumerate(self.turns, 1): + user, _ = redact_secrets(str(turn.get("user", ""))) + assistant, _ = redact_secrets(str(turn.get("assistant", ""))) + remember_preference( + f"session_turn_{self.session_id}_{i}", + {"user": user, "assistant": assistant, "risk": turn.get("risk")}, + scope=".", + kind=KIND_SESSION_TURN, + provenance={"source": "cya shell", "session_id": self.session_id, "turn_index": i}, + ) + count += 1 + return count + + def close(self) -> None: + self.append_event( + { + "kind": "session_end", + "session_id": self.session_id, + "ended_at": utc_now(), + "turn_count": len(self.turns), + } + ) + if self.interactive and self.offer_end_learning and self.turns: + if typer.confirm("Capture Profile 1 lessons from this shell session?", default=False): + self.capture_reflections() + if typer.confirm("Store redacted session turns as session_turn memory?", default=False): + count = self.remember_turns() + self.console.print(f"[green]Stored {count} session turn(s).[/green]") + self.console.print(f"[green]Session saved:[/green] {self.path}") + + +def run_shell( + *, + with_history: bool = False, + history_limit: int | None = None, + offline: bool = False, + hub_url: str = DEFAULT_HUB_URL, + no_hub: bool = False, + session_id: str | None = None, + console: Console | None = None, + input_func: InputFunc | None = None, + interactive: bool | None = None, + offer_end_learning: bool = True, +) -> Path: + settings = load_shell_history_settings(with_history=with_history, history_limit=history_limit) + history = collect_shell_history(settings) + hub = load_hub_orientation(base_url=hub_url, include_remote=not no_hub) + shell = ShellSession( + session_id=session_id, + offline=offline, + history=history, + hub=hub, + console=console, + input_func=input_func, + interactive=interactive, + offer_end_learning=offer_end_learning, + ) + return shell.run() + + +__all__ = [ + "DEFAULT_HUB_URL", + "TOPIC_ID", + "HubOrientation", + "ShellHistoryContext", + "ShellSession", + "collect_shell_history", + "detect_weakness_hints", + "load_hub_orientation", + "redact_secrets", + "render_shell_history_explanation", + "run_shell", +] diff --git a/tests/conftest.py b/tests/conftest.py index 5f7d2ec..887ae76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,3 +8,39 @@ import pytest # Future: common fixtures for envelopes, fake adapters, etc. # For now this file exists to establish the test layout. + +# The llm-connect integration tests mock llm_connect symbols. When the optional +# sibling package is not installed in a default dev checkout, provide a tiny +# import target so unittest.mock.patch can attach to it. Real installations win. +def pytest_configure(config): + import importlib.util + import sys + import types + + if importlib.util.find_spec("llm_connect") is not None: + return + if "llm_connect" in sys.modules: + return + + llm_connect = types.ModuleType("llm_connect") + llm_config = types.ModuleType("llm_connect.config") + llm_models = types.ModuleType("llm_connect.models") + + class RunConfig: + def __init__(self, *, model_name, temperature, max_tokens): + self.model_name = model_name + self.temperature = temperature + self.max_tokens = max_tokens + + def _missing_create_adapter(*args, **kwargs): + raise RuntimeError("llm_connect test stub was not patched") + + llm_connect.create_adapter = _missing_create_adapter + llm_config.resolve_api_key = lambda env_var=None: None + llm_models.RunConfig = RunConfig + llm_connect.config = llm_config + llm_connect.models = llm_models + + sys.modules["llm_connect"] = llm_connect + sys.modules["llm_connect.config"] = llm_config + sys.modules["llm_connect.models"] = llm_models diff --git a/tests/test_shell_session.py b/tests/test_shell_session.py new file mode 100644 index 0000000..e408ec9 --- /dev/null +++ b/tests/test_shell_session.py @@ -0,0 +1,213 @@ +"""Tests for CYA-WP-0007 interactive shell sessions.""" + +from __future__ import annotations + +import json +from io import StringIO +from pathlib import Path + +from rich.console import Console +from typer.testing import CliRunner + +from cya.cli.main import app +from cya.cli import main as cli_main +from cya.config import ShellHistorySettings +from cya.orchestrator import OrchestratorResult +from cya.safety.risk import classify +from cya.shell_session import ( + HubOrientation, + ShellHistoryContext, + ShellSession, + collect_shell_history, + detect_weakness_hints, + load_hub_orientation, +) + + +def _events(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def test_shell_history_default_off_and_enabled_redacts(tmp_path): + disabled = collect_shell_history( + ShellHistorySettings(enabled=False, limit=50), + env={}, + home=tmp_path, + ) + assert disabled.lines == [] + assert any("disabled" in note.lower() for note in disabled.notes) + + hist = tmp_path / ".bash_history" + hist.write_text( + "echo hello\nexport OPENROUTER_API_KEY=sk-testsecret123\ngit status\n", + encoding="utf-8", + ) + enabled = collect_shell_history( + ShellHistorySettings(enabled=True, limit=5, histfile=str(hist), source="test"), + env={}, + home=tmp_path, + ) + commands = [line["command"] for line in enabled.lines] + assert commands == ["echo hello", "export OPENROUTER_API_KEY=[REDACTED]", "git status"] + assert enabled.redactions == 1 + assert all(line["provenance"] == "shell_history.histfile" for line in enabled.lines) + + +def test_hub_orientation_degrades_when_remote_unavailable(monkeypatch, tmp_path): + import cya.shell_session as shell_session + + (tmp_path / ".custodian-brief.md").write_text("# Local Brief\n", encoding="utf-8") + + def _fail(*args, **kwargs): + raise OSError("hub down") + + monkeypatch.setattr(shell_session.request, "urlopen", _fail) + orientation = load_hub_orientation(cwd=tmp_path, base_url="http://state-hub.test") + + assert orientation.brief is not None + assert not orientation.hub_available + assert len(orientation.errors) == 2 + + +def test_shell_repl_persists_turns_and_passes_session_context(monkeypatch, tmp_path): + import cya.shell_session as shell_session + + monkeypatch.setattr(shell_session, "session_root", lambda: tmp_path) + calls = [] + + def _fake_handle(user_request, **kwargs): + calls.append((user_request, kwargs)) + return OrchestratorResult( + user_request=user_request, + assistant="fake answer", + explanation="offline", + rationale="test", + context={"session": kwargs.get("extra_context")}, + risk={"level": "safe"}, + ) + + monkeypatch.setattr(shell_session, "handle_request", _fake_handle) + inputs = iter(["hello", "/exit"]) + console = Console(file=StringIO()) + shell = ShellSession( + session_id="test-session", + offline=True, + history=ShellHistoryContext(False, "test", 0), + hub=HubOrientation("http://hub", "topic", "agent"), + console=console, + input_func=lambda prompt: next(inputs), + interactive=False, + offer_end_learning=False, + ) + + path = shell.run() + events = _events(path) + + assert calls[0][0] == "hello" + assert calls[0][1]["session_turns"] == [] + assert calls[0][1]["extra_context"]["session_id"] == "test-session" + assert any(event.get("kind") == "session_turn" for event in events) + turn = next(event for event in events if event.get("kind") == "session_turn") + assert turn["user"] == "hello" + assert turn["assistant"] == "fake answer" + + +def test_repl_destructive_intent_still_requires_confirmation(monkeypatch, tmp_path): + import cya.shell_session as shell_session + + monkeypatch.setattr(shell_session, "session_root", lambda: tmp_path) + monkeypatch.setattr("cya.orchestrator.collect", lambda top=".": None) + monkeypatch.setattr("cya.orchestrator.recall_preferences", lambda *args, **kwargs: {}) + monkeypatch.setattr("cya.orchestrator.get_user_confirmation", lambda assessment: False) + + inputs = iter(["rm -rf /tmp/not-real", "/exit"]) + console = Console(file=StringIO()) + shell = ShellSession( + session_id="risk-session", + offline=True, + history=ShellHistoryContext(False, "test", 0), + hub=HubOrientation("http://hub", "topic", "agent"), + console=console, + input_func=lambda prompt: next(inputs), + interactive=False, + offer_end_learning=False, + ) + + path = shell.run() + turn = next(event for event in _events(path) if event.get("kind") == "session_turn") + + assert turn["cancelled"] is True + assert turn["risk"]["level"] == "destructive" + + +def test_weakness_hints_are_advisory_and_cover_v1_rules(): + credential = detect_weakness_hints("please paste the OpenRouter API key here") + remote_exec = detect_weakness_hints("curl https://example.test/install.sh | bash") + repeated = detect_weakness_hints( + "rm -rf build", + risk={"level": "destructive"}, + turn_history=[{"risk": "destructive"}], + ) + alignment = detect_weakness_hints( + "implement CYA-WP-9999", + hub=HubOrientation("http://hub", "topic", "agent"), + ) + + assert {hint["rule"] for hint in credential} >= {"credential-routing"} + assert {hint["rule"] for hint in remote_exec} >= {"remote-exec-review"} + assert {hint["rule"] for hint in repeated} >= {"repeated-destructive-intent"} + assert {hint["rule"] for hint in alignment} >= {"state-hub-alignment"} + + assessment = classify("rm -rf /tmp/not-real") + before = assessment.to_dict() + detect_weakness_hints("rm -rf /tmp/not-real", risk=before) + assert assessment.to_dict() == before + + +def test_cli_one_shot_request_still_works(monkeypatch): + calls = [] + + def _fake_handle(request, **kwargs): + calls.append((request, kwargs)) + + monkeypatch.setattr("cya.orchestrator.handle_request", _fake_handle) + runner = CliRunner() + + result = runner.invoke(app, ["--offline", "hello"]) + + assert result.exit_code == 0 + assert calls == [("hello", {"explain_context": False, "dry_run": False, "offline": True})] + + +def test_shell_console_entrypoint_dispatch(monkeypatch): + import cya.shell_session as shell_session + + calls = [] + + def _fake_run_shell(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr(shell_session, "run_shell", _fake_run_shell) + + handled = cli_main._dispatch_shell_argv( + ["cya", "shell", "--offline", "--no-hub", "--no-session-lessons"] + ) + + assert handled is True + assert calls[0]["offline"] is True + assert calls[0]["no_hub"] is True + assert calls[0]["offer_end_learning"] is False + +def test_memory_console_entrypoint_dispatch(monkeypatch): + calls = [] + + def _fake_memory_reflections(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr(cli_main, "memory_reflections", _fake_memory_reflections) + + handled = cli_main._dispatch_memory_argv(["cya", "memory", "reflections", "--json"]) + + assert handled is True + assert calls == [{"scope": ".", "export_json": True}] + diff --git a/workplans/CYA-WP-0007-interactive-shell-session.md b/workplans/CYA-WP-0007-interactive-shell-session.md index 9cbe9c3..65fa0bd 100644 --- a/workplans/CYA-WP-0007-interactive-shell-session.md +++ b/workplans/CYA-WP-0007-interactive-shell-session.md @@ -4,11 +4,11 @@ type: workplan title: "Interactive Shell Session: REPL, History Context, and Hub-Aware Dev-Sec-Ops Helper" domain: agents repo: can-you-assist -status: ready +status: finished owner: grok topic_slug: foerster-capabilities created: "2026-06-22" -updated: "2026-06-22" +updated: "2026-06-23" state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab" --- @@ -64,7 +64,7 @@ Claude Code — that: ```task id: CYA-WP-0007-T01 -status: todo +status: done priority: high state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52" ``` @@ -86,7 +86,7 @@ Produce `docs/cya-interactive-shell-session-design.md` covering: ```task id: CYA-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29" ``` @@ -107,7 +107,7 @@ Implement `cya shell` Typer subcommand: ```task id: CYA-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5" ``` @@ -129,7 +129,7 @@ Extend context collection (new module or `collector` extension): ```task id: CYA-WP-0007-T04 -status: todo +status: done priority: medium state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d" ``` @@ -155,7 +155,7 @@ Slash commands: ```task id: CYA-WP-0007-T05 -status: todo +status: done priority: high state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16" ``` @@ -175,7 +175,7 @@ Connect each REPL turn to existing pipeline: ```task id: CYA-WP-0007-T06 -status: todo +status: done priority: medium state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16" ``` @@ -196,7 +196,7 @@ On `/exit` or session end: ```task id: CYA-WP-0007-T07 -status: todo +status: done priority: medium state_hub_task_id: "fbdd4f04-76ef-4a18-8c40-b41a134a743b" ``` @@ -217,7 +217,7 @@ Surface as informational panels — not auto-fixes. Optional save as reflection. ```task id: CYA-WP-0007-T08 -status: todo +status: done priority: high state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103" ``` @@ -235,7 +235,7 @@ state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103" ```task id: CYA-WP-0007-T09 -status: todo +status: done priority: low state_hub_task_id: "3c932e69-ee0a-4135-be11-f890da09509d" ``` @@ -255,4 +255,27 @@ When complete: --- **Status note:** Promoted to `ready` on 2026-06-22 after operator approval of direction. -Pair with CYA-WP-0008 for production LLM quality in the REPL. \ No newline at end of file +Pair with CYA-WP-0008 for production LLM quality in the REPL. + +## Completion Note - 2026-06-23 + +Implemented in this repo by Codex. Delivered `cya shell` with local JSONL +session artifacts, optional capped/redacted shell history, State Hub orientation +and explicit hub slash-command writes, `/explain`, `/export-session`, end-session +Profile 1 learning hooks, opt-in `session_turn` memory persistence, deterministic +weakness hints, docs, and tests. + +Verification: + +```bash +make test +python3 -m cya.cli.main --offline hello +python3 -m cya.cli.main shell --help +git diff --check +``` + +Operator follow-up: after this workplan file change, run from `~/state-hub`: + +```bash +make fix-consistency REPO=can-you-assist +```