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:
21
AGENTS.md
21
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/<session-id>.jsonl`.
|
||||
Shell history is off by default and remains capped/redacted when enabled.
|
||||
|
||||
Slash commands: `/help`, `/explain`, `/hub`, `/hub log "summary"`, `/inbox`,
|
||||
`/inbox read <id>`, `/export-session`, `/learn`, `/exit`.
|
||||
|
||||
State Hub writes are never automatic. `/hub log` requires interactive
|
||||
confirmation; `/inbox read <id>` is the explicit mark-read action.
|
||||
|
||||
54
README.md
54
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/<session-id>.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.
|
||||
|
||||
17
SCOPE.md
17
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.
|
||||
|
||||
|
||||
@@ -6,4 +6,11 @@ backend = "openrouter"
|
||||
model = "anthropic/claude-sonnet-4"
|
||||
temperature = 0.3
|
||||
max_tokens = 2000
|
||||
api_key_env = "OPENROUTER_API_KEY"
|
||||
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"
|
||||
|
||||
172
docs/cya-interactive-shell-session-design.md
Normal file
172
docs/cya-interactive-shell-session-design.md
Normal file
@@ -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 <id>` - 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/<session-id>.jsonl
|
||||
~/.config/cya/sessions/<session-id>-summary.json
|
||||
```
|
||||
|
||||
Session ids use `cya-YYYYMMDD-HHMMSS-<random>`.
|
||||
|
||||
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 <id>` 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.
|
||||
43
docs/cya-shell-operator-session.md
Normal file
43
docs/cya-shell-operator-session.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
213
tests/test_shell_session.py
Normal file
213
tests/test_shell_session.py
Normal file
@@ -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}]
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user