generated from coulomb/repo-seed
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
862
src/cya/shell_session.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user