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.
This commit is contained in:
2026-06-24 14:53:18 +02:00
parent a6266f7e2c
commit f15d253e64
14 changed files with 1819 additions and 31 deletions

View File

@@ -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()

View File

@@ -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,
*,

View File

@@ -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",
]

View File

@@ -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"]

862
src/cya/shell_session.py Normal file
View File

@@ -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 <id> - 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 <id>[/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",
]