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
|
1. Write the file following the format above
|
||||||
2. Notify the custodian operator to run `make fix-consistency REPO=can-you-assist`
|
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/`)
|
(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
|
The output includes a structured suggestion, rationale, and (when relevant) a
|
||||||
clear preview + confirmation prompt. Nothing executes without your explicit yes.
|
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)
|
## Safety (core product behavior)
|
||||||
|
|
||||||
- Genuine rule-based assessment is the primary mechanism.
|
- 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.
|
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:
|
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.
|
- **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-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-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:
|
Core capabilities now include:
|
||||||
- Natural language request handling via clean Typer CLI.
|
- 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.
|
- 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.
|
- `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.
|
- 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.
|
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.
|
- Orchestration of the request → context → safety → LLM adapter → response pipeline.
|
||||||
- The stable `LLMAdapter` Protocol and the contract for how `cya` talks to LLM backends.
|
- 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.
|
- 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.
|
- User-facing documentation, examples, and safety guarantees for the CLI tool.
|
||||||
|
|
||||||
## Does Not Own
|
## 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).
|
- 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).
|
- 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).
|
- 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
|
## 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 llm-connect features beyond basic `execute_prompt` delegation (adaptive routing, cost dashboards, structured output schemas).
|
||||||
- Deep semantic repository understanding or large-scale content analysis.
|
- Deep semantic repository understanding or large-scale content analysis.
|
||||||
- Automatic command execution (even "safe" suggestions) — explicit user confirmation remains mandatory for anything non-safe.
|
- 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.
|
- Cost tracking, token budgeting, or usage dashboards.
|
||||||
- Team/shared memory or collaboration features.
|
- Team/shared memory or collaboration features.
|
||||||
- Plugin system or domain-specific extensions.
|
- 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/safety/risk.py` — the `_RULES` table and `classify()` function (memory-aware).
|
||||||
- `cya/context/collector.py` — collection functions and ignore policy.
|
- `cya/context/collector.py` — collection functions and ignore policy.
|
||||||
- `cya/orchestrator.py` — the main coordination entry point.
|
- `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).
|
- Packaging & distribution: `Makefile`, `pyproject.toml`, `docs/release-process.md`, and `MANIFEST.in` (first-class concern with registered future work).
|
||||||
|
|
||||||
## Success Criteria (Current State)
|
## 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.
|
- 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`.
|
- Understand exactly what context and memory influenced a response via `--explain-context`.
|
||||||
- Trust that dangerous actions will never execute without explicit confirmation.
|
- 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.
|
- Use `cya retrospect` to reflect on usage and set goals that influence future behavior.
|
||||||
- Build and verify distribution packages locally.
|
- Build and verify distribution packages locally.
|
||||||
- See a clear path for how real `llm-connect`, deeper `phase-memory`, and future PyPI releases will integrate.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ model = "anthropic/claude-sonnet-4"
|
|||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
max_tokens = 2000
|
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(
|
memory_app = typer.Typer(
|
||||||
help="Inspect and manage user-controlled memory (Profile 0 + Profile 1).",
|
help="Inspect and manage user-controlled memory (Profile 0 + Profile 1).",
|
||||||
rich_markup_mode="rich",
|
rich_markup_mode="rich",
|
||||||
@@ -195,8 +241,136 @@ def retrospect(
|
|||||||
run_retrospection(scope=scope, limit=limit)
|
run_retrospection(scope=scope, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def _dispatch_shell_argv(argv: list[str] | None = None) -> bool:
|
||||||
app()
|
"""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:
|
def run() -> None:
|
||||||
@@ -206,4 +380,10 @@ def run() -> None:
|
|||||||
Using a thin wrapper around app() lets us keep the full-featured
|
Using a thin wrapper around app() lets us keep the full-featured
|
||||||
@app.callback(invoke_without_command=True) + ctx signature for Typer/Click.
|
@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()
|
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
|
# Session context bounds (CYA-WP-0008-T04) — documented in docs/llm-connect-integration.md
|
||||||
MAX_SESSION_TURNS = 10
|
MAX_SESSION_TURNS = 10
|
||||||
MAX_SESSION_CHARS = 4000
|
MAX_SESSION_CHARS = 4000
|
||||||
|
DEFAULT_SHELL_HISTORY_LINES = 50
|
||||||
|
|
||||||
|
|
||||||
def _load_toml(path: Path) -> dict[str, Any]:
|
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
|
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
|
@dataclass
|
||||||
class LLMSettings:
|
class LLMSettings:
|
||||||
"""Resolved LLM adapter settings."""
|
"""Resolved LLM adapter settings."""
|
||||||
@@ -77,6 +87,23 @@ class LLMSettings:
|
|||||||
return hints
|
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:
|
def _coerce_float(value: Any, default: float) -> float:
|
||||||
try:
|
try:
|
||||||
return float(value)
|
return float(value)
|
||||||
@@ -91,6 +118,19 @@ def _coerce_int(value: Any, default: int) -> int:
|
|||||||
return default
|
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:
|
def load_llm_settings(*, offline: bool = False) -> LLMSettings:
|
||||||
"""Resolve LLM settings from env, user config, and project config."""
|
"""Resolve LLM settings from env, user config, and project config."""
|
||||||
if offline:
|
if offline:
|
||||||
@@ -150,6 +190,55 @@ def load_llm_settings(*, offline: bool = False) -> LLMSettings:
|
|||||||
return base
|
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(
|
def bound_session_turns(
|
||||||
turns: list[dict[str, str]] | None,
|
turns: list[dict[str, str]] | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ KIND_PREFERENCE = "preference"
|
|||||||
KIND_RETROSPECTION = "retrospection"
|
KIND_RETROSPECTION = "retrospection"
|
||||||
KIND_INTERACTION_GOAL = "interaction_goal"
|
KIND_INTERACTION_GOAL = "interaction_goal"
|
||||||
KIND_REFLECTION = "reflection" # Profile 1 (Reflexion-style verbal lessons) — T05 minimal spike
|
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:
|
def _warn_not_connected(feature: str) -> None:
|
||||||
@@ -311,5 +312,6 @@ __all__ = [
|
|||||||
"KIND_RETROSPECTION",
|
"KIND_RETROSPECTION",
|
||||||
"KIND_INTERACTION_GOAL",
|
"KIND_INTERACTION_GOAL",
|
||||||
"KIND_REFLECTION",
|
"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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -56,6 +58,41 @@ from cya.llm.factory import get_adapter
|
|||||||
console = Console()
|
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(
|
def handle_request(
|
||||||
user_request: str,
|
user_request: str,
|
||||||
*,
|
*,
|
||||||
@@ -63,7 +100,8 @@ def handle_request(
|
|||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
offline: bool = False,
|
offline: bool = False,
|
||||||
session_turns: list[dict[str, str]] | None = None,
|
session_turns: list[dict[str, str]] | None = None,
|
||||||
) -> None:
|
extra_context: dict[str, Any] | None = None,
|
||||||
|
) -> OrchestratorResult:
|
||||||
"""Primary orchestrator entry point.
|
"""Primary orchestrator entry point.
|
||||||
|
|
||||||
This is what the CLI (and future tests / other front-ends) should call.
|
This is what the CLI (and future tests / other front-ends) should call.
|
||||||
@@ -135,8 +173,29 @@ def handle_request(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
# 2. Risk classification + mandatory confirmation (T03 safety; T04 memory signals)
|
||||||
assessment = classify(user_request, envelope, memory=memory)
|
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:
|
if assessment.requires_confirmation:
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
@@ -155,19 +214,31 @@ def handle_request(
|
|||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
if not get_user_confirmation(assessment):
|
if not get_user_confirmation(assessment):
|
||||||
console.print("[yellow]Action cancelled by user. No changes made.[/yellow]")
|
msg = "Action cancelled by user. No changes made."
|
||||||
return
|
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:
|
if dry_run:
|
||||||
console.print("[green]--dry-run acknowledged.[/green] No side-effects.")
|
msg = "--dry-run acknowledged. No side-effects."
|
||||||
return
|
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)
|
# 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008)
|
||||||
adapter = get_adapter(offline=offline)
|
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(
|
llm_request = AssistanceRequest(
|
||||||
user_request=user_request,
|
user_request=user_request,
|
||||||
context=ctx,
|
context=ctx,
|
||||||
@@ -197,6 +268,16 @@ def handle_request(
|
|||||||
"[green]✓[/green] Request processed by orchestrator (T02+T03+T04 coordinated by T06)."
|
"[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:
|
def run_retrospection(scope: str = ".", limit: int = 8) -> None:
|
||||||
"""Guided retrospection session (T04 of CYA-WP-0003).
|
"""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.
|
# Future: common fixtures for envelopes, fake adapters, etc.
|
||||||
# For now this file exists to establish the test layout.
|
# 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"
|
title: "Interactive Shell Session: REPL, History Context, and Hub-Aware Dev-Sec-Ops Helper"
|
||||||
domain: agents
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: ready
|
status: finished
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: foerster-capabilities
|
topic_slug: foerster-capabilities
|
||||||
created: "2026-06-22"
|
created: "2026-06-22"
|
||||||
updated: "2026-06-22"
|
updated: "2026-06-23"
|
||||||
state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab"
|
state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Claude Code — that:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T01
|
id: CYA-WP-0007-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52"
|
state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52"
|
||||||
```
|
```
|
||||||
@@ -86,7 +86,7 @@ Produce `docs/cya-interactive-shell-session-design.md` covering:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T02
|
id: CYA-WP-0007-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29"
|
state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29"
|
||||||
```
|
```
|
||||||
@@ -107,7 +107,7 @@ Implement `cya shell` Typer subcommand:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T03
|
id: CYA-WP-0007-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5"
|
state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5"
|
||||||
```
|
```
|
||||||
@@ -129,7 +129,7 @@ Extend context collection (new module or `collector` extension):
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T04
|
id: CYA-WP-0007-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d"
|
state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d"
|
||||||
```
|
```
|
||||||
@@ -155,7 +155,7 @@ Slash commands:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T05
|
id: CYA-WP-0007-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16"
|
state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16"
|
||||||
```
|
```
|
||||||
@@ -175,7 +175,7 @@ Connect each REPL turn to existing pipeline:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T06
|
id: CYA-WP-0007-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16"
|
state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16"
|
||||||
```
|
```
|
||||||
@@ -196,7 +196,7 @@ On `/exit` or session end:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T07
|
id: CYA-WP-0007-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "fbdd4f04-76ef-4a18-8c40-b41a134a743b"
|
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
|
```task
|
||||||
id: CYA-WP-0007-T08
|
id: CYA-WP-0007-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
|
state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
|
||||||
```
|
```
|
||||||
@@ -235,7 +235,7 @@ state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0007-T09
|
id: CYA-WP-0007-T09
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "3c932e69-ee0a-4135-be11-f890da09509d"
|
state_hub_task_id: "3c932e69-ee0a-4135-be11-f890da09509d"
|
||||||
```
|
```
|
||||||
@@ -256,3 +256,26 @@ When complete:
|
|||||||
|
|
||||||
**Status note:** Promoted to `ready` on 2026-06-22 after operator approval of direction.
|
**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