generated from coulomb/repo-seed
Compare commits
13 Commits
a0d24a31eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5df8ee5b | |||
| 6018e42930 | |||
| f15d253e64 | |||
| a6266f7e2c | |||
| 8e39e8e27c | |||
| a0f79af2ab | |||
| 79736d8b08 | |||
| d95dbb8ef5 | |||
| 019f6e7dc7 | |||
| cd5db14fbf | |||
| 34528308bb | |||
| f0ee0636c0 | |||
| c14d09c14d |
@@ -1,15 +1,3 @@
|
|||||||
## Agent entry points
|
|
||||||
|
|
||||||
| Runtime | Canonical instructions |
|
|
||||||
| --- | --- |
|
|
||||||
| **Grok / Codex** (shell) | `AGENTS.md` at repo root |
|
|
||||||
| **Claude Code** | This file tree via `CLAUDE.md` |
|
|
||||||
|
|
||||||
`AGENTS.md` and `.claude/rules/` are kept in sync for repo-specific content.
|
|
||||||
Fleet-wide credential routing is mirrored in `credential-routing.md` and the
|
|
||||||
matching section of `AGENTS.md` — re-sync from `~/ops-warden/wiki/CredentialRouting.md`
|
|
||||||
when the catalog changes.
|
|
||||||
|
|
||||||
## Kaizen Agents
|
## Kaizen Agents
|
||||||
|
|
||||||
Specialized agent personas available on demand via the state-hub MCP.
|
Specialized agent personas available on demand via the state-hub MCP.
|
||||||
@@ -29,4 +17,4 @@ Common agents:
|
|||||||
| `project-management` | process | Track status, determine next steps |
|
| `project-management` | process | Track status, determine next steps |
|
||||||
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||||
|
|
||||||
All 17 agents: call `list_kaizen_agents()` for the full list.
|
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Request pipeline** (`src/cya/orchestrator.py`):
|
<!-- TODO: Describe the key design decisions and component structure.
|
||||||
1. Collect local context (`context/collector.py`)
|
Key modules, data flows, external integrations, state machines, etc. -->
|
||||||
2. Recall memory via phase-memory ports (`memory/__init__.py`)
|
|
||||||
3. Classify risk (`safety/risk.py`) — rule-based; memory signals add caution only
|
|
||||||
4. Call LLM via `LLMAdapter` Protocol (`llm/adapter.py`) — FakeLLMAdapter today
|
|
||||||
5. Render explainable response (Rich)
|
|
||||||
|
|
||||||
**Memory (Profile 0 + Profile 1 spike):**
|
|
||||||
- User-controlled local JSON behind explicit ports (`remember`, `recall`, `forget`, `export`)
|
|
||||||
- Kinds: `preference`, `retrospection`, `interaction_goal`, `reflection`
|
|
||||||
- Directory/project-bound activation via `activation_context`
|
|
||||||
- `cya retrospect` feeds higher-order memory; optional verbal lesson capture (Profile 1)
|
|
||||||
|
|
||||||
**Boundaries:** See `repo-boundary.md`. No production path bypasses the adapter or memory ports.
|
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
- Memory contract: `MemoryVision.md`
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
- Activation/retrospection concept: `docs/cya-memory-activation-and-retrospection-concept.md`
|
|
||||||
- phase-memory feedback: `docs/phase-memory-optimization-suggestions.md`
|
|
||||||
- `~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# Credential and access routing
|
# Credential and access routing
|
||||||
|
|
||||||
> Fleet template mirrored in `AGENTS.md` (Credential and access routing section).
|
|
||||||
> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes.
|
|
||||||
|
|
||||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
@@ -28,14 +25,13 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
|
|||||||
|
|
||||||
### Quick routing table
|
### Quick routing table
|
||||||
|
|
||||||
Prefer `warden route find` for repo-specific needs. Common routes:
|
|
||||||
|
|
||||||
| I need… | Owner | ops-warden executes? |
|
| I need… | Owner | ops-warden executes? |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
| Authorization decision | flex-auth | No — route only |
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
### Anti-patterns (do not do these)
|
### Anti-patterns (do not do these)
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
## First Session Protocol
|
## First Session Protocol
|
||||||
|
|
||||||
Triggered when `get_domain_summary("capabilities")` shows **no workstreams**.
|
Triggered when `get_domain_summary("agents")` shows **no workstreams**.
|
||||||
The project is registered but work has not yet been structured.
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
**Step 1 — Read, don't write**
|
**Step 1 — Read, don't write**
|
||||||
- `INTENT.md`, `README.md`, `SCOPE.md`, `AGENTS.md`, `MemoryVision.md`
|
- `~/the-custodian/canon/projects/agents/project_charter_v0.1.md` — purpose, scope
|
||||||
- Scan repo root: `src/cya/`, `workplans/`, `tests/`
|
- `~/the-custodian/canon/projects/agents/roadmap_v0.1.md` — planned phases
|
||||||
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
**Step 2 — Survey in-progress work**
|
**Step 2 — Survey in-progress work**
|
||||||
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||||
|
|
||||||
**Step 3 — Propose workstreams to Bernd**
|
**Step 3 — Propose workstreams to Bernd**
|
||||||
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to
|
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||||
INTENT/SCOPE. **Wait for approval before creating.**
|
roadmap phase. **Wait for approval before creating.**
|
||||||
|
|
||||||
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||||
```
|
```
|
||||||
@@ -27,11 +28,11 @@ create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
|||||||
**Step 5 — Record the setup**
|
**Step 5 — Record the setup**
|
||||||
```
|
```
|
||||||
add_progress_event(
|
add_progress_event(
|
||||||
summary="First session: structured capabilities/can-you-assist into N workstreams, M tasks",
|
summary="First session: structured agents into N workstreams, M tasks",
|
||||||
event_type="milestone",
|
event_type="milestone",
|
||||||
topic_id="64418556-3206-457a-ba29-6884b5b12cf3",
|
topic_id="64418556-3206-457a-ba29-6884b5b12cf3",
|
||||||
detail={"workstreams": [...], "tasks_created": M}
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- Past first session for this repo — retained for fleet consistency -->
|
<!-- Delete or archive this file once past first session -->
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
## Repo boundary
|
## Repo boundary
|
||||||
|
|
||||||
This repo owns **can-you-assist** (`cya`) only. It does not own:
|
This repo owns **can-you-assist** only. It does not own:
|
||||||
|
|
||||||
- LLM provider access, API clients, or model hosting → `llm-connect`
|
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||||
- Durable memory storage, profile planners, graph/event stores, or lifecycle algorithms → `phase-memory`
|
- SSH key management → railiance-infra/
|
||||||
- Global work tracking, decisions, or cross-repo coordination → State Hub / custodian
|
- State hub code → state-hub/
|
||||||
- Credential custody (API keys, DB passwords, OIDC) → OpenBao / key-cape / operator paths (route via `warden route`)
|
-->
|
||||||
- SSH certificate issuance → `ops-warden` (`warden sign` only)
|
|
||||||
|
|
||||||
`cya` integrates via stable seams (`LLMAdapter` Protocol, memory ports). State Hub is non-runtime.
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
**Purpose:** Console-native, backend-agnostic LLM assistant (`cya`) for practical local work from the shell, using user-controlled context, memory, and provider configuration.
|
**Purpose:** Console-native, backend-agnostic LLM helper for practical local shell, repository, filesystem, and note workflows.
|
||||||
|
|
||||||
**Domain:** capabilities
|
**Domain:** agents
|
||||||
**Repo slug:** can-you-assist
|
**Repo slug:** can-you-assist
|
||||||
**Topic ID:** 64418556-3206-457a-ba29-6884b5b12cf3
|
**Topic ID:** 64418556-3206-457a-ba29-6884b5b12cf3
|
||||||
**Workplan prefix:** CYA-WP-
|
|
||||||
**Command name:** cya
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
## Session Protocol
|
## Session Protocol
|
||||||
|
|
||||||
State Hub: http://127.0.0.1:8000 (remote tunnel: http://127.0.0.1:18000)
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
**Step 1 — Orient**
|
**Step 1 — Orient**
|
||||||
|
|
||||||
@@ -10,12 +11,11 @@ cat .custodian-brief.md
|
|||||||
```
|
```
|
||||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
```
|
```
|
||||||
get_domain_summary("capabilities")
|
get_domain_summary("agents")
|
||||||
```
|
```
|
||||||
If MCP tools are unavailable in the current agent session, use the REST API:
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
```bash
|
```bash
|
||||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
|
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||||
| python3 -m json.tool
|
|
||||||
```
|
```
|
||||||
If the hub is offline: `cd ~/state-hub && make api`
|
If the hub is offline: `cd ~/state-hub && make api`
|
||||||
|
|
||||||
@@ -40,11 +40,11 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
|||||||
ls workplans/
|
ls workplans/
|
||||||
```
|
```
|
||||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
`todo`/`in_progress` tasks.
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
**Step 4 — Present brief**
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
1. **Active workstreams** for `capabilities` — title, task counts, blocking decisions
|
1. **Active workstreams** for `agents` — title, task counts, blocking decisions
|
||||||
2. **Pending tasks** from `workplans/` + any `[repo:can-you-assist]` hub tasks
|
2. **Pending tasks** from `workplans/` + any `[repo:can-you-assist]` hub tasks
|
||||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
@@ -54,12 +54,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
|
|||||||
|
|
||||||
If no workstreams: follow First Session Protocol (`first-session.md`).
|
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
**During work:**
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
- Keep changes small and inspectable.
|
|
||||||
- Treat command execution safety as product behavior: explain destructive commands and require explicit confirmation before suggesting execution.
|
|
||||||
- Do not commit secrets, API keys, local transcripts, private notes, or hidden memory contents.
|
|
||||||
- Before requesting API keys, SSH access, login tokens, or database passwords, run `warden route find` / `warden route show` per `credential-routing.md`.
|
|
||||||
- `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
|
||||||
|
|
||||||
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||||
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
@@ -87,4 +82,4 @@ cd ~/state-hub && make fix-consistency-remote REPO=can-you-assist
|
|||||||
```
|
```
|
||||||
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||||
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||||
until you pull — intentional to prevent clobbering remote progress.
|
until you pull — intentional to prevent clobbering remote progress.
|
||||||
|
|||||||
@@ -1,38 +1,19 @@
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Language:** Python 3.11+
|
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||||
- **CLI:** Typer + Rich
|
- **Language:**
|
||||||
- **Config:** TOML
|
- **Key deps:**
|
||||||
- **Packaging:** setuptools + setuptools_scm (`src/` layout)
|
|
||||||
- **Tests:** pytest
|
|
||||||
|
|
||||||
## Dev Commands
|
## Dev Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Recommended: install latest development code
|
# TODO: Fill in the standard commands for this repo
|
||||||
make dev-install
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
make test
|
|
||||||
# or: python3 -m pytest tests/ -q
|
|
||||||
|
|
||||||
# Build distribution packages
|
# Lint / type check
|
||||||
make dist
|
|
||||||
make release-prep
|
|
||||||
make check-dist
|
|
||||||
|
|
||||||
# Run the assistant
|
# Build / package (if applicable)
|
||||||
cya "your natural language request here"
|
|
||||||
cya --help
|
|
||||||
cya --explain-context "show me what context would be collected"
|
|
||||||
cya retrospect
|
|
||||||
|
|
||||||
# Version / inspection
|
|
||||||
make version
|
|
||||||
git status --short
|
|
||||||
rg --files
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Current memory baseline: **Profile 0** (local JSON + activation + retrospection).
|
|
||||||
Profile 1 verbal reflections shipped as a minimal spike (CYA-WP-0005); production
|
|
||||||
hardening tracked in CYA-WP-0006.
|
|
||||||
@@ -25,4 +25,16 @@ Ecosystem todos from other agents arrive as `[repo:can-you-assist]` hub tasks
|
|||||||
visible at session start. Pick one up by creating the workplan file, then registering
|
visible at session start. Pick one up by creating the workplan file, then registering
|
||||||
the workstream.
|
the workstream.
|
||||||
|
|
||||||
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||||
|
blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||||
# Custodian Brief — can-you-assist
|
# Custodian Brief — can-you-assist
|
||||||
|
|
||||||
**Domain:** capabilities
|
**Domain:** agents
|
||||||
**Last synced:** 2026-06-21 23:25 UTC
|
**Last synced:** 2026-06-23 12:26 UTC
|
||||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Current Goal
|
## Current Goal
|
||||||
@@ -17,6 +17,6 @@ Console-native user-controlled LLM assistant
|
|||||||
## MCP Orientation (when available)
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
If the state-hub MCP server is reachable, call:
|
If the state-hub MCP server is reachable, call:
|
||||||
`get_domain_summary("capabilities")`
|
`get_domain_summary("agents")`
|
||||||
This provides richer cross-domain context.
|
This provides richer cross-domain context.
|
||||||
If the MCP call fails, use this file as your orientation source.
|
If the MCP call fails, use this file as your orientation source.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -99,7 +99,7 @@ ipython_config.py
|
|||||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
# commonly ignored for libraries.
|
# commonly ignored for libraries.
|
||||||
#uv.lock
|
uv.lock
|
||||||
|
|
||||||
# poetry
|
# poetry
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
|||||||
16
.repo-classification.yaml
Normal file
16
.repo-classification.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-22'
|
||||||
|
classified_by: agent
|
||||||
|
category: project
|
||||||
|
domain: agents
|
||||||
|
secondary_domains: []
|
||||||
|
capability_tags: []
|
||||||
|
business_stake:
|
||||||
|
- technology
|
||||||
|
- automation
|
||||||
|
- product
|
||||||
|
business_mechanics:
|
||||||
|
- coordination
|
||||||
|
- operation
|
||||||
303
AGENTS.md
303
AGENTS.md
@@ -1,225 +1,108 @@
|
|||||||
# can-you-assist - Agent Instructions
|
# can-you-assist — Agent Instructions
|
||||||
|
|
||||||
## Worker Role
|
|
||||||
|
|
||||||
Primary worker agent: Grok Build / Grok Code.
|
|
||||||
|
|
||||||
Run from the repository root. When available, start with `grok inspect` to
|
|
||||||
confirm Grok has discovered this `AGENTS.md`, the repo files, and any local
|
|
||||||
`.grok/` configuration. The xAI Build docs say Grok reads the `AGENTS.md`
|
|
||||||
instruction-file family and can inspect discovered instructions, skills,
|
|
||||||
plugins, hooks, and MCPs:
|
|
||||||
|
|
||||||
- https://docs.x.ai/build/overview
|
|
||||||
- https://docs.x.ai/build/features/skills-plugins-marketplaces
|
|
||||||
|
|
||||||
This file is the canonical worker guide for this repo. Do not assume a Claude
|
|
||||||
Code MCP server is available; use the State Hub HTTP API unless the operator
|
|
||||||
explicitly provides another integration.
|
|
||||||
|
|
||||||
## Repo Identity
|
## Repo Identity
|
||||||
|
|
||||||
**Purpose:** Console-native, backend-agnostic LLM assistant for practical local
|
**Purpose:** Console-native, backend-agnostic LLM helper for practical local shell, repository, filesystem, and note workflows.
|
||||||
work from the shell, using user-controlled context, memory, and provider
|
|
||||||
configuration.
|
|
||||||
|
|
||||||
**Domain:** capabilities
|
**Domain:** agents
|
||||||
**Repo slug:** can-you-assist
|
**Repo slug:** can-you-assist
|
||||||
**Topic ID:** `64418556-3206-457a-ba29-6884b5b12cf3`
|
**Topic ID:** `64418556-3206-457a-ba29-6884b5b12cf3`
|
||||||
**Workplan prefix:** `CYA-WP-`
|
**Workplan prefix:** `CYA-WP-`
|
||||||
**Command name:** `cya`
|
|
||||||
|
|
||||||
## Product Direction
|
---
|
||||||
|
|
||||||
`cya` should help users express intent in natural language from a terminal and
|
|
||||||
receive useful, explainable help for command-line tasks. It should be good at
|
|
||||||
repository inspection, file and note workflows, command suggestion, command
|
|
||||||
explanation, and local context summarization.
|
|
||||||
|
|
||||||
Keep these boundaries crisp:
|
|
||||||
|
|
||||||
- `can-you-assist` owns the CLI assistant experience, local context gathering,
|
|
||||||
safety prompts, command explanations, and orchestration of assistance.
|
|
||||||
- `llm-connect` owns provider/backend access. Do not hard-code one LLM vendor
|
|
||||||
as the conceptual foundation.
|
|
||||||
- `phase-memory` owns user-controlled memory, preferences, history, and
|
|
||||||
adaptation. Do not hide memory in opaque hosted state.
|
|
||||||
- State Hub tracks work, coordination, progress, and cross-repo handoffs. It
|
|
||||||
should not become a runtime dependency of the `cya` CLI.
|
|
||||||
|
|
||||||
## State Hub Integration
|
## State Hub Integration
|
||||||
|
|
||||||
The Custodian State Hub tracks work across all domains. Interact via HTTP REST.
|
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||||
|
there is no MCP server for Codex agents.
|
||||||
|
|
||||||
| Context | URL |
|
| Context | URL |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| Local workstation | `http://127.0.0.1:8000` |
|
| Local workstation | `http://127.0.0.1:8000` |
|
||||||
| Remote via tunnel | `http://127.0.0.1:18000` |
|
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||||
|
|
||||||
### Orient At Session Start
|
### Orient at session start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Offline brief - generated by State Hub consistency tooling when available
|
# Offline brief — works without hub connection
|
||||||
cat .custodian-brief.md
|
cat .custodian-brief.md
|
||||||
|
|
||||||
# Active workstreams for this domain/topic
|
# Active workstreams for this domain
|
||||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
|
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
|
||||||
| python3 -m json.tool
|
| python3 -m json.tool
|
||||||
|
|
||||||
# Inbox for this repo worker
|
# Check inbox
|
||||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=can-you-assist&unread_only=true" \
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=can-you-assist&unread_only=true" \
|
||||||
| python3 -m json.tool
|
| python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
Mark a message read:
|
Mark a message read:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
-H "Content-Type: application/json" -d '{}'
|
-H "Content-Type: application/json" -d '{}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update Task Status
|
### Log progress (required at session close)
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"status": "in_progress"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed task statuses: `todo`, `in_progress`, `done`, `blocked`.
|
|
||||||
|
|
||||||
### Log Progress
|
|
||||||
|
|
||||||
Log progress at session close and after meaningful milestones:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"summary": "what changed",
|
"summary": "what was done",
|
||||||
"event_type": "note",
|
"event_type": "note",
|
||||||
"author": "grok",
|
"author": "codex",
|
||||||
"workstream_id": "<uuid>",
|
"workstream_id": "<uuid>",
|
||||||
"task_id": "<uuid>"
|
"task_id": "<uuid>"
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Omit `workstream_id` and `task_id` only when there is no applicable item.
|
Omit `workstream_id` / `task_id` when not applicable.
|
||||||
|
|
||||||
|
### Update task status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status": "progress"}'
|
||||||
|
# values: wait | todo | progress | done | cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flag a task for human review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session Protocol
|
## Session Protocol
|
||||||
|
|
||||||
Start:
|
**Start:**
|
||||||
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
|
2. Check inbox: `GET /messages/?to_agent=can-you-assist&unread_only=true`; mark read
|
||||||
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
|
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
1. Run `git status --short`.
|
**During work:**
|
||||||
2. Read `.custodian-brief.md` if present; if absent, use the State Hub API
|
- Update task statuses in workplan files as tasks progress
|
||||||
queries above.
|
- Record significant decisions via `POST /decisions/`
|
||||||
3. Check the `can-you-assist` inbox and mark read only after acting on a
|
|
||||||
message or carrying it forward in a workplan.
|
|
||||||
4. Scan `workplans/` if it exists. If it does not exist yet, the onboarding
|
|
||||||
workstream should create it.
|
|
||||||
5. Pick the highest-priority active task that is unblocked and update task
|
|
||||||
status before substantial work.
|
|
||||||
|
|
||||||
During work:
|
**Close:**
|
||||||
|
1. Update workplan file task statuses to reflect progress
|
||||||
- Keep changes small and inspectable.
|
2. Log: `POST /progress/` with a summary of what changed
|
||||||
- Treat command execution safety as product behavior: explain destructive or
|
3. Note for the custodian operator: after workplan file changes, run from
|
||||||
broad filesystem commands and require explicit confirmation before suggesting
|
`~/state-hub`:
|
||||||
execution.
|
```bash
|
||||||
- Before requesting API keys, SSH access, login tokens, or database passwords,
|
make fix-consistency REPO=can-you-assist
|
||||||
run `warden route find` / `warden route show` per **Credential and access routing**.
|
```
|
||||||
- Do not commit secrets, API keys, local transcripts, private notes, or hidden
|
This syncs task status from files into the hub DB.
|
||||||
memory contents.
|
|
||||||
- Preserve user-controlled memory as a design principle. Prefer explicit,
|
|
||||||
inspectable local files over hidden state.
|
|
||||||
- Record significant design decisions with `POST /decisions/`.
|
|
||||||
|
|
||||||
Close:
|
|
||||||
|
|
||||||
1. Update workplan task statuses to match reality.
|
|
||||||
2. Log a progress event in State Hub.
|
|
||||||
3. Ask the custodian operator to run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/state-hub && make fix-consistency REPO=can-you-assist
|
|
||||||
```
|
|
||||||
|
|
||||||
That syncs repo workplan files into the State Hub DB and regenerates
|
|
||||||
`.custodian-brief.md`.
|
|
||||||
|
|
||||||
## Current Grok Handoff
|
|
||||||
|
|
||||||
State Hub registration has been created for `can-you-assist` under the
|
|
||||||
`capabilities` domain. Look for active workstream
|
|
||||||
`repo-integration-can-you-assist`.
|
|
||||||
|
|
||||||
First useful worker moves:
|
|
||||||
|
|
||||||
1. Read `INTENT.md`, `README.md`, this `AGENTS.md`, and `SCOPE.md`.
|
|
||||||
2. Create `workplans/CYA-WP-0001-console-native-mvp.md` with a focused first
|
|
||||||
implementation slice: CLI skeleton, safe command-assistance flow,
|
|
||||||
llm-connect adapter boundary, phase-memory boundary, and test strategy.
|
|
||||||
3. Keep the first workplan `ready` until the repo state and stack choice are
|
|
||||||
reviewed. Move it to `active` when implementation begins.
|
|
||||||
4. Run or request SBOM ingest from State Hub after the first dependency files
|
|
||||||
exist.
|
|
||||||
5. Register obvious extension points and technical debt once there is code to
|
|
||||||
anchor them.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# === Packaging & Installation (CYA-WP-0004) ===
|
|
||||||
|
|
||||||
# Recommended: Stay on latest development code
|
|
||||||
make dev-install
|
|
||||||
|
|
||||||
# Build distribution packages (sdist + wheel)
|
|
||||||
make dist
|
|
||||||
|
|
||||||
# Prepare a release (tests + clean build)
|
|
||||||
make release-prep
|
|
||||||
|
|
||||||
# Verify a built wheel in isolation
|
|
||||||
make check-dist
|
|
||||||
|
|
||||||
# Run the assistant
|
|
||||||
cya "your natural language request here"
|
|
||||||
cya --help
|
|
||||||
cya --explain-context "show me what context would be collected"
|
|
||||||
|
|
||||||
# Memory features (0003 + 0005)
|
|
||||||
cya retrospect # Guided reflection session
|
|
||||||
# Current memory implementation is Profile 0 (see CYA-WP-0005 and MemoryVision "Profile 0 Baseline").
|
|
||||||
# Future Profiles 1–3 (verbal self-improvement, hierarchical synthesis, procedural rules) are tracked in that workplan.
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
python -m pytest tests/ -q
|
|
||||||
|
|
||||||
# Git / inspection
|
|
||||||
git status --short
|
|
||||||
git log --oneline -5
|
|
||||||
rg --files
|
|
||||||
```
|
|
||||||
|
|
||||||
No formal lint or build system yet (ruff is configured in pyproject.toml but not enforced in CI for the first slice). Add `make test` / `make lint` targets in a follow-on when needed.
|
|
||||||
|
|
||||||
Current primary entry point: `cya` (after editable install).
|
|
||||||
|
|
||||||
Relevant workplans:
|
|
||||||
- `workplans/CYA-WP-0002-memory-integration-roadmap.md`
|
|
||||||
- `workplans/CYA-WP-0003-contextual-memory-activation-and-retrospection.md`
|
|
||||||
- `workplans/CYA-WP-0004-dev-install-and-release-packaging.md`
|
|
||||||
- `workplans/CYA-WP-0005-agentic-memory-profiles-and-phase-memory-feedback.md`
|
|
||||||
- `workplans/CYA-WP-0006-profile-1-production-hardening.md` (ready — next slice)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credential and access routing
|
## Credential and access routing
|
||||||
|
|
||||||
> Fleet template mirrored in `.claude/rules/credential-routing.md` for Claude Code.
|
|
||||||
> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes.
|
|
||||||
|
|
||||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
@@ -245,14 +128,13 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
|
|||||||
|
|
||||||
### Quick routing table
|
### Quick routing table
|
||||||
|
|
||||||
Prefer `warden route find` for repo-specific needs. Common routes:
|
|
||||||
|
|
||||||
| I need… | Owner | ops-warden executes? |
|
| I need… | Owner | ops-warden executes? |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
| Authorization decision | flex-auth | No — route only |
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
### Anti-patterns (do not do these)
|
### Anti-patterns (do not do these)
|
||||||
@@ -270,52 +152,89 @@ get wrong.
|
|||||||
|
|
||||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workplan Convention
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
Work items originate as files in this repo. The hub is a read/cache/index
|
Work items originate as files in this repo — not in the hub. The hub is a
|
||||||
layer that rebuilds from files.
|
read/cache/index layer that rebuilds from files.
|
||||||
|
|
||||||
**File location:** `workplans/CYA-WP-NNNN-<slug>.md`
|
**File location:** `workplans/CAN-WP-NNNN-<slug>.md`
|
||||||
|
|
||||||
**Archived location:** `workplans/archived/YYMMDD-CYA-WP-NNNN-<slug>.md`
|
**Archived location:** finished workplans may move to
|
||||||
|
`workplans/archived/YYMMDD-CAN-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
**Ad hoc tasks:** small opportunistic fixes may use
|
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||||
`workplans/ADHOC-YYYY-MM-DD.md` with task ids like
|
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||||
`ADHOC-YYYY-MM-DD-T01`. Use this only for low-risk work completed directly.
|
this only for low-risk work completed directly; create a normal workplan for
|
||||||
|
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||||
|
|
||||||
Frontmatter:
|
**Frontmatter:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
id: CYA-WP-NNNN
|
id: CAN-WP-NNNN
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "..."
|
title: "..."
|
||||||
domain: capabilities
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
owner: grok
|
owner: codex
|
||||||
topic_slug: foerster-capabilities
|
topic_slug: ...
|
||||||
created: "YYYY-MM-DD"
|
created: "YYYY-MM-DD"
|
||||||
updated: "YYYY-MM-DD"
|
updated: "YYYY-MM-DD"
|
||||||
state_hub_workstream_id: "<uuid>" # written by fix-consistency - do not edit
|
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Task block format:
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
````
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
|
```
|
||||||
## Task Title
|
## Task Title
|
||||||
|
|
||||||
```task
|
` ` `task
|
||||||
id: CYA-WP-NNNN-T01
|
id: CAN-WP-NNNN-T01
|
||||||
status: todo | in_progress | done | blocked
|
status: wait | todo | progress | done | cancel
|
||||||
priority: high | medium | low
|
priority: high | medium | low
|
||||||
state_hub_task_id: "<uuid>" # written by fix-consistency - do not edit
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
```
|
` ` `
|
||||||
|
|
||||||
Task description text.
|
Task description text.
|
||||||
````
|
```
|
||||||
|
|
||||||
Status progression: `todo` -> `in_progress` -> `done` or `blocked`.
|
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
To create a new workplan:
|
||||||
|
1. Write the file following the format above
|
||||||
|
2. Notify the custodian operator to run `make fix-consistency REPO=can-you-assist`
|
||||||
|
(or send a message to the hub agent via `POST /messages/`)
|
||||||
|
---
|
||||||
|
|
||||||
|
## `cya shell` Command Reference
|
||||||
|
|
||||||
|
Interactive shell sessions are an owned repo capability. Use them for manual
|
||||||
|
operator assistance, not autonomous command execution.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cya shell # start a stateful shell session
|
||||||
|
cya shell --offline --no-hub # local smoke without hub HTTP calls
|
||||||
|
cya shell --with-history # opt in to capped, redacted history context
|
||||||
|
```
|
||||||
|
|
||||||
|
Session artifacts live at `~/.config/cya/sessions/<session-id>.jsonl`.
|
||||||
|
Shell history is off by default and remains capped/redacted when enabled.
|
||||||
|
|
||||||
|
Slash commands: `/help`, `/explain`, `/hub`, `/hub log "summary"`, `/inbox`,
|
||||||
|
`/inbox read <id>`, `/export-session`, `/learn`, `/exit`.
|
||||||
|
|
||||||
|
State Hub writes are never automatic. `/hub log` requires interactive
|
||||||
|
confirmation; `/inbox read <id>` is the explicit mark-read action.
|
||||||
|
|||||||
@@ -9,4 +9,4 @@
|
|||||||
@.claude/rules/architecture.md
|
@.claude/rules/architecture.md
|
||||||
@.claude/rules/repo-boundary.md
|
@.claude/rules/repo-boundary.md
|
||||||
@.claude/rules/credential-routing.md
|
@.claude/rules/credential-routing.md
|
||||||
@.claude/rules/agents.md
|
@.claude/rules/agents.md
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ The three profiles are ordered by increasing agentic power and implementation co
|
|||||||
|
|
||||||
### Profile 1 — Reflexion-Style Verbal Self-Improvement Loop
|
### Profile 1 — Reflexion-Style Verbal Self-Improvement Loop
|
||||||
|
|
||||||
|
**Status:** Production (CYA-WP-0006). Shipped with guided capture in `cya retrospect`, `cya memory reflections`, explicit compaction, and budget-capped surfacing in responses and `--explain-context`.
|
||||||
|
|
||||||
**Intent:** Enable lightweight, high-explainability self-improvement by capturing and preferentially activating natural-language "lessons" and verbal reflections from user interactions and retrospection sessions.
|
**Intent:** Enable lightweight, high-explainability self-improvement by capturing and preferentially activating natural-language "lessons" and verbal reflections from user interactions and retrospection sessions.
|
||||||
|
|
||||||
**Core Loop (condensed):**
|
**Core Loop (condensed):**
|
||||||
|
|||||||
124
README.md
124
README.md
@@ -16,7 +16,7 @@ usable after `pip install -e .`:
|
|||||||
- `cya "your request in plain English"`
|
- `cya "your request in plain English"`
|
||||||
- `cya --explain-context "..."` — shows exactly what local context would be sent
|
- `cya --explain-context "..."` — shows exactly what local context would be sent
|
||||||
- Automatic rule-based risk classification with mandatory confirmation for anything destructive, privileged, mass-edit, or network-affecting
|
- Automatic rule-based risk classification with mandatory confirmation for anything destructive, privileged, mass-edit, or network-affecting
|
||||||
- All LLM interaction flows through a documented `LLMAdapter` seam (currently a deterministic fake; ready for real `llm-connect`)
|
- All LLM interaction flows through a documented `LLMAdapter` seam (`FakeLLMAdapter` by default; real `llm-connect` when configured)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -58,6 +58,41 @@ git pull
|
|||||||
make dev-install
|
make dev-install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## LLM backend (configured vs offline)
|
||||||
|
|
||||||
|
By default `cya` uses a deterministic **offline** adapter (no API keys, no network).
|
||||||
|
For real inference, configure llm-connect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install llm-connect (sibling checkout)
|
||||||
|
pip install -e ~/llm-connect
|
||||||
|
|
||||||
|
# 2. Route credentials — do not commit keys
|
||||||
|
warden route find "OpenRouter API key" --json
|
||||||
|
|
||||||
|
# 3. Export key and configure cya
|
||||||
|
export OPENROUTER_API_KEY="..." # from OpenBao / operator path
|
||||||
|
mkdir -p ~/.config/cya
|
||||||
|
cat > ~/.config/cya/config.toml <<'EOF'
|
||||||
|
[llm]
|
||||||
|
adapter = "connect"
|
||||||
|
backend = "openrouter"
|
||||||
|
model = "anthropic/claude-sonnet-4"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cya "show me the recent git history for this repo"
|
||||||
|
```
|
||||||
|
|
||||||
|
Force offline mode anytime (tests, CI, air-gapped):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cya --offline "your request"
|
||||||
|
# or: CYA_LLM_ADAPTER=fake cya "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/llm-connect-integration.md` for the full mapping, session context budget,
|
||||||
|
and optional `.cya.toml` project overrides.
|
||||||
|
|
||||||
## Usage examples
|
## Usage examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -74,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.
|
||||||
@@ -115,12 +204,29 @@ Run a guided reflection session to review how memory was used and explicitly set
|
|||||||
cya retrospect
|
cya retrospect
|
||||||
```
|
```
|
||||||
|
|
||||||
During the session `cya` will (Profile 1 spike in T05 adds optional verbal lesson capture at the end):
|
During the session `cya` will:
|
||||||
- Show recent memory items that were activated.
|
- Show recent memory items that were activated.
|
||||||
- Help you reflect on what worked or didn't.
|
- Help you reflect on what worked or didn't.
|
||||||
- Let you record new **interaction goals** (e.g. "be more concise", "always show one safe alternative for destructive commands").
|
- Let you record new **interaction goals** (e.g. "be more concise", "always show one safe alternative for destructive commands").
|
||||||
|
- Optionally capture **1–3 verbal lessons** (Profile 1) with guided prompts, preview, and confirmation.
|
||||||
|
|
||||||
These goals are stored as first-class retrospection memory and will influence future activations and responses.
|
Example Profile 1 flow at the end of `cya retrospect`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Capture 1–3 verbal lessons from this session? (y/n) y
|
||||||
|
What went well that you want to remember? (or 'skip') Safety warnings were clear
|
||||||
|
What should cya remember for next time? (or 'skip') Always suggest git status first
|
||||||
|
What should cya avoid in this scope? (or 'skip') skip
|
||||||
|
|
||||||
|
Preview — verbal lessons to save
|
||||||
|
1. [went well] Safety warnings were clear
|
||||||
|
2. [remember] Always suggest git status first
|
||||||
|
|
||||||
|
Save these lessons? (y/n) y
|
||||||
|
Saved 2 verbal reflection(s) (Profile 1).
|
||||||
|
```
|
||||||
|
|
||||||
|
These goals and lessons are stored as first-class memory and will influence future activations and responses.
|
||||||
|
|
||||||
### Inspecting and Controlling Memory
|
### Inspecting and Controlling Memory
|
||||||
|
|
||||||
@@ -132,6 +238,8 @@ All memory is stored in plain, user-editable JSON:
|
|||||||
Useful commands:
|
Useful commands:
|
||||||
```bash
|
```bash
|
||||||
cya --explain-context "..." # See exactly what memory was activated and why
|
cya --explain-context "..." # See exactly what memory was activated and why
|
||||||
|
cya memory reflections # List verbal reflections for the current scope
|
||||||
|
cya memory reflections --json # Export reflections as JSON
|
||||||
# (You can also use the memory ports directly in Python if you want to script it.)
|
# (You can also use the memory ports directly in Python if you want to script it.)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,7 +250,9 @@ Memory also feeds the safety layer: a "never auto-run" preference you set during
|
|||||||
- Activation is automatic based on cwd + git root (with full provenance).
|
- Activation is automatic based on cwd + git root (with full provenance).
|
||||||
- Retrospection outcomes are stored with special kinds (`retrospection`, `interaction_goal`) and get preferential treatment in future context building.
|
- Retrospection outcomes are stored with special kinds (`retrospection`, `interaction_goal`) and get preferential treatment in future context building.
|
||||||
- Everything is designed to be replaced/enriched by a full `phase-memory` implementation later (see MemoryVision.md).
|
- Everything is designed to be replaced/enriched by a full `phase-memory` implementation later (see MemoryVision.md).
|
||||||
- Current implementation is formally **Profile 0** (post-0003 local JSON + activation + retrospection loop). See CYA-WP-0005 and the "Profile 0 Baseline" section in MemoryVision.md for the exact definition and the roadmap to Profiles 1–3 (self-improving verbal reflections, hierarchical synthesis, and procedural rules).
|
- **Profile 0** (post-0003 local JSON + activation + retrospection loop) is the stable foundation.
|
||||||
|
- **Profile 1** (verbal reflections) is production-ready as of CYA-WP-0006: guided capture in `cya retrospect`, `cya memory reflections`, compaction, and surfacing in responses / `--explain-context`.
|
||||||
|
- Profiles 2–3 (hierarchical synthesis, procedural rules) remain roadmap items — see MemoryVision.md.
|
||||||
|
|
||||||
See:
|
See:
|
||||||
- `docs/cya-memory-activation-and-retrospection-concept.md` (the T01 design)
|
- `docs/cya-memory-activation-and-retrospection-concept.md` (the T01 design)
|
||||||
@@ -169,9 +279,11 @@ decisions, and integration guide.
|
|||||||
```bash
|
```bash
|
||||||
# Recommended one-liner (see Installation section above)
|
# Recommended one-liner (see Installation section above)
|
||||||
make dev-install
|
make dev-install
|
||||||
|
pip install -e ~/llm-connect # optional, for live inference
|
||||||
|
|
||||||
pytest tests/ -q
|
pytest tests/ -q # offline mocks only; no API keys
|
||||||
cya "..." # manual verification
|
pytest -m llm_live # manual live check (requires OPENROUTER_API_KEY)
|
||||||
|
cya --offline "..." # manual verification without network
|
||||||
make version # show current dev version
|
make version # show current dev version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
25
SCOPE.md
25
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:
|
||||||
|
|
||||||
@@ -14,19 +14,21 @@ Four implementation slices have been delivered:
|
|||||||
- **CYA-WP-0002 (Memory Integration)**: Real user-controlled, persisting memory (scoped JSON) behind explicit ports, wired into context and safety.
|
- **CYA-WP-0002 (Memory Integration)**: Real user-controlled, persisting memory (scoped JSON) behind explicit ports, wired into context and safety.
|
||||||
- **CYA-WP-0003 (Contextual Activation & Retrospection)**: Directory/project-bound automatic memory activation, `cya retrospect` guided reflection sessions, retrospection outcomes feeding future behavior (continuous user-driven optimization loop).
|
- **CYA-WP-0003 (Contextual Activation & Retrospection)**: Directory/project-bound automatic memory activation, `cya retrospect` guided reflection sessions, retrospection outcomes feeding future behavior (continuous user-driven optimization loop).
|
||||||
- **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 a minimal but fully working **Profile 1** (Reflexion-style verbal reflections/lessons) spike: `remember_reflection()` + `KIND_REFLECTION`, optional "capture verbal lesson" step inside `cya retrospect`, preferential activation when reflections are present, and surfacing in responses / `--explain-context`. The sister-repo optimization suggestions document for phase-memory was also finalized. See the workplan, MemoryVision.md, and `docs/phase-memory-optimization-suggestions.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.
|
||||||
- Bounded, transparent local context collection.
|
- Bounded, transparent local context collection.
|
||||||
- Genuine rule-based (memory-aware) risk classification with mandatory confirmation.
|
- Genuine rule-based (memory-aware) risk classification with mandatory confirmation.
|
||||||
- Stable `LLMAdapter` Protocol.
|
- Stable `LLMAdapter` Protocol with `LLMConnectAdapter` behind the same factory when configured.
|
||||||
- Real, user-controlled, contextually activated memory (Profile 0: directory/project scoped local JSON with kinds, activation_context, provenance, and retrospection outcomes as higher-order memory).
|
- Real, user-controlled, contextually activated memory (Profile 0: directory/project scoped local JSON with kinds, activation_context, provenance, and retrospection outcomes as higher-order memory).
|
||||||
- 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, now with optional verbal lesson capture (first delivered Profile 1 self-improvement behavior).
|
- `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
|
||||||
|
|
||||||
@@ -77,10 +80,10 @@ See the individual workplans for detailed scope per slice.
|
|||||||
## Explicitly Out of Scope (Current and Near-Term)
|
## Explicitly Out of Scope (Current and Near-Term)
|
||||||
|
|
||||||
- Full deep integration with the complete `phase-memory` profile/planner/graph system (current implementation uses a deliberate, user-visible local JSON store with contextual activation; deeper integration is planned future work per MemoryVision.md).
|
- Full deep integration with the complete `phase-memory` profile/planner/graph system (current implementation uses a deliberate, user-visible local JSON store with contextual activation; deeper integration is planned future work per MemoryVision.md).
|
||||||
- Real `llm-connect` client implementation (only the stable `LLMAdapter` Protocol contract + FakeLLMAdapter exists).
|
- 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-0004 (Dev-Head Install & Release Packaging).**
|
**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.
|
||||||
|
|
||||||
|
|||||||
20
docs/CYA-WP-0006-profile-1-gap-checklist.md
Normal file
20
docs/CYA-WP-0006-profile-1-gap-checklist.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# CYA-WP-0006 — Profile 1 Gap Checklist
|
||||||
|
|
||||||
|
Audit of shipped Profile 1 spike (CYA-WP-0005-T05) vs MemoryVision Profile 1 acceptance criteria.
|
||||||
|
Gates implementation tasks T02–T05.
|
||||||
|
|
||||||
|
| Gap | Spike state | Target (production) | Task |
|
||||||
|
|-----|-------------|---------------------|------|
|
||||||
|
| Capture UX | Single optional prompt; no preview | Guided 1–3 prompts (well / remember / avoid); preview + confirm; skip leaves no empty records | T02 |
|
||||||
|
| Provenance | Plain key/value only | Session date + scope in reflection provenance | T02 |
|
||||||
|
| Review / export | Manual JSON inspection | `cya memory reflections` lists/exports by scope | T03 |
|
||||||
|
| Compaction | None | Near-duplicate detection; explicit merge/replace (no silent delete) | T03 |
|
||||||
|
| Activation boost | Kind filter only | Reflections sorted ahead when recalled | T04 |
|
||||||
|
| Explain surfacing | Item count + sample keys | Reflection count + truncated lesson text + provenance | T04 |
|
||||||
|
| Response surfacing | Two reflections, 60-char truncate | Budget-capped "reflections influenced this" line | T04 |
|
||||||
|
| Observability | `by_kind` in export | Reflection counts by scope in export | T05 |
|
||||||
|
| Safety regression | Preference "never" tested | Reflections cannot downgrade destructive confirmation | T05 |
|
||||||
|
| Tests | Single spike test | Paths for capture, compaction, surfacing, safety | T05 |
|
||||||
|
| Docs | "spike" wording | README example session; MemoryVision "production" | T06 |
|
||||||
|
|
||||||
|
**Profile 0 invariants (must not regress):** user-controlled JSON, full provenance, memory adds caution only.
|
||||||
16
docs/cya-config.example.toml
Normal file
16
docs/cya-config.example.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Example ~/.config/cya/config.toml — placeholders only; do not commit secrets.
|
||||||
|
|
||||||
|
[llm]
|
||||||
|
adapter = "connect"
|
||||||
|
backend = "openrouter"
|
||||||
|
model = "anthropic/claude-sonnet-4"
|
||||||
|
temperature = 0.3
|
||||||
|
max_tokens = 2000
|
||||||
|
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
|
||||||
|
```
|
||||||
100
docs/llm-connect-integration.md
Normal file
100
docs/llm-connect-integration.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# llm-connect Integration (CYA-WP-0008)
|
||||||
|
|
||||||
|
## Mapping: cya ↔ llm-connect
|
||||||
|
|
||||||
|
| cya (`AssistanceRequest`) | llm-connect |
|
||||||
|
|---------------------------|-------------|
|
||||||
|
| `user_request` | User message body (after context framing) |
|
||||||
|
| `context` (envelope + memory + `session_turns`) | Serialized into the prompt via `cya.llm.prompt.build_assistance_prompt` |
|
||||||
|
| `hints` (`model`, `temperature`, `max_tokens`) | `RunConfig` fields for `execute_prompt` |
|
||||||
|
| `AssistanceResponse.suggestion` | `LLMResponse.content` |
|
||||||
|
| `AssistanceResponse.metadata` | `LLMResponse.model`, `usage`, `finish_reason` |
|
||||||
|
|
||||||
|
llm-connect owns provider clients (`create_adapter`), API key resolution, retries, and
|
||||||
|
token usage. `cya` never imports vendor SDKs directly.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
User config: `~/.config/cya/config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[llm]
|
||||||
|
adapter = "connect" # "connect" | "fake" (default: fake when absent)
|
||||||
|
backend = "openrouter" # openrouter | openai | gemini | claude-code | mock
|
||||||
|
model = "anthropic/claude-sonnet-4"
|
||||||
|
temperature = 0.3
|
||||||
|
max_tokens = 2000
|
||||||
|
api_key_env = "OPENROUTER_API_KEY" # optional override
|
||||||
|
# system_prompt = "..." # optional; uses cya default when omitted
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional project override: `.cya.toml` (same `[llm]` section; merged over user config).
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `CYA_LLM_ADAPTER` | `connect` or `fake` |
|
||||||
|
| `CYA_LLM_BACKEND` / `CYA_LLM_PROVIDER` | Provider name |
|
||||||
|
| `CYA_LLM_MODEL` | Model id |
|
||||||
|
|
||||||
|
CLI: `cya --offline "..."` forces `FakeLLMAdapter`.
|
||||||
|
|
||||||
|
## Session context budget (multi-turn / `cya shell`)
|
||||||
|
|
||||||
|
Recent turns are passed in `AssistanceRequest.context["session_turns"]` as
|
||||||
|
`{"user": "...", "assistant": "..."}` records.
|
||||||
|
|
||||||
|
Bounds (see `cya.config`):
|
||||||
|
|
||||||
|
- **Max turns:** 10
|
||||||
|
- **Max characters:** 4000 (total across included turns)
|
||||||
|
|
||||||
|
Older or oversized history is dropped from the prompt automatically.
|
||||||
|
|
||||||
|
## Credential routing
|
||||||
|
|
||||||
|
Do **not** commit API keys. Before requesting secrets, route custody:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "OpenRouter API key" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical ownership:
|
||||||
|
|
||||||
|
| Need | Owner | ops-warden executes? |
|
||||||
|
|------|-------|----------------------|
|
||||||
|
| `OPENROUTER_API_KEY` | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| `OPENAI_API_KEY` | OpenBao | No — route only |
|
||||||
|
| `GEMINI_API_KEY` | OpenBao | No — route only |
|
||||||
|
|
||||||
|
llm-connect resolves keys via `resolve_api_key()` (explicit arg → env var → project key file).
|
||||||
|
|
||||||
|
## Adapter selection
|
||||||
|
|
||||||
|
`cya.llm.factory.get_adapter()` is the single factory for one-shot and shell paths:
|
||||||
|
|
||||||
|
1. `--offline` or `CYA_LLM_ADAPTER=fake` → `FakeLLMAdapter`
|
||||||
|
2. `adapter = "connect"` in config/env → `LLMConnectAdapter` (graceful degrade on failure)
|
||||||
|
3. Otherwise → `FakeLLMAdapter` (current default)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-install
|
||||||
|
pip install -e ~/llm-connect # sibling checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional extra group (placeholder for packaging): `pip install -e ".[llm]"`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Default CI: `make test` — mocks llm-connect; no network.
|
||||||
|
- Manual live check: `pytest -m llm_live` (requires configured API key).
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- Structured JSON output schema not enforced yet (free-form model text).
|
||||||
|
- `claude-code` backend does not require an API key; other backends do.
|
||||||
|
- Per-directory `.cya.toml` overrides user config but does not yet mirror llm-connect's full 7-layer resolution.
|
||||||
@@ -15,6 +15,7 @@ authors = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typer[standard]>=0.12.0",
|
"typer[standard]>=0.12.0",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
|
"tomli>=2.0.0; python_version<'3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -26,6 +27,9 @@ dev = [
|
|||||||
test = [
|
test = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
]
|
]
|
||||||
|
llm = [
|
||||||
|
# Install llm-connect from a sibling checkout, e.g. pip install -e ~/llm-connect
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cya = "cya.cli.main:run"
|
cya = "cya.cli.main:run"
|
||||||
@@ -57,6 +61,7 @@ python_functions = ["test_*"]
|
|||||||
addopts = "-q --tb=short"
|
addopts = "-q --tb=short"
|
||||||
markers = [
|
markers = [
|
||||||
"safety: core safety and risk classifier invariants (always run)",
|
"safety: core safety and risk classifier invariants (always run)",
|
||||||
|
"llm_live: live llm-connect inference (requires API key; manual runs only)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
|
|||||||
120
registry/capabilities/capability.agents.cli-assistant.md
Normal file
120
registry/capabilities/capability.agents.cli-assistant.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
id: capability.agents.cli-assistant
|
||||||
|
name: Console-Native LLM Assistant (cya)
|
||||||
|
summary: Console-native, backend-agnostic assistant CLI that expresses user intent in natural language
|
||||||
|
and returns safe, explainable, context-aware help.
|
||||||
|
owner: can-you-assist
|
||||||
|
status: draft
|
||||||
|
domain: agents
|
||||||
|
tags:
|
||||||
|
- cli
|
||||||
|
- assistant
|
||||||
|
- llm
|
||||||
|
- safety
|
||||||
|
maturity:
|
||||||
|
discovery:
|
||||||
|
current: D2
|
||||||
|
target: D4
|
||||||
|
confidence: medium
|
||||||
|
rationale: README documents the MVP slice (CYA-WP-0001), the LLMAdapter seam, and the rule-based risk-classification
|
||||||
|
safety layer; no separate INTENT/SCOPE file found.
|
||||||
|
availability:
|
||||||
|
current: A2
|
||||||
|
target: A3
|
||||||
|
confidence: medium
|
||||||
|
rationale: Installable today via `pip install -e .`; ships a working CLI (`cya "..."`, `--explain-context`)
|
||||||
|
with a FakeLLMAdapter default and optional real llm-connect backend.
|
||||||
|
external_evidence:
|
||||||
|
completeness:
|
||||||
|
level: C1
|
||||||
|
confidence: low
|
||||||
|
basis: scope_vs_intent_and_consumer_expectations
|
||||||
|
satisfied_expectations:
|
||||||
|
- natural-language CLI entry point
|
||||||
|
- context-explain mode
|
||||||
|
- mandatory confirmation for destructive/privileged/mass-edit/network actions
|
||||||
|
broken_expectations: []
|
||||||
|
out_of_scope_expectations: []
|
||||||
|
reliability:
|
||||||
|
level: R0
|
||||||
|
confidence: low
|
||||||
|
basis: consumer_quality_signals
|
||||||
|
known_reliability_risks:
|
||||||
|
- MVP slice only; real llm-connect backend optional/not always configured
|
||||||
|
discovery:
|
||||||
|
intent: Let users express intent in natural language from the terminal and receive safe, explainable,
|
||||||
|
context-aware help, without owning LLM inference or memory storage itself.
|
||||||
|
includes:
|
||||||
|
- CLI orchestration and UX
|
||||||
|
- rule-based risk classification and confirmation gating
|
||||||
|
- LLMAdapter seam to llm-connect
|
||||||
|
excludes:
|
||||||
|
- LLM inference (owned by llm-connect)
|
||||||
|
- memory storage (owned by phase-memory)
|
||||||
|
assumptions: []
|
||||||
|
use_cases: []
|
||||||
|
research_memos: []
|
||||||
|
availability:
|
||||||
|
current_level: A2
|
||||||
|
target_level: A3
|
||||||
|
current_artifacts:
|
||||||
|
- '`cya` CLI package, pip-installable'
|
||||||
|
target_artifacts: []
|
||||||
|
consumption_modes:
|
||||||
|
- cli
|
||||||
|
relations:
|
||||||
|
depends_on: []
|
||||||
|
supports: []
|
||||||
|
related_to: []
|
||||||
|
evidence:
|
||||||
|
documentation:
|
||||||
|
- README.md
|
||||||
|
tests:
|
||||||
|
- tests/
|
||||||
|
consumer_feedback: []
|
||||||
|
bug_reports: []
|
||||||
|
incidents: []
|
||||||
|
consumer_guidance:
|
||||||
|
recommended_for:
|
||||||
|
- shell-based workflows wanting a safety-gated natural-language assistant
|
||||||
|
not_recommended_for:
|
||||||
|
- needs beyond the first MVP slice (CYA-WP-0001)
|
||||||
|
known_limitations:
|
||||||
|
- first narrow MVP slice; FakeLLMAdapter is the default, real backend is optional
|
||||||
|
promotion_history: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Console-Native LLM Assistant (cya)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`cya` is the CLI surface for the capabilities domain: it owns orchestration, UX, and a safety layer, and talks to `llm-connect` only through a stable `LLMAdapter` boundary, keeping memory under `phase-memory`.
|
||||||
|
|
||||||
|
## Assessment notes
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
README documents the MVP slice (CYA-WP-0001), the LLMAdapter seam, and the rule-based risk-classification safety layer; no separate INTENT/SCOPE file found.
|
||||||
|
|
||||||
|
### Availability
|
||||||
|
|
||||||
|
Installable today via `pip install -e .`; ships a working CLI (`cya "..."`, `--explain-context`) with a FakeLLMAdapter default and optional real llm-connect backend.
|
||||||
|
|
||||||
|
### Completeness
|
||||||
|
|
||||||
|
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
|
||||||
|
(reuse-surface). No external consumer feedback exists yet; levels reflect
|
||||||
|
scope-vs-intent documentation quality, not internal code quality.
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
No production consumer telemetry exists yet; reliability level is
|
||||||
|
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
|
||||||
|
|
||||||
|
## Promotion checklist
|
||||||
|
|
||||||
|
- [x] ID follows `capability.<domain>.<name>` pattern
|
||||||
|
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
|
||||||
|
- [x] `external_evidence` is populated separately from `maturity`
|
||||||
|
- [ ] Relations reference valid capability IDs (none yet)
|
||||||
|
- [x] Index entry added in `registry/indexes/capabilities.yaml`
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updated: '2026-06-16'
|
updated: '2026-07-06'
|
||||||
domain: helix_forge
|
domain: helix_forge
|
||||||
capabilities: []
|
capabilities:
|
||||||
|
- id: capability.agents.cli-assistant
|
||||||
|
name: Console-Native LLM Assistant (cya)
|
||||||
|
summary: Console-native, backend-agnostic assistant CLI that expresses user intent in natural language
|
||||||
|
and returns safe, explainable, context-aware help.
|
||||||
|
vector: D2 / A2 / C1 / R0
|
||||||
|
domain: agents
|
||||||
|
status: draft
|
||||||
|
owner: can-you-assist
|
||||||
|
path: registry/capabilities/capability.agents.cli-assistant.md
|
||||||
|
tags:
|
||||||
|
- cli
|
||||||
|
- assistant
|
||||||
|
- llm
|
||||||
|
- safety
|
||||||
|
consumption_modes:
|
||||||
|
- cli
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ This module will evolve in T06 (orchestrator) but the surface contract stays sta
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -65,6 +66,11 @@ def main(
|
|||||||
"-n",
|
"-n",
|
||||||
help="Preview mode — do not perform any actions (stub in T01).",
|
help="Preview mode — do not perform any actions (stub in T01).",
|
||||||
),
|
),
|
||||||
|
offline: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--offline",
|
||||||
|
help="Use the deterministic FakeLLMAdapter (no llm-connect / no API keys).",
|
||||||
|
),
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--version",
|
"--version",
|
||||||
@@ -105,9 +111,111 @@ def main(
|
|||||||
request,
|
request,
|
||||||
explain_context=explain_context,
|
explain_context=explain_context,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
|
offline=offline,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("shell")
|
||||||
|
def shell_command(
|
||||||
|
with_history: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--with-history",
|
||||||
|
help="Opt in to capped, redacted shell-history context for this session.",
|
||||||
|
),
|
||||||
|
history_limit: int | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--history-limit",
|
||||||
|
help="Maximum history lines to include, capped at the configured safe limit.",
|
||||||
|
),
|
||||||
|
offline: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--offline",
|
||||||
|
help="Use the deterministic FakeLLMAdapter for this shell session.",
|
||||||
|
),
|
||||||
|
hub_url: str = typer.Option(
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
"--hub-url",
|
||||||
|
help="State Hub base URL for read-only session orientation.",
|
||||||
|
),
|
||||||
|
no_hub: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--no-hub",
|
||||||
|
help="Skip State Hub HTTP orientation; still reads .custodian-brief.md if present.",
|
||||||
|
),
|
||||||
|
offer_session_lessons: bool = typer.Option(
|
||||||
|
True,
|
||||||
|
"--session-lessons/--no-session-lessons",
|
||||||
|
help="Offer end-of-session Profile 1 reflection and session_turn memory capture.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Start an interactive, stateful cya shell session."""
|
||||||
|
from cya.shell_session import run_shell
|
||||||
|
|
||||||
|
run_shell(
|
||||||
|
with_history=with_history,
|
||||||
|
history_limit=history_limit,
|
||||||
|
offline=offline,
|
||||||
|
hub_url=hub_url,
|
||||||
|
no_hub=no_hub,
|
||||||
|
offer_end_learning=offer_session_lessons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
memory_app = typer.Typer(
|
||||||
|
help="Inspect and manage user-controlled memory (Profile 0 + Profile 1).",
|
||||||
|
rich_markup_mode="rich",
|
||||||
|
)
|
||||||
|
app.add_typer(memory_app, name="memory")
|
||||||
|
|
||||||
|
|
||||||
|
@memory_app.command("reflections")
|
||||||
|
def memory_reflections(
|
||||||
|
scope: str = typer.Option(
|
||||||
|
".",
|
||||||
|
"--scope",
|
||||||
|
"-s",
|
||||||
|
help="Directory or scope to list reflections for.",
|
||||||
|
),
|
||||||
|
export_json: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--json",
|
||||||
|
help="Export reflections as JSON (same as export_memory with kinds filter).",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""List or export verbal reflections for a scope (Profile 1)."""
|
||||||
|
from cya.memory import export_memory, KIND_REFLECTION
|
||||||
|
from cya.memory.reflections import list_reflections, reflection_export_stats
|
||||||
|
|
||||||
|
if export_json:
|
||||||
|
data = export_memory(scope, kinds=[KIND_REFLECTION])
|
||||||
|
console.print(json.dumps(data, indent=2, default=str))
|
||||||
|
return
|
||||||
|
|
||||||
|
items = list_reflections(scope)
|
||||||
|
stats = reflection_export_stats(scope)
|
||||||
|
if not items:
|
||||||
|
console.print(f"[yellow]No reflections in scope {scope!r}.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for item in items:
|
||||||
|
prov = item.get("provenance") or {}
|
||||||
|
date = prov.get("session_date", "?")
|
||||||
|
lines.append(f"• [bold]{item.get('key')}[/bold] ({date}): {item.get('value')}")
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
"\n".join(lines),
|
||||||
|
title=f"Reflections in {scope} ({stats['reflection_count']} total)",
|
||||||
|
border_style="cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if stats.get("reflection_counts_by_scope"):
|
||||||
|
console.print(
|
||||||
|
f"[dim]By scope: {stats['reflection_counts_by_scope']}[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def retrospect(
|
def retrospect(
|
||||||
scope: str = typer.Option(
|
scope: str = typer.Option(
|
||||||
@@ -133,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:
|
||||||
@@ -144,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()
|
||||||
|
|||||||
263
src/cya/config.py
Normal file
263
src/cya/config.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""User configuration for cya (CYA-WP-0008-T03).
|
||||||
|
|
||||||
|
Reads ``~/.config/cya/config.toml`` and optional project ``.cya.toml``.
|
||||||
|
Environment variables override file values where noted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_USER_CONFIG = Path.home() / ".config" / "cya" / "config.toml"
|
||||||
|
_PROJECT_CONFIG_NAME = ".cya.toml"
|
||||||
|
|
||||||
|
# Session context bounds (CYA-WP-0008-T04) — documented in docs/llm-connect-integration.md
|
||||||
|
MAX_SESSION_TURNS = 10
|
||||||
|
MAX_SESSION_CHARS = 4000
|
||||||
|
DEFAULT_SHELL_HISTORY_LINES = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _load_toml(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
return tomllib.loads(path.read_text())
|
||||||
|
import tomli
|
||||||
|
|
||||||
|
return tomli.loads(path.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project_config(start: Path | None = None) -> Path | None:
|
||||||
|
current = (start or Path.cwd()).resolve()
|
||||||
|
for directory in [current, *current.parents]:
|
||||||
|
candidate = directory / _PROJECT_CONFIG_NAME
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_llm_sections(*sources: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
merged: dict[str, Any] = {}
|
||||||
|
for source in sources:
|
||||||
|
section = source.get("llm")
|
||||||
|
if isinstance(section, dict):
|
||||||
|
merged.update(section)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_shell_history_sections(*sources: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
merged: dict[str, Any] = {}
|
||||||
|
for source in sources:
|
||||||
|
section = source.get("shell_history")
|
||||||
|
if isinstance(section, dict):
|
||||||
|
merged.update(section)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMSettings:
|
||||||
|
"""Resolved LLM adapter settings."""
|
||||||
|
|
||||||
|
adapter: str = "fake" # "fake" | "connect"
|
||||||
|
backend: str = "openrouter"
|
||||||
|
model: str | None = None
|
||||||
|
temperature: float = 0.3
|
||||||
|
max_tokens: int = 2000
|
||||||
|
api_key_env: str | None = None
|
||||||
|
system_prompt: str | None = None
|
||||||
|
configured: bool = False
|
||||||
|
source: str = "default"
|
||||||
|
|
||||||
|
def to_hints(self) -> dict[str, Any]:
|
||||||
|
hints: dict[str, Any] = {
|
||||||
|
"backend": self.backend,
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"max_tokens": self.max_tokens,
|
||||||
|
}
|
||||||
|
if self.model:
|
||||||
|
hints["model"] = self.model
|
||||||
|
if self.api_key_env:
|
||||||
|
hints["api_key_env"] = self.api_key_env
|
||||||
|
return hints
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShellHistorySettings:
|
||||||
|
"""Resolved shell-history collection settings.
|
||||||
|
|
||||||
|
History is intentionally off unless enabled by the CLI or config. The line
|
||||||
|
limit is capped so a session cannot accidentally ingest an unbounded shell
|
||||||
|
history file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
limit: int = DEFAULT_SHELL_HISTORY_LINES
|
||||||
|
histfile: str | None = None
|
||||||
|
source: str = "default"
|
||||||
|
requested_limit: int | None = None
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_float(value: Any, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: Any, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text in {"1", "true", "yes", "y", "on"}:
|
||||||
|
return True
|
||||||
|
if text in {"0", "false", "no", "n", "off"}:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def load_llm_settings(*, offline: bool = False) -> LLMSettings:
|
||||||
|
"""Resolve LLM settings from env, user config, and project config."""
|
||||||
|
if offline:
|
||||||
|
return LLMSettings(adapter="fake", configured=False, source="--offline")
|
||||||
|
|
||||||
|
env_adapter = os.environ.get("CYA_LLM_ADAPTER", "").strip().lower()
|
||||||
|
if env_adapter in ("fake", "connect"):
|
||||||
|
base = LLMSettings(adapter=env_adapter, configured=env_adapter == "connect", source="CYA_LLM_ADAPTER")
|
||||||
|
else:
|
||||||
|
base = LLMSettings()
|
||||||
|
|
||||||
|
user_data = _load_toml(_USER_CONFIG)
|
||||||
|
project_path = _find_project_config()
|
||||||
|
project_data = _load_toml(project_path) if project_path else {}
|
||||||
|
merged = _merge_llm_sections(user_data, project_data)
|
||||||
|
|
||||||
|
if merged:
|
||||||
|
file_adapter = str(merged.get("adapter", "")).strip().lower()
|
||||||
|
if file_adapter in ("fake", "connect") and not env_adapter:
|
||||||
|
base.adapter = file_adapter
|
||||||
|
base.configured = file_adapter == "connect"
|
||||||
|
base.source = str(project_path or _USER_CONFIG)
|
||||||
|
|
||||||
|
backend = merged.get("backend") or merged.get("provider")
|
||||||
|
if backend:
|
||||||
|
base.backend = str(backend)
|
||||||
|
if not env_adapter and file_adapter != "fake":
|
||||||
|
base.adapter = "connect"
|
||||||
|
base.configured = True
|
||||||
|
base.source = str(project_path or _USER_CONFIG)
|
||||||
|
|
||||||
|
if merged.get("model"):
|
||||||
|
base.model = str(merged["model"])
|
||||||
|
base.temperature = _coerce_float(merged.get("temperature"), base.temperature)
|
||||||
|
base.max_tokens = _coerce_int(merged.get("max_tokens"), base.max_tokens)
|
||||||
|
if merged.get("api_key_env"):
|
||||||
|
base.api_key_env = str(merged["api_key_env"])
|
||||||
|
if merged.get("system_prompt"):
|
||||||
|
base.system_prompt = str(merged["system_prompt"])
|
||||||
|
|
||||||
|
env_backend = os.environ.get("CYA_LLM_BACKEND") or os.environ.get("CYA_LLM_PROVIDER")
|
||||||
|
if env_backend:
|
||||||
|
base.backend = env_backend.strip()
|
||||||
|
if base.adapter != "fake":
|
||||||
|
base.adapter = "connect"
|
||||||
|
base.configured = True
|
||||||
|
base.source = "CYA_LLM_BACKEND"
|
||||||
|
|
||||||
|
env_model = os.environ.get("CYA_LLM_MODEL")
|
||||||
|
if env_model:
|
||||||
|
base.model = env_model.strip()
|
||||||
|
if base.adapter != "fake":
|
||||||
|
base.adapter = "connect"
|
||||||
|
base.configured = True
|
||||||
|
base.source = "CYA_LLM_MODEL"
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def load_shell_history_settings(
|
||||||
|
*,
|
||||||
|
with_history: bool = False,
|
||||||
|
history_limit: int | None = None,
|
||||||
|
) -> ShellHistorySettings:
|
||||||
|
"""Resolve optional shell-history collection settings.
|
||||||
|
|
||||||
|
The default is always disabled. Config can enable history with:
|
||||||
|
|
||||||
|
``[shell_history] enabled = true``
|
||||||
|
|
||||||
|
The CLI ``--with-history`` flag also enables it for just that shell session.
|
||||||
|
"""
|
||||||
|
user_data = _load_toml(_USER_CONFIG)
|
||||||
|
project_path = _find_project_config()
|
||||||
|
project_data = _load_toml(project_path) if project_path else {}
|
||||||
|
merged = _merge_shell_history_sections(user_data, project_data)
|
||||||
|
|
||||||
|
settings = ShellHistorySettings()
|
||||||
|
if merged:
|
||||||
|
source = str(project_path or _USER_CONFIG)
|
||||||
|
settings.enabled = _coerce_bool(merged.get("enabled"), False)
|
||||||
|
settings.limit = _coerce_int(merged.get("limit"), settings.limit)
|
||||||
|
settings.histfile = str(merged["histfile"]) if merged.get("histfile") else None
|
||||||
|
settings.source = source
|
||||||
|
|
||||||
|
if with_history:
|
||||||
|
settings.enabled = True
|
||||||
|
settings.source = "--with-history"
|
||||||
|
|
||||||
|
if history_limit is not None:
|
||||||
|
settings.requested_limit = history_limit
|
||||||
|
settings.limit = _coerce_int(history_limit, settings.limit)
|
||||||
|
if with_history:
|
||||||
|
settings.source = "--with-history"
|
||||||
|
|
||||||
|
if settings.limit < 0:
|
||||||
|
settings.notes.append("Negative history limit was treated as 0.")
|
||||||
|
settings.limit = 0
|
||||||
|
|
||||||
|
if settings.limit > DEFAULT_SHELL_HISTORY_LINES:
|
||||||
|
settings.notes.append(
|
||||||
|
f"History limit capped at {DEFAULT_SHELL_HISTORY_LINES} lines."
|
||||||
|
)
|
||||||
|
settings.limit = DEFAULT_SHELL_HISTORY_LINES
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def bound_session_turns(
|
||||||
|
turns: list[dict[str, str]] | None,
|
||||||
|
*,
|
||||||
|
max_turns: int = MAX_SESSION_TURNS,
|
||||||
|
max_chars: int = MAX_SESSION_CHARS,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Trim session history to a bounded token/line budget for the adapter."""
|
||||||
|
if not turns:
|
||||||
|
return []
|
||||||
|
|
||||||
|
recent = turns[-max_turns:]
|
||||||
|
bounded: list[dict[str, str]] = []
|
||||||
|
used = 0
|
||||||
|
for turn in recent:
|
||||||
|
user = turn.get("user", "")
|
||||||
|
assistant = turn.get("assistant", "")
|
||||||
|
chunk_len = len(user) + len(assistant)
|
||||||
|
if used + chunk_len > max_chars and bounded:
|
||||||
|
break
|
||||||
|
bounded.append({"user": user, "assistant": assistant})
|
||||||
|
used += chunk_len
|
||||||
|
return bounded
|
||||||
@@ -17,11 +17,15 @@ from .adapter import (
|
|||||||
LLMAdapter,
|
LLMAdapter,
|
||||||
FakeLLMAdapter,
|
FakeLLMAdapter,
|
||||||
)
|
)
|
||||||
|
from .connect_adapter import LLMConnectAdapter
|
||||||
|
from .factory import get_adapter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AssistanceRequest",
|
"AssistanceRequest",
|
||||||
"AssistanceResponse",
|
"AssistanceResponse",
|
||||||
"LLMAdapter",
|
"LLMAdapter",
|
||||||
"FakeLLMAdapter",
|
"FakeLLMAdapter",
|
||||||
|
"LLMConnectAdapter",
|
||||||
|
"get_adapter",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
138
src/cya/llm/connect_adapter.py
Normal file
138
src/cya/llm/connect_adapter.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""llm-connect-backed adapter (CYA-WP-0008-T02)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cya.config import LLMSettings
|
||||||
|
from cya.llm.adapter import AssistanceRequest, AssistanceResponse
|
||||||
|
from cya.llm.prompt import build_assistance_prompt
|
||||||
|
|
||||||
|
_PROVIDER_ENV_KEYS: dict[str, str] = {
|
||||||
|
"openrouter": "OPENROUTER_API_KEY",
|
||||||
|
"openai": "OPENAI_API_KEY",
|
||||||
|
"gemini": "GEMINI_API_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConnectAdapter:
|
||||||
|
"""Delegates to llm-connect while satisfying cya's LLMAdapter protocol."""
|
||||||
|
|
||||||
|
def __init__(self, settings: LLMSettings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._client: Any | None = None
|
||||||
|
self._init_error: str | None = None
|
||||||
|
self._ensure_client()
|
||||||
|
|
||||||
|
def _ensure_client(self) -> None:
|
||||||
|
try:
|
||||||
|
from llm_connect import create_adapter
|
||||||
|
from llm_connect.config import resolve_api_key
|
||||||
|
except ImportError:
|
||||||
|
self._init_error = (
|
||||||
|
"llm-connect is not installed. Install with:\n"
|
||||||
|
" pip install -e ~/llm-connect\n"
|
||||||
|
"or: pip install -e \".[llm]\" after adding llm-connect to your environment."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
env_var = self._settings.api_key_env or _PROVIDER_ENV_KEYS.get(
|
||||||
|
self._settings.backend, "OPENROUTER_API_KEY"
|
||||||
|
)
|
||||||
|
api_key = resolve_api_key(env_var=env_var)
|
||||||
|
if self._settings.backend in ("openrouter", "openai", "gemini") and not api_key:
|
||||||
|
self._init_error = (
|
||||||
|
f"No API key found for backend {self._settings.backend!r} "
|
||||||
|
f"(checked env {env_var!r}).\n"
|
||||||
|
"Route credential custody via warden before requesting secrets:\n"
|
||||||
|
" warden route find \"OpenRouter API key\" --json\n"
|
||||||
|
"Then export the key into your environment — never commit it to the repo."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client = create_adapter(
|
||||||
|
provider=self._settings.backend,
|
||||||
|
model=self._settings.model,
|
||||||
|
api_key=api_key,
|
||||||
|
system_prompt=self._settings.system_prompt,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface config errors to the user
|
||||||
|
self._init_error = f"Failed to initialize llm-connect adapter: {exc}"
|
||||||
|
|
||||||
|
def complete(self, request: AssistanceRequest) -> AssistanceResponse:
|
||||||
|
if self._init_error or self._client is None:
|
||||||
|
return self._degraded_response(self._init_error or "llm-connect client unavailable.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from llm_connect.models import RunConfig
|
||||||
|
except ImportError:
|
||||||
|
return self._degraded_response(self._init_error or "llm-connect not installed.")
|
||||||
|
|
||||||
|
system, user_prompt = build_assistance_prompt(
|
||||||
|
request,
|
||||||
|
system_prompt=self._settings.system_prompt,
|
||||||
|
)
|
||||||
|
hints = {**self._settings.to_hints(), **request.hints}
|
||||||
|
run_config = RunConfig(
|
||||||
|
model_name=hints.get("model") or self._settings.model or "anthropic/claude-sonnet-4",
|
||||||
|
temperature=float(hints.get("temperature", self._settings.temperature)),
|
||||||
|
max_tokens=int(hints.get("max_tokens", self._settings.max_tokens)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-create adapter when per-request system prompt differs (llm-connect stores it at init).
|
||||||
|
client = self._client
|
||||||
|
if system and not self._settings.system_prompt:
|
||||||
|
from llm_connect import create_adapter
|
||||||
|
from llm_connect.config import resolve_api_key
|
||||||
|
|
||||||
|
env_var = self._settings.api_key_env or _PROVIDER_ENV_KEYS.get(
|
||||||
|
self._settings.backend, "OPENROUTER_API_KEY"
|
||||||
|
)
|
||||||
|
api_key = resolve_api_key(env_var=env_var)
|
||||||
|
client = create_adapter(
|
||||||
|
provider=self._settings.backend,
|
||||||
|
model=self._settings.model,
|
||||||
|
api_key=api_key,
|
||||||
|
system_prompt=system,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
llm_response = client.execute_prompt(user_prompt, run_config)
|
||||||
|
except Exception as exc: # noqa: BLE001 — user-facing degrade path
|
||||||
|
return self._degraded_response(
|
||||||
|
f"llm-connect request failed: {exc}",
|
||||||
|
partial_raw=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
content = (llm_response.content or "").strip()
|
||||||
|
return AssistanceResponse(
|
||||||
|
suggestion=content or "(empty model response)",
|
||||||
|
explanation="Response generated via llm-connect.",
|
||||||
|
rationale="Model inference using configured backend and bounded local context.",
|
||||||
|
risks=[],
|
||||||
|
raw_model_output=content,
|
||||||
|
metadata={
|
||||||
|
"adapter": "LLMConnectAdapter",
|
||||||
|
"backend": self._settings.backend,
|
||||||
|
"model": llm_response.model,
|
||||||
|
"usage": llm_response.usage,
|
||||||
|
"finish_reason": llm_response.finish_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _degraded_response(message: str, *, partial_raw: str | None = None) -> AssistanceResponse:
|
||||||
|
return AssistanceResponse(
|
||||||
|
suggestion=(
|
||||||
|
"cya could not reach a configured LLM backend.\n\n"
|
||||||
|
f"{message}\n\n"
|
||||||
|
"Continuing in offline mode: re-run with `--offline` or configure "
|
||||||
|
"`~/.config/cya/config.toml` (see README)."
|
||||||
|
),
|
||||||
|
explanation="Graceful degradation — no live inference was performed.",
|
||||||
|
rationale="llm-connect unavailable or misconfigured.",
|
||||||
|
risks=["No live model inference"],
|
||||||
|
raw_model_output=partial_raw,
|
||||||
|
metadata={"adapter": "LLMConnectAdapter", "degraded": True},
|
||||||
|
)
|
||||||
18
src/cya/llm/factory.py
Normal file
18
src/cya/llm/factory.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Adapter selection factory (CYA-WP-0008-T04)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cya.config import LLMSettings, load_llm_settings
|
||||||
|
from cya.llm.adapter import FakeLLMAdapter, LLMAdapter
|
||||||
|
from cya.llm.connect_adapter import LLMConnectAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def get_adapter(*, offline: bool = False, settings: LLMSettings | None = None) -> LLMAdapter:
|
||||||
|
"""Return the active LLMAdapter for one-shot and shell code paths."""
|
||||||
|
resolved = settings or load_llm_settings(offline=offline)
|
||||||
|
if resolved.adapter == "connect":
|
||||||
|
return LLMConnectAdapter(resolved)
|
||||||
|
return FakeLLMAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["get_adapter", "load_llm_settings"]
|
||||||
73
src/cya/llm/prompt.py
Normal file
73
src/cya/llm/prompt.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Prompt construction for llm-connect delegation (CYA-WP-0008)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cya.llm.adapter import AssistanceRequest
|
||||||
|
|
||||||
|
_DEFAULT_SYSTEM = """You are cya, a console-native assistant for practical local work from the shell.
|
||||||
|
|
||||||
|
Help the user with command-line tasks: repository inspection, file workflows, command
|
||||||
|
suggestion, command explanation, and local context summarization.
|
||||||
|
|
||||||
|
Be concise and practical. When suggesting shell commands, explain risks briefly.
|
||||||
|
Do not claim to have executed anything — the user runs commands themselves.
|
||||||
|
Reference the provided context when it is relevant."""
|
||||||
|
|
||||||
|
|
||||||
|
def default_system_prompt() -> str:
|
||||||
|
return _DEFAULT_SYSTEM
|
||||||
|
|
||||||
|
|
||||||
|
def build_assistance_prompt(request: AssistanceRequest, *, system_prompt: str | None = None) -> tuple[str, str]:
|
||||||
|
"""Return (system_prompt, user_prompt) for llm-connect execute_prompt."""
|
||||||
|
system = system_prompt or default_system_prompt()
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
context = request.context or {}
|
||||||
|
session_turns = context.get("session_turns")
|
||||||
|
if session_turns:
|
||||||
|
parts.append("## Recent conversation")
|
||||||
|
for turn in session_turns:
|
||||||
|
parts.append(f"User: {turn.get('user', '')}")
|
||||||
|
parts.append(f"Assistant: {turn.get('assistant', '')}")
|
||||||
|
|
||||||
|
envelope = {k: v for k, v in context.items() if k not in ("session_turns", "memory")}
|
||||||
|
if envelope:
|
||||||
|
parts.append("## Local context")
|
||||||
|
parts.append(_summarize_context(envelope))
|
||||||
|
|
||||||
|
memory = context.get("memory")
|
||||||
|
if isinstance(memory, dict) and memory.get("items"):
|
||||||
|
parts.append("## Activated memory")
|
||||||
|
for item in memory["items"][:8]:
|
||||||
|
parts.append(f"- [{item.get('kind', '?')}] {item.get('key', '?')}: {item.get('value', '')}")
|
||||||
|
|
||||||
|
parts.append("## Current request")
|
||||||
|
parts.append(request.user_request.strip())
|
||||||
|
|
||||||
|
return system, "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_context(envelope: dict[str, Any]) -> str:
|
||||||
|
"""Compact, JSON-safe context summary to stay within prompt budget."""
|
||||||
|
summary: dict[str, Any] = {}
|
||||||
|
if envelope.get("cwd"):
|
||||||
|
summary["cwd"] = envelope["cwd"]
|
||||||
|
if envelope.get("git"):
|
||||||
|
git = envelope["git"]
|
||||||
|
summary["git"] = {
|
||||||
|
k: git[k]
|
||||||
|
for k in ("branch", "status_short", "workdir", "is_repo")
|
||||||
|
if k in git
|
||||||
|
}
|
||||||
|
if envelope.get("top_level"):
|
||||||
|
names = [e.get("name") for e in envelope["top_level"][:30] if e.get("name")]
|
||||||
|
summary["top_level"] = names
|
||||||
|
if envelope.get("env"):
|
||||||
|
summary["env"] = envelope["env"]
|
||||||
|
if envelope.get("notes"):
|
||||||
|
summary["notes"] = envelope["notes"][:5]
|
||||||
|
return json.dumps(summary, indent=2, default=str)
|
||||||
@@ -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:
|
||||||
@@ -99,6 +100,7 @@ def remember_preference(
|
|||||||
profile: str | None = None,
|
profile: str | None = None,
|
||||||
ttl: str | None = None,
|
ttl: str | None = None,
|
||||||
kind: str = KIND_PREFERENCE,
|
kind: str = KIND_PREFERENCE,
|
||||||
|
provenance: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Remember a user preference, workflow pattern, retrospection outcome, or goal.
|
"""Remember a user preference, workflow pattern, retrospection outcome, or goal.
|
||||||
|
|
||||||
@@ -118,6 +120,8 @@ def remember_preference(
|
|||||||
"profile": profile,
|
"profile": profile,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
}
|
}
|
||||||
|
if provenance:
|
||||||
|
item["provenance"] = provenance
|
||||||
# avoid exact dups for same key in small stores
|
# avoid exact dups for same key in small stores
|
||||||
items = [i for i in items if i.get("key") != key]
|
items = [i for i in items if i.get("key") != key]
|
||||||
items.append(item)
|
items.append(item)
|
||||||
@@ -166,7 +170,13 @@ def recall_preferences(
|
|||||||
normal.append(item)
|
normal.append(item)
|
||||||
items = boosted + normal
|
items = boosted + normal
|
||||||
|
|
||||||
items = items[-limit:] # most recent first (after boosting)
|
# Profile 1: prefer reflections ahead of other kinds when requested
|
||||||
|
if kinds and KIND_REFLECTION in kinds:
|
||||||
|
reflections = [i for i in items if i.get("kind") == KIND_REFLECTION]
|
||||||
|
others = [i for i in items if i.get("kind") != KIND_REFLECTION]
|
||||||
|
items = (reflections + others)[:limit]
|
||||||
|
else:
|
||||||
|
items = items[-limit:] # most recent (after boosting)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
@@ -222,7 +232,13 @@ def export_memory(scope: str = "cwd", *, profile: str | None = None, kinds: list
|
|||||||
k = item.get("kind", "unknown")
|
k = item.get("kind", "unknown")
|
||||||
by_kind.setdefault(k, []).append(item)
|
by_kind.setdefault(k, []).append(item)
|
||||||
|
|
||||||
return {
|
reflection_by_scope: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
if item.get("kind") == KIND_REFLECTION:
|
||||||
|
s = str(item.get("scope", scope))
|
||||||
|
reflection_by_scope[s] = reflection_by_scope.get(s, 0) + 1
|
||||||
|
|
||||||
|
result = {
|
||||||
"status": "real (T02+0003 local json; activation + retrospection ready)",
|
"status": "real (T02+0003 local json; activation + retrospection ready)",
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
@@ -234,6 +250,10 @@ def export_memory(scope: str = "cwd", *, profile: str | None = None, kinds: list
|
|||||||
"note": "User-controlled. Replace with real phase-memory when available.",
|
"note": "User-controlled. Replace with real phase-memory when available.",
|
||||||
"phases": ["ephemeral", "fluid", "stabilized", "rigid"],
|
"phases": ["ephemeral", "fluid", "stabilized", "rigid"],
|
||||||
}
|
}
|
||||||
|
if reflection_by_scope:
|
||||||
|
result["reflection_counts_by_scope"] = reflection_by_scope
|
||||||
|
result["reflection_count"] = sum(reflection_by_scope.values())
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_warn_not_connected(f"export_memory(scope={scope}, profile={profile}) err={e}")
|
_warn_not_connected(f"export_memory(scope={scope}, profile={profile}) err={e}")
|
||||||
return {
|
return {
|
||||||
@@ -264,13 +284,21 @@ def remember_reflection(
|
|||||||
scope: str = "cwd",
|
scope: str = "cwd",
|
||||||
*,
|
*,
|
||||||
profile: str | None = None,
|
profile: str | None = None,
|
||||||
|
provenance: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Convenience helper for Profile 1 (Reflexion-style verbal self-improvement).
|
"""Convenience helper for Profile 1 (Reflexion-style verbal self-improvement).
|
||||||
|
|
||||||
Stores verbal lessons/reflections with kind="reflection" for preferential
|
Stores verbal lessons/reflections with kind="reflection" for preferential
|
||||||
activation in future turns. Thin wrapper for the T05 minimal spike.
|
activation in future turns.
|
||||||
"""
|
"""
|
||||||
remember_preference(key, value, scope=scope, profile=profile, kind=KIND_REFLECTION)
|
remember_preference(
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
scope=scope,
|
||||||
|
profile=profile,
|
||||||
|
kind=KIND_REFLECTION,
|
||||||
|
provenance=provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -284,5 +312,6 @@ __all__ = [
|
|||||||
"KIND_RETROSPECTION",
|
"KIND_RETROSPECTION",
|
||||||
"KIND_INTERACTION_GOAL",
|
"KIND_INTERACTION_GOAL",
|
||||||
"KIND_REFLECTION",
|
"KIND_REFLECTION",
|
||||||
|
"KIND_SESSION_TURN",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
241
src/cya/memory/reflections.py
Normal file
241
src/cya/memory/reflections.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Profile 1 reflection helpers — capture, compaction, and surfacing.
|
||||||
|
|
||||||
|
Pure, testable logic for verbal reflection management. Interactive CLI flows
|
||||||
|
in orchestrator.py call into these functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cya.memory import (
|
||||||
|
KIND_REFLECTION,
|
||||||
|
_load,
|
||||||
|
_save,
|
||||||
|
export_memory,
|
||||||
|
remember_reflection,
|
||||||
|
)
|
||||||
|
|
||||||
|
REFLECTION_CAPTURE_PROMPTS: tuple[tuple[str, str], ...] = (
|
||||||
|
("went_well", "What went well that you want to remember? (or 'skip')"),
|
||||||
|
("remember", "What should cya remember for next time? (or 'skip')"),
|
||||||
|
("avoid", "What should cya avoid in this scope? (or 'skip')"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_SKIP_VALUES = frozenset({"", "skip", "s", "n", "no"})
|
||||||
|
_MAX_SURFACING_LESSONS = 3
|
||||||
|
_MAX_LESSON_CHARS_EXPLAIN = 80
|
||||||
|
_MAX_LESSON_CHARS_RESPONSE = 60
|
||||||
|
_SIMILARITY_THRESHOLD = 0.85
|
||||||
|
|
||||||
|
|
||||||
|
def is_skip_answer(text: str) -> bool:
|
||||||
|
return not text or text.strip().lower() in _SKIP_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def collect_lessons_from_answers(answers: dict[str, str]) -> list[dict[str, str]]:
|
||||||
|
"""Build lesson records from guided prompt answers; skips empty/skip answers."""
|
||||||
|
lessons: list[dict[str, str]] = []
|
||||||
|
for prompt_key, _label in REFLECTION_CAPTURE_PROMPTS:
|
||||||
|
raw = answers.get(prompt_key, "")
|
||||||
|
if is_skip_answer(raw):
|
||||||
|
continue
|
||||||
|
text = raw.strip()
|
||||||
|
if text:
|
||||||
|
lessons.append({"prompt": prompt_key, "text": text})
|
||||||
|
return lessons
|
||||||
|
|
||||||
|
|
||||||
|
def preview_lessons(lessons: list[dict[str, str]]) -> str:
|
||||||
|
if not lessons:
|
||||||
|
return "(no lessons to save)"
|
||||||
|
lines = []
|
||||||
|
for i, lesson in enumerate(lessons, 1):
|
||||||
|
label = lesson.get("prompt", "lesson").replace("_", " ")
|
||||||
|
lines.append(f"{i}. [{label}] {lesson['text']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def session_provenance(scope: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||||
|
"scope": scope,
|
||||||
|
"source": "cya retrospect",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_reflection_lessons(
|
||||||
|
lessons: list[dict[str, str]],
|
||||||
|
scope: str,
|
||||||
|
*,
|
||||||
|
provenance: dict[str, Any] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Persist confirmed lessons; returns count saved. No-op for empty input."""
|
||||||
|
if not lessons:
|
||||||
|
return 0
|
||||||
|
meta = provenance or session_provenance(scope)
|
||||||
|
saved = 0
|
||||||
|
for lesson in lessons:
|
||||||
|
key = f"reflection_{lesson['prompt']}_{meta['session_date']}"
|
||||||
|
remember_reflection(
|
||||||
|
key,
|
||||||
|
lesson["text"],
|
||||||
|
scope=scope,
|
||||||
|
provenance={**meta, "prompt": lesson["prompt"]},
|
||||||
|
)
|
||||||
|
saved += 1
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_reflection_text(text: str) -> str:
|
||||||
|
collapsed = re.sub(r"\s+", " ", text.strip().lower())
|
||||||
|
return re.sub(r"[^\w\s]", "", collapsed)
|
||||||
|
|
||||||
|
|
||||||
|
def reflection_similarity(a: str, b: str) -> float:
|
||||||
|
na, nb = normalize_reflection_text(a), normalize_reflection_text(b)
|
||||||
|
if not na or not nb:
|
||||||
|
return 0.0
|
||||||
|
if na == nb:
|
||||||
|
return 1.0
|
||||||
|
return SequenceMatcher(None, na, nb).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicate_reflection_groups(
|
||||||
|
scope: str,
|
||||||
|
*,
|
||||||
|
threshold: float = _SIMILARITY_THRESHOLD,
|
||||||
|
) -> list[list[dict[str, Any]]]:
|
||||||
|
"""Return groups of near-duplicate reflection items in the same scope."""
|
||||||
|
items = [i for i in _load(scope) if i.get("kind") == KIND_REFLECTION]
|
||||||
|
groups: list[list[dict[str, Any]]] = []
|
||||||
|
used: set[str] = set()
|
||||||
|
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
key_i = item.get("key", str(i))
|
||||||
|
if key_i in used:
|
||||||
|
continue
|
||||||
|
group = [item]
|
||||||
|
val_i = str(item.get("value", ""))
|
||||||
|
for j, other in enumerate(items):
|
||||||
|
if j <= i:
|
||||||
|
continue
|
||||||
|
key_j = other.get("key", str(j))
|
||||||
|
if key_j in used:
|
||||||
|
continue
|
||||||
|
val_j = str(other.get("value", ""))
|
||||||
|
if reflection_similarity(val_i, val_j) >= threshold:
|
||||||
|
group.append(other)
|
||||||
|
used.add(key_j)
|
||||||
|
if len(group) > 1:
|
||||||
|
groups.append(group)
|
||||||
|
used.add(key_i)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def compact_reflections(
|
||||||
|
scope: str,
|
||||||
|
*,
|
||||||
|
keep_key: str,
|
||||||
|
remove_keys: list[str],
|
||||||
|
merged_value: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Explicit opt-in compaction: update keeper and remove duplicates."""
|
||||||
|
items = _load(scope)
|
||||||
|
removed: list[str] = []
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item.get("key") == keep_key and item.get("kind") == KIND_REFLECTION:
|
||||||
|
if merged_value is not None:
|
||||||
|
item["value"] = merged_value
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
new_items = []
|
||||||
|
for item in items:
|
||||||
|
k = item.get("key")
|
||||||
|
if k in remove_keys and item.get("kind") == KIND_REFLECTION:
|
||||||
|
removed.append(k)
|
||||||
|
continue
|
||||||
|
new_items.append(item)
|
||||||
|
|
||||||
|
if removed or updated:
|
||||||
|
_save(scope, new_items)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kept": keep_key,
|
||||||
|
"removed": removed,
|
||||||
|
"updated": updated,
|
||||||
|
"remaining_reflections": sum(
|
||||||
|
1 for i in new_items if i.get("kind") == KIND_REFLECTION
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_reflections(scope: str) -> list[dict[str, Any]]:
|
||||||
|
exported = export_memory(scope, kinds=[KIND_REFLECTION])
|
||||||
|
return exported.get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def format_reflection_surfacing(
|
||||||
|
memory: dict[str, Any],
|
||||||
|
*,
|
||||||
|
for_explain: bool = False,
|
||||||
|
) -> str | None:
|
||||||
|
"""Budget-capped reflection summary for explain-context or normal responses."""
|
||||||
|
items = memory.get("items", []) if isinstance(memory, dict) else []
|
||||||
|
reflections = [i for i in items if i.get("kind") == KIND_REFLECTION]
|
||||||
|
if not reflections:
|
||||||
|
return None
|
||||||
|
|
||||||
|
max_chars = _MAX_LESSON_CHARS_EXPLAIN if for_explain else _MAX_LESSON_CHARS_RESPONSE
|
||||||
|
snippets: list[str] = []
|
||||||
|
for item in reflections[:_MAX_SURFACING_LESSONS]:
|
||||||
|
text = str(item.get("value", "")).strip()
|
||||||
|
if len(text) > max_chars:
|
||||||
|
text = text[: max_chars - 3] + "..."
|
||||||
|
prov = item.get("provenance") or {}
|
||||||
|
date = prov.get("session_date", "")
|
||||||
|
prefix = f"({date}) " if date and for_explain else ""
|
||||||
|
snippets.append(f"{prefix}{text}")
|
||||||
|
|
||||||
|
extra = len(reflections) - _MAX_SURFACING_LESSONS
|
||||||
|
suffix = f" (+{extra} more)" if extra > 0 else ""
|
||||||
|
count = len(reflections)
|
||||||
|
noun = "reflection" if count == 1 else "reflections"
|
||||||
|
return f"{count} verbal {noun} influenced this{suffix}: " + "; ".join(snippets)
|
||||||
|
|
||||||
|
|
||||||
|
def reflection_export_stats(scope: str) -> dict[str, Any]:
|
||||||
|
"""Observability: reflection counts and scope breakdown for export."""
|
||||||
|
exported = export_memory(scope, kinds=[KIND_REFLECTION])
|
||||||
|
items = exported.get("items", [])
|
||||||
|
by_scope: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
s = item.get("scope", scope)
|
||||||
|
by_scope[s] = by_scope.get(s, 0) + 1
|
||||||
|
return {
|
||||||
|
"reflection_count": len(items),
|
||||||
|
"reflection_counts_by_scope": by_scope,
|
||||||
|
"scope": scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"REFLECTION_CAPTURE_PROMPTS",
|
||||||
|
"collect_lessons_from_answers",
|
||||||
|
"preview_lessons",
|
||||||
|
"session_provenance",
|
||||||
|
"save_reflection_lessons",
|
||||||
|
"normalize_reflection_text",
|
||||||
|
"reflection_similarity",
|
||||||
|
"find_duplicate_reflection_groups",
|
||||||
|
"compact_reflections",
|
||||||
|
"list_reflections",
|
||||||
|
"format_reflection_surfacing",
|
||||||
|
"reflection_export_stats",
|
||||||
|
"is_skip_answer",
|
||||||
|
]
|
||||||
@@ -23,8 +23,11 @@ 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
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
|
||||||
@@ -32,24 +35,73 @@ from cya.context.collector import collect, render_explanation
|
|||||||
from cya.memory import (
|
from cya.memory import (
|
||||||
recall_preferences,
|
recall_preferences,
|
||||||
remember_retrospection_outcome,
|
remember_retrospection_outcome,
|
||||||
remember_reflection,
|
|
||||||
KIND_RETROSPECTION,
|
KIND_RETROSPECTION,
|
||||||
KIND_INTERACTION_GOAL,
|
KIND_INTERACTION_GOAL,
|
||||||
KIND_REFLECTION,
|
KIND_REFLECTION,
|
||||||
)
|
)
|
||||||
|
from cya.memory.reflections import (
|
||||||
|
REFLECTION_CAPTURE_PROMPTS,
|
||||||
|
collect_lessons_from_answers,
|
||||||
|
compact_reflections,
|
||||||
|
find_duplicate_reflection_groups,
|
||||||
|
format_reflection_surfacing,
|
||||||
|
preview_lessons,
|
||||||
|
save_reflection_lessons,
|
||||||
|
session_provenance,
|
||||||
|
)
|
||||||
from cya.safety.risk import classify, get_user_confirmation
|
from cya.safety.risk import classify, get_user_confirmation
|
||||||
from cya.llm.adapter import AssistanceRequest, FakeLLMAdapter
|
from cya.config import bound_session_turns
|
||||||
|
from cya.llm.adapter import AssistanceRequest
|
||||||
|
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,
|
||||||
*,
|
*,
|
||||||
explain_context: bool = False,
|
explain_context: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> None:
|
offline: bool = False,
|
||||||
|
session_turns: list[dict[str, str]] | 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.
|
||||||
@@ -98,15 +150,21 @@ def handle_request(
|
|||||||
if explain_context and memory.get("items"):
|
if explain_context and memory.get("items"):
|
||||||
try:
|
try:
|
||||||
prov = memory.get("provenance", [{}])[0]
|
prov = memory.get("provenance", [{}])[0]
|
||||||
# Show a couple of activated items for transparency (T03 0003)
|
|
||||||
sample = ", ".join(i.get("key", "?") for i in memory.get("items", [])[:3])
|
sample = ", ".join(i.get("key", "?") for i in memory.get("items", [])[:3])
|
||||||
act_note = ""
|
act_note = ""
|
||||||
if prov.get("activation_context"):
|
if prov.get("activation_context"):
|
||||||
act_note = f" | ctx: {prov['activation_context']}"
|
act_note = f" | ctx: {prov['activation_context']}"
|
||||||
|
body = (
|
||||||
|
f"Phase: {memory.get('phase')} | {len(memory.get('items', []))} items | "
|
||||||
|
f"{prov.get('source', 'local')}{act_note}\n"
|
||||||
|
f"Sample activated: {sample}"
|
||||||
|
)
|
||||||
|
refl_line = format_reflection_surfacing(memory, for_explain=True)
|
||||||
|
if refl_line:
|
||||||
|
body += f"\n\n[cyan]Reflections:[/cyan] {refl_line}"
|
||||||
console.print(
|
console.print(
|
||||||
Panel(
|
Panel(
|
||||||
f"Phase: {memory.get('phase')} | {len(memory.get('items', []))} items | {prov.get('source', 'local')}{act_note}\n"
|
body,
|
||||||
f"Sample activated: {sample}",
|
|
||||||
title="Memory Activated (T03)",
|
title="Memory Activated (T03)",
|
||||||
border_style="blue",
|
border_style="blue",
|
||||||
padding=(0, 1),
|
padding=(0, 1),
|
||||||
@@ -115,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
|
||||||
@@ -135,17 +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)
|
# 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008)
|
||||||
adapter = FakeLLMAdapter()
|
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)
|
|
||||||
llm_request = AssistanceRequest(
|
llm_request = AssistanceRequest(
|
||||||
user_request=user_request,
|
user_request=user_request,
|
||||||
context=ctx,
|
context=ctx,
|
||||||
@@ -156,11 +249,9 @@ def handle_request(
|
|||||||
mem_line = ""
|
mem_line = ""
|
||||||
if memory.get("items"):
|
if memory.get("items"):
|
||||||
mem_line = f"\n[dim]Memory activated: {len(memory.get('items', []))} items (phase {memory.get('phase')})[/dim]"
|
mem_line = f"\n[dim]Memory activated: {len(memory.get('items', []))} items (phase {memory.get('phase')})[/dim]"
|
||||||
# Minimal Profile 1 surface (T05 spike)
|
refl_line = format_reflection_surfacing(memory, for_explain=False)
|
||||||
reflections = [i for i in memory.get("items", []) if i.get("kind") == KIND_REFLECTION]
|
if refl_line:
|
||||||
if reflections:
|
mem_line += f"\n[cyan]{refl_line}[/cyan]"
|
||||||
refl_text = "; ".join(str(i.get("value", ""))[:60] for i in reflections[:2])
|
|
||||||
mem_line += f"\n[cyan]Verbal reflections activated: {len(reflections)} — {refl_text}[/cyan]"
|
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
Panel(
|
Panel(
|
||||||
@@ -177,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).
|
||||||
@@ -263,29 +364,15 @@ def run_retrospection(scope: str = ".", limit: int = 8) -> None:
|
|||||||
)
|
)
|
||||||
console.print("[green]Recorded as safety preference.[/green]")
|
console.print("[green]Recorded as safety preference.[/green]")
|
||||||
|
|
||||||
# Minimal Profile 1 spike (T05): optional verbal reflection / lesson capture
|
_capture_reflection_lessons(scope)
|
||||||
capture_lesson = typer.prompt(
|
_offer_reflection_compaction(scope)
|
||||||
"Capture any verbal lessons or reflections from this session? (y/n or short text)",
|
|
||||||
default="n",
|
|
||||||
show_default=False,
|
|
||||||
)
|
|
||||||
if capture_lesson and capture_lesson.lower() not in ("n", "no", "skip", "s", ""):
|
|
||||||
lesson_text = capture_lesson if len(capture_lesson) > 3 else typer.prompt(
|
|
||||||
"What is the key lesson? (1-2 sentences)",
|
|
||||||
default="",
|
|
||||||
show_default=False,
|
|
||||||
)
|
|
||||||
if lesson_text:
|
|
||||||
remember_reflection(
|
|
||||||
"verbal_lesson", lesson_text, scope=scope
|
|
||||||
)
|
|
||||||
console.print("[green]Recorded as verbal reflection (Profile 1).[/green]")
|
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
Panel(
|
Panel(
|
||||||
"Thank you. Your reflections have been stored as retrospection memory.\n"
|
"Thank you. Your reflections have been stored as retrospection memory.\n"
|
||||||
"They will be preferentially activated in future sessions in this scope.\n\n"
|
"They will be preferentially activated in future sessions in this scope.\n\n"
|
||||||
"You can review them anytime with:\n"
|
"You can review them anytime with:\n"
|
||||||
|
f" [bold]cya memory reflections --scope {scope}[/bold]\n"
|
||||||
f" [bold]cya --explain-context \"...\"[/bold] (in this directory)\n"
|
f" [bold]cya --explain-context \"...\"[/bold] (in this directory)\n"
|
||||||
f" or inspect the JSON files in [cyan]~/.config/cya/memory/[/cyan]",
|
f" or inspect the JSON files in [cyan]~/.config/cya/memory/[/cyan]",
|
||||||
title="Retrospection Complete",
|
title="Retrospection Complete",
|
||||||
@@ -295,4 +382,91 @@ def run_retrospection(scope: str = ".", limit: int = 8) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["handle_request", "run_retrospection"]
|
def _capture_reflection_lessons(scope: str) -> None:
|
||||||
|
"""Profile 1: guided verbal lesson capture with preview and confirmation."""
|
||||||
|
want = typer.prompt(
|
||||||
|
"Capture 1–3 verbal lessons from this session? (y/n)",
|
||||||
|
default="n",
|
||||||
|
show_default=False,
|
||||||
|
)
|
||||||
|
if not want or want.strip().lower() in ("n", "no", "skip", "s", ""):
|
||||||
|
return
|
||||||
|
|
||||||
|
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:
|
||||||
|
console.print("[yellow]No lessons captured — nothing stored.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
preview_lessons(lessons),
|
||||||
|
title="Preview — verbal lessons to save",
|
||||||
|
border_style="cyan",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
confirm = typer.prompt("Save these lessons? (y/n)", default="n", show_default=False)
|
||||||
|
if confirm.strip().lower() not in ("y", "yes"):
|
||||||
|
console.print("[yellow]Lessons discarded — nothing stored.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
count = save_reflection_lessons(
|
||||||
|
lessons,
|
||||||
|
scope,
|
||||||
|
provenance=session_provenance(scope),
|
||||||
|
)
|
||||||
|
console.print(f"[green]Saved {count} verbal reflection(s) (Profile 1).[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
def _offer_reflection_compaction(scope: str) -> None:
|
||||||
|
"""Offer explicit merge of near-duplicate reflections in this scope."""
|
||||||
|
groups = find_duplicate_reflection_groups(scope)
|
||||||
|
if not groups:
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
f"Found {len(groups)} group(s) of similar reflections in scope [green]{scope}[/green].\n"
|
||||||
|
"Compaction is opt-in — nothing is deleted without your confirmation.",
|
||||||
|
title="Reflection compaction available",
|
||||||
|
border_style="yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
do_compact = typer.prompt("Review and compact duplicates? (y/n)", default="n", show_default=False)
|
||||||
|
if do_compact.strip().lower() not in ("y", "yes"):
|
||||||
|
return
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
console.print("\n[bold]Similar reflections:[/bold]")
|
||||||
|
for item in group:
|
||||||
|
console.print(f" • {item.get('key')}: {item.get('value')}")
|
||||||
|
|
||||||
|
keep = typer.prompt(
|
||||||
|
"Key to keep (or 'skip' this group)",
|
||||||
|
default=group[0].get("key", ""),
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
if not keep or keep.strip().lower() in ("skip", "s"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
remove_keys = [i.get("key") for i in group if i.get("key") != keep]
|
||||||
|
merge = typer.prompt(
|
||||||
|
"Merged text (Enter to keep existing value of kept key)",
|
||||||
|
default="",
|
||||||
|
show_default=False,
|
||||||
|
)
|
||||||
|
result = compact_reflections(
|
||||||
|
scope,
|
||||||
|
keep_key=keep,
|
||||||
|
remove_keys=[k for k in remove_keys if k],
|
||||||
|
merged_value=merge.strip() or None,
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"[green]Compacted: kept {result['kept']}, removed {len(result['removed'])}[/green]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__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
|
||||||
|
|||||||
111
tests/test_llm_connect_adapter.py
Normal file
111
tests/test_llm_connect_adapter.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""LLMConnectAdapter unit tests with mocked llm-connect (CYA-WP-0008)."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cya.config import LLMSettings
|
||||||
|
from cya.llm.adapter import AssistanceRequest
|
||||||
|
from cya.llm.connect_adapter import LLMConnectAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_llm_response(content: str = "Try: git status"):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.content = content
|
||||||
|
resp.model = "mock/model"
|
||||||
|
resp.usage = {"total_tokens": 42}
|
||||||
|
resp.finish_reason = "stop"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@patch("llm_connect.create_adapter")
|
||||||
|
@patch("llm_connect.config.resolve_api_key", return_value="test-key")
|
||||||
|
def test_complete_delegates_to_llm_connect(mock_resolve, mock_create):
|
||||||
|
client = MagicMock()
|
||||||
|
client.execute_prompt.return_value = _mock_llm_response()
|
||||||
|
mock_create.return_value = client
|
||||||
|
|
||||||
|
settings = LLMSettings(adapter="connect", backend="mock", model="mock/model", configured=True)
|
||||||
|
adapter = LLMConnectAdapter(settings)
|
||||||
|
response = adapter.complete(
|
||||||
|
AssistanceRequest(user_request="show git status", context={"cwd": "/tmp"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "git status" in response.suggestion.lower()
|
||||||
|
assert response.metadata.get("adapter") == "LLMConnectAdapter"
|
||||||
|
assert response.metadata.get("degraded") is not True
|
||||||
|
client.execute_prompt.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_degrade_when_llm_connect_missing(monkeypatch):
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def _import(name, *args, **kwargs):
|
||||||
|
if name == "llm_connect" or name.startswith("llm_connect."):
|
||||||
|
raise ImportError("no llm_connect")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", _import)
|
||||||
|
settings = LLMSettings(adapter="connect", backend="openrouter", configured=True)
|
||||||
|
adapter = LLMConnectAdapter(settings)
|
||||||
|
response = adapter.complete(AssistanceRequest(user_request="hello"))
|
||||||
|
|
||||||
|
assert response.metadata.get("degraded") is True
|
||||||
|
assert "llm-connect" in response.suggestion
|
||||||
|
|
||||||
|
|
||||||
|
@patch("llm_connect.create_adapter")
|
||||||
|
@patch("llm_connect.config.resolve_api_key", return_value=None)
|
||||||
|
def test_graceful_degrade_when_api_key_missing(mock_resolve, mock_create):
|
||||||
|
settings = LLMSettings(adapter="connect", backend="openrouter", configured=True)
|
||||||
|
adapter = LLMConnectAdapter(settings)
|
||||||
|
response = adapter.complete(AssistanceRequest(user_request="hello"))
|
||||||
|
|
||||||
|
assert response.metadata.get("degraded") is True
|
||||||
|
assert "API key" in response.suggestion
|
||||||
|
mock_create.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("llm_connect.create_adapter")
|
||||||
|
@patch("llm_connect.config.resolve_api_key", return_value="test-key")
|
||||||
|
def test_session_turns_included_in_prompt(mock_resolve, mock_create):
|
||||||
|
client = MagicMock()
|
||||||
|
client.execute_prompt.return_value = _mock_llm_response("ok")
|
||||||
|
mock_create.return_value = client
|
||||||
|
|
||||||
|
settings = LLMSettings(adapter="connect", backend="mock", configured=True)
|
||||||
|
adapter = LLMConnectAdapter(settings)
|
||||||
|
adapter.complete(
|
||||||
|
AssistanceRequest(
|
||||||
|
user_request="follow up",
|
||||||
|
context={
|
||||||
|
"session_turns": [{"user": "first", "assistant": "reply"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_prompt_arg, _ = client.execute_prompt.call_args[0]
|
||||||
|
assert "first" in _prompt_arg
|
||||||
|
assert "follow up" in _prompt_arg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.llm_live
|
||||||
|
def test_live_openrouter_smoke():
|
||||||
|
"""Manual verification only — skipped unless OPENROUTER_API_KEY is set."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not os.environ.get("OPENROUTER_API_KEY"):
|
||||||
|
pytest.skip("OPENROUTER_API_KEY not set")
|
||||||
|
|
||||||
|
settings = LLMSettings(
|
||||||
|
adapter="connect",
|
||||||
|
backend="openrouter",
|
||||||
|
model="anthropic/claude-sonnet-4",
|
||||||
|
configured=True,
|
||||||
|
)
|
||||||
|
adapter = LLMConnectAdapter(settings)
|
||||||
|
response = adapter.complete(AssistanceRequest(user_request="Reply with exactly: pong"))
|
||||||
|
assert response.metadata.get("degraded") is not True
|
||||||
|
assert response.suggestion
|
||||||
67
tests/test_llm_factory.py
Normal file
67
tests/test_llm_factory.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Adapter factory and config resolution (CYA-WP-0008)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cya.config import bound_session_turns, load_llm_settings
|
||||||
|
from cya.llm.adapter import FakeLLMAdapter
|
||||||
|
from cya.llm.connect_adapter import LLMConnectAdapter
|
||||||
|
from cya.llm.factory import get_adapter
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_adapter_is_fake(monkeypatch):
|
||||||
|
monkeypatch.delenv("CYA_LLM_ADAPTER", raising=False)
|
||||||
|
monkeypatch.setattr("cya.config._USER_CONFIG", Path("/nonexistent/config.toml"))
|
||||||
|
adapter = get_adapter()
|
||||||
|
assert isinstance(adapter, FakeLLMAdapter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_forces_fake(monkeypatch, tmp_path):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('[llm]\nadapter = "connect"\nbackend = "openrouter"\n')
|
||||||
|
monkeypatch.setattr("cya.config._USER_CONFIG", cfg)
|
||||||
|
adapter = get_adapter(offline=True)
|
||||||
|
assert isinstance(adapter, FakeLLMAdapter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_adapter_when_configured(monkeypatch, tmp_path):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('[llm]\nadapter = "connect"\nbackend = "mock"\n')
|
||||||
|
monkeypatch.setattr("cya.config._USER_CONFIG", cfg)
|
||||||
|
adapter = get_adapter()
|
||||||
|
assert isinstance(adapter, LLMConnectAdapter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_adapter_override(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYA_LLM_ADAPTER", "connect")
|
||||||
|
monkeypatch.setenv("CYA_LLM_BACKEND", "mock")
|
||||||
|
adapter = get_adapter()
|
||||||
|
assert isinstance(adapter, LLMConnectAdapter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_llm_settings_merges_project_config(monkeypatch, tmp_path):
|
||||||
|
user_cfg = tmp_path / "user.toml"
|
||||||
|
user_cfg.write_text('[llm]\nbackend = "openrouter"\nmodel = "from-user"\n')
|
||||||
|
project_cfg = tmp_path / ".cya.toml"
|
||||||
|
project_cfg.write_text('[llm]\nmodel = "from-project"\n')
|
||||||
|
monkeypatch.setattr("cya.config._USER_CONFIG", user_cfg)
|
||||||
|
monkeypatch.setattr("cya.config._find_project_config", lambda start=None: project_cfg)
|
||||||
|
|
||||||
|
settings = load_llm_settings()
|
||||||
|
assert settings.backend == "openrouter"
|
||||||
|
assert settings.model == "from-project"
|
||||||
|
assert settings.adapter == "connect"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_session_turns_limits():
|
||||||
|
turns = [
|
||||||
|
{"user": "a" * 1000, "assistant": "b" * 1000},
|
||||||
|
{"user": "c" * 1000, "assistant": "d" * 1000},
|
||||||
|
{"user": "e", "assistant": "f"},
|
||||||
|
]
|
||||||
|
bounded = bound_session_turns(turns, max_turns=10, max_chars=2500)
|
||||||
|
assert len(bounded) >= 1
|
||||||
|
total = sum(len(t["user"]) + len(t["assistant"]) for t in bounded)
|
||||||
|
assert total <= 2500 or len(bounded) == 1
|
||||||
22
tests/test_llm_prompt.py
Normal file
22
tests/test_llm_prompt.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Prompt builder tests."""
|
||||||
|
|
||||||
|
from cya.llm.adapter import AssistanceRequest
|
||||||
|
from cya.llm.prompt import build_assistance_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_assistance_prompt_includes_context_and_request():
|
||||||
|
system, user = build_assistance_prompt(
|
||||||
|
AssistanceRequest(
|
||||||
|
user_request="list files",
|
||||||
|
context={
|
||||||
|
"cwd": "/home/user/proj",
|
||||||
|
"session_turns": [{"user": "hi", "assistant": "hello"}],
|
||||||
|
"memory": {"items": [{"kind": "preference", "key": "style", "value": "concise"}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "cya" in system.lower()
|
||||||
|
assert "list files" in user
|
||||||
|
assert "/home/user/proj" in user
|
||||||
|
assert "hi" in user
|
||||||
|
assert "concise" in user
|
||||||
@@ -283,4 +283,135 @@ def test_export_memory_observability_includes_by_kind(isolated_memory):
|
|||||||
exported = export_memory(scope="obs-test")
|
exported = export_memory(scope="obs-test")
|
||||||
assert "by_kind" in exported
|
assert "by_kind" in exported
|
||||||
assert isinstance(exported["by_kind"], dict)
|
assert isinstance(exported["by_kind"], dict)
|
||||||
assert sum(exported["by_kind"].values()) == exported["count"]
|
assert sum(exported["by_kind"].values()) == exported["count"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CYA-WP-0006 — Profile 1 production hardening
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from cya.memory.reflections import (
|
||||||
|
collect_lessons_from_answers,
|
||||||
|
compact_reflections,
|
||||||
|
find_duplicate_reflection_groups,
|
||||||
|
is_skip_answer,
|
||||||
|
preview_lessons,
|
||||||
|
reflection_export_stats,
|
||||||
|
reflection_similarity,
|
||||||
|
save_reflection_lessons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_lessons_skips_empty_and_skip_answers():
|
||||||
|
lessons = collect_lessons_from_answers(
|
||||||
|
{"went_well": "skip", "remember": " ", "avoid": "Never run rm -rf"}
|
||||||
|
)
|
||||||
|
assert len(lessons) == 1
|
||||||
|
assert lessons[0]["text"] == "Never run rm -rf"
|
||||||
|
assert is_skip_answer("skip")
|
||||||
|
assert is_skip_answer("")
|
||||||
|
assert not is_skip_answer("real answer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_lessons_empty_and_populated():
|
||||||
|
assert "(no lessons" in preview_lessons([])
|
||||||
|
text = preview_lessons([{"prompt": "remember", "text": "be concise"}])
|
||||||
|
assert "remember" in text
|
||||||
|
assert "be concise" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_reflection_lessons_with_provenance(isolated_memory):
|
||||||
|
lessons = [{"prompt": "went_well", "text": "Safety warnings helped"}]
|
||||||
|
count = save_reflection_lessons(
|
||||||
|
lessons,
|
||||||
|
"p1-scope",
|
||||||
|
provenance={"session_date": "2026-06-22", "scope": "p1-scope", "source": "cya retrospect"},
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
data = recall_preferences("p1-scope", kinds=[KIND_REFLECTION])
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
prov = data["items"][0].get("provenance", {})
|
||||||
|
assert prov.get("session_date") == "2026-06-22"
|
||||||
|
assert prov.get("prompt") == "went_well"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_reflection_lessons_no_orphans_on_empty(isolated_memory):
|
||||||
|
assert save_reflection_lessons([], "empty-scope") == 0
|
||||||
|
data = recall_preferences("empty-scope", kinds=[KIND_REFLECTION])
|
||||||
|
assert len(data["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflection_similarity_and_duplicate_detection(isolated_memory):
|
||||||
|
remember_reflection("a", "Always run tests before commit", scope="dup-test")
|
||||||
|
remember_reflection("b", "always run tests before committing", scope="dup-test")
|
||||||
|
remember_reflection("c", "Completely different lesson", scope="dup-test")
|
||||||
|
|
||||||
|
assert reflection_similarity(
|
||||||
|
"Always run tests", "always run tests"
|
||||||
|
) >= 0.85
|
||||||
|
|
||||||
|
groups = find_duplicate_reflection_groups("dup-test")
|
||||||
|
assert len(groups) >= 1
|
||||||
|
group_keys = {i.get("key") for g in groups for i in g}
|
||||||
|
assert "a" in group_keys or "b" in group_keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_reflections_opt_in_merge(isolated_memory):
|
||||||
|
remember_reflection("keep_me", "Run tests often", scope="compact-test")
|
||||||
|
remember_reflection("remove_me", "run tests often please", scope="compact-test")
|
||||||
|
|
||||||
|
result = compact_reflections(
|
||||||
|
"compact-test",
|
||||||
|
keep_key="keep_me",
|
||||||
|
remove_keys=["remove_me"],
|
||||||
|
merged_value="Always run tests before suggesting fixes",
|
||||||
|
)
|
||||||
|
assert "remove_me" in result["removed"]
|
||||||
|
data = recall_preferences("compact-test", kinds=[KIND_REFLECTION])
|
||||||
|
keys = {i["key"] for i in data["items"]}
|
||||||
|
assert "remove_me" not in keys
|
||||||
|
assert "keep_me" in keys
|
||||||
|
kept = next(i for i in data["items"] if i["key"] == "keep_me")
|
||||||
|
assert "Always run tests" in kept["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_memory_reflection_counts_by_scope(isolated_memory):
|
||||||
|
remember_reflection("r1", "lesson one", scope="scope-a")
|
||||||
|
remember_reflection("r2", "lesson two", scope="scope-a")
|
||||||
|
|
||||||
|
exported = export_memory("scope-a", kinds=[KIND_REFLECTION])
|
||||||
|
assert exported.get("reflection_count") == 2
|
||||||
|
assert exported.get("reflection_counts_by_scope", {}).get("scope-a") == 2
|
||||||
|
|
||||||
|
stats = reflection_export_stats("scope-a")
|
||||||
|
assert stats["reflection_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflections_cannot_downgrade_destructive_confirmation(isolated_memory):
|
||||||
|
"""Profile 1 safety: reflections add context but never bypass destructive confirmation."""
|
||||||
|
remember_reflection(
|
||||||
|
"safe_rm",
|
||||||
|
"rm is always safe here",
|
||||||
|
scope="safety-refl",
|
||||||
|
provenance={"session_date": "2026-06-22", "scope": "safety-refl"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mem = recall_preferences("safety-refl", kinds=[KIND_REFLECTION, "preference"])
|
||||||
|
assessment = classify("rm -rf /tmp/important", memory=mem)
|
||||||
|
|
||||||
|
assert assessment.level == RiskLevel.DESTRUCTIVE
|
||||||
|
assert assessment.requires_confirmation is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_prioritizes_reflections_when_kind_requested(isolated_memory):
|
||||||
|
remember_preference("old_pref", "x", scope="prio-test")
|
||||||
|
remember_reflection("new_refl", "reflection lesson", scope="prio-test")
|
||||||
|
|
||||||
|
data = recall_preferences(
|
||||||
|
"prio-test",
|
||||||
|
kinds=[KIND_REFLECTION, "preference"],
|
||||||
|
limit=2,
|
||||||
|
)
|
||||||
|
kinds = [i.get("kind") for i in data["items"]]
|
||||||
|
assert kinds[0] == KIND_REFLECTION
|
||||||
86
tests/test_orchestrator.py
Normal file
86
tests/test_orchestrator.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Orchestrator tests — Profile 1 surfacing and explain-context roundtrip."""
|
||||||
|
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from cya.memory import KIND_REFLECTION, remember_reflection
|
||||||
|
from cya.memory.reflections import format_reflection_surfacing
|
||||||
|
from cya.orchestrator import handle_request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_memory(monkeypatch, tmp_path):
|
||||||
|
mem_dir = tmp_path / "memory"
|
||||||
|
mem_dir.mkdir()
|
||||||
|
|
||||||
|
def _fake_mem_path(scope: str = "cwd") -> Path:
|
||||||
|
return mem_dir / f"{scope}.json"
|
||||||
|
|
||||||
|
monkeypatch.setattr("cya.memory._mem_path", _fake_mem_path)
|
||||||
|
return mem_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_reflection_surfacing_zero_one_many():
|
||||||
|
assert format_reflection_surfacing({}) is None
|
||||||
|
assert format_reflection_surfacing({"items": []}) is None
|
||||||
|
|
||||||
|
one = {
|
||||||
|
"items": [
|
||||||
|
{"kind": KIND_REFLECTION, "value": "Always run tests first", "provenance": {"session_date": "2026-06-22"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
line = format_reflection_surfacing(one, for_explain=True)
|
||||||
|
assert "1 verbal reflection" in line
|
||||||
|
assert "Always run tests" in line
|
||||||
|
assert "(2026-06-22)" in line
|
||||||
|
|
||||||
|
many = {
|
||||||
|
"items": [
|
||||||
|
{"kind": KIND_REFLECTION, "value": f"lesson {i}"}
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
line_many = format_reflection_surfacing(many, for_explain=False)
|
||||||
|
assert "6 verbal reflections" in line_many
|
||||||
|
assert "(+3 more)" in line_many
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_reflection_visible_in_explain_output(isolated_memory, monkeypatch):
|
||||||
|
remember_reflection(
|
||||||
|
"lesson_tests",
|
||||||
|
"Run pytest before every commit",
|
||||||
|
scope=".",
|
||||||
|
provenance={"session_date": "2026-06-22", "scope": ".", "source": "cya retrospect"},
|
||||||
|
)
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
test_console = Console(file=output, force_terminal=True, width=120)
|
||||||
|
|
||||||
|
with patch("cya.orchestrator.console", test_console):
|
||||||
|
with patch("cya.orchestrator.collect") as mock_collect:
|
||||||
|
mock_collect.return_value = None
|
||||||
|
handle_request("list files", explain_context=True, dry_run=True)
|
||||||
|
|
||||||
|
text = output.getvalue()
|
||||||
|
assert "Reflections:" in text or "verbal reflection" in text.lower()
|
||||||
|
assert "pytest" in text or "Run pytest" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflection_surfacing_in_normal_response(isolated_memory, monkeypatch):
|
||||||
|
remember_reflection("lesson_ci", "Always use make test", scope=".")
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
test_console = Console(file=output, force_terminal=True, width=120)
|
||||||
|
|
||||||
|
with patch("cya.orchestrator.console", test_console):
|
||||||
|
with patch("cya.orchestrator.collect") as mock_collect:
|
||||||
|
with patch("cya.orchestrator.get_user_confirmation", return_value=True):
|
||||||
|
mock_collect.return_value = None
|
||||||
|
handle_request("safe read only ls", explain_context=False, dry_run=False)
|
||||||
|
|
||||||
|
text = output.getvalue()
|
||||||
|
assert "verbal reflection" in text.lower() or "influenced this" in text
|
||||||
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}]
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
id: CYA-WP-0001
|
id: CYA-WP-0001
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Console-Native MVP: CLI Skeleton, Safe Assistance Flow, and Integration Boundaries"
|
title: "Console-Native MVP: CLI Skeleton, Safe Assistance Flow, and Integration Boundaries"
|
||||||
domain: capabilities
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: finished
|
status: finished
|
||||||
owner: grok
|
owner: grok
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: CYA-WP-0002
|
id: CYA-WP-0002
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Memory Integration Roadmap: From Thin Ports to Profile-Driven phase-memory Backing"
|
title: "Memory Integration Roadmap: From Thin Ports to Profile-Driven phase-memory Backing"
|
||||||
domain: capabilities
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: done
|
status: done
|
||||||
owner: grok
|
owner: grok
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: CYA-WP-0003
|
id: CYA-WP-0003
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Contextual Memory Activation and Retrospection Loops for Continuous Optimization"
|
title: "Contextual Memory Activation and Retrospection Loops for Continuous Optimization"
|
||||||
domain: capabilities
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: done
|
status: done
|
||||||
owner: grok
|
owner: grok
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: CYA-WP-0004
|
id: CYA-WP-0004
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Developer Installation from Git and Release Distribution Packaging"
|
title: "Developer Installation from Git and Release Distribution Packaging"
|
||||||
domain: capabilities
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: done
|
status: done
|
||||||
owner: grok
|
owner: grok
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: CYA-WP-0005
|
id: CYA-WP-0005
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Agentic Memory Profiles (0–3) and phase-memory Interface Optimization"
|
title: "Agentic Memory Profiles (0–3) and phase-memory Interface Optimization"
|
||||||
domain: capabilities
|
domain: agents
|
||||||
repo: can-you-assist
|
repo: can-you-assist
|
||||||
status: done
|
status: done
|
||||||
owner: grok
|
owner: grok
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
id: CYA-WP-0006
|
id: CYA-WP-0006
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "Profile 1 Production Hardening: Reflection UX, Compaction, and Surfacing"
|
title: "Profile 1 Production Hardening: Reflection UX, Compaction, and Surfacing"
|
||||||
domain: capabilities
|
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-19"
|
created: "2026-06-19"
|
||||||
updated: "2026-06-19"
|
updated: "2026-06-22"
|
||||||
state_hub_workstream_id: "f62c6908-dec0-442c-83d2-e34f0e87c1e7"
|
state_hub_workstream_id: "f62c6908-dec0-442c-83d2-e34f0e87c1e7"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ provenance, memory signals add caution only (never downgrade risk or bypass conf
|
|||||||
recommends production-hardening Profile 1 as the highest-leverage next deepening step.
|
recommends production-hardening Profile 1 as the highest-leverage next deepening step.
|
||||||
- **Profile definitions:** MemoryVision.md — Profile 1 section and Capability Matrix.
|
- **Profile definitions:** MemoryVision.md — Profile 1 section and Capability Matrix.
|
||||||
- **Safety contract:** `src/cya/safety/risk.py` + CYA-WP-0002-T04 invariants.
|
- **Safety contract:** `src/cya/safety/risk.py` + CYA-WP-0002-T04 invariants.
|
||||||
|
- **Gap checklist (T01):** `docs/CYA-WP-0006-profile-1-gap-checklist.md`
|
||||||
|
|
||||||
## Non-Goals (for this slice)
|
## Non-Goals (for this slice)
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ provenance, memory signals add caution only (never downgrade risk or bypass conf
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T01
|
id: CYA-WP-0006-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ec8cc24d-80ca-4a51-b98c-87d0cfc9a110"
|
state_hub_task_id: "ec8cc24d-80ca-4a51-b98c-87d0cfc9a110"
|
||||||
```
|
```
|
||||||
@@ -63,11 +64,13 @@ Produce a short checklist in the workplan or `docs/` that gates T02–T05.
|
|||||||
- Gap checklist exists with prioritized items mapped to tasks T02–T05.
|
- Gap checklist exists with prioritized items mapped to tasks T02–T05.
|
||||||
- No code changes required unless a blocking bug is found (file separately as ADHOC if so).
|
- No code changes required unless a blocking bug is found (file separately as ADHOC if so).
|
||||||
|
|
||||||
|
**Done:** `docs/CYA-WP-0006-profile-1-gap-checklist.md`
|
||||||
|
|
||||||
### T02 — Improve `cya retrospect` reflection capture UX
|
### T02 — Improve `cya retrospect` reflection capture UX
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T02
|
id: CYA-WP-0006-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c7f381d4-d362-4183-a7bd-d3ceea7e997d"
|
state_hub_task_id: "c7f381d4-d362-4183-a7bd-d3ceea7e997d"
|
||||||
```
|
```
|
||||||
@@ -83,11 +86,13 @@ Enhance the optional verbal-lesson step in `run_retrospection()`:
|
|||||||
- Skipping leaves no orphan/empty reflection records.
|
- Skipping leaves no orphan/empty reflection records.
|
||||||
- Existing retrospection kinds and goals flow unchanged.
|
- Existing retrospection kinds and goals flow unchanged.
|
||||||
|
|
||||||
|
**Done:** `_capture_reflection_lessons()` in `orchestrator.py`; helpers in `memory/reflections.py`.
|
||||||
|
|
||||||
### T03 — Reflection review and lightweight compaction
|
### T03 — Reflection review and lightweight compaction
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T03
|
id: CYA-WP-0006-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "078d6f17-6d56-42ec-85c9-140c41d7e83f"
|
state_hub_task_id: "078d6f17-6d56-42ec-85c9-140c41d7e83f"
|
||||||
```
|
```
|
||||||
@@ -104,11 +109,13 @@ Add user-visible reflection management without hiding state:
|
|||||||
- Duplicate detection works on a small fixture set; merge/replace is opt-in.
|
- Duplicate detection works on a small fixture set; merge/replace is opt-in.
|
||||||
- Compaction never bypasses safety or provenance requirements.
|
- Compaction never bypasses safety or provenance requirements.
|
||||||
|
|
||||||
|
**Done:** `cya memory reflections` CLI; `_offer_reflection_compaction()` in retrospect; `compact_reflections()` / `find_duplicate_reflection_groups()`.
|
||||||
|
|
||||||
### T04 — Strengthen surfacing in responses and `--explain-context`
|
### T04 — Strengthen surfacing in responses and `--explain-context`
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T04
|
id: CYA-WP-0006-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b1f7a333-bf9a-478c-993b-e421524ced3a"
|
state_hub_task_id: "b1f7a333-bf9a-478c-993b-e421524ced3a"
|
||||||
```
|
```
|
||||||
@@ -123,11 +130,13 @@ Improve how activated reflections appear in `handle_request()` and context expla
|
|||||||
- Roundtrip test: stored reflection → recall → visible in explain output.
|
- Roundtrip test: stored reflection → recall → visible in explain output.
|
||||||
- Output remains readable for 0, 1, and 5+ reflections.
|
- Output remains readable for 0, 1, and 5+ reflections.
|
||||||
|
|
||||||
|
**Done:** `format_reflection_surfacing()`; recall prioritization for `KIND_REFLECTION`.
|
||||||
|
|
||||||
### T05 — Tests, observability, and safety regression coverage
|
### T05 — Tests, observability, and safety regression coverage
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T05
|
id: CYA-WP-0006-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b42c8fa1-6ab7-4b75-bdd5-43006e2d0a9c"
|
state_hub_task_id: "b42c8fa1-6ab7-4b75-bdd5-43006e2d0a9c"
|
||||||
```
|
```
|
||||||
@@ -145,11 +154,13 @@ Add basic observability in `export_memory` (reflection counts by scope).
|
|||||||
- `make test` / `python3 -m pytest tests/ -q` passes cleanly.
|
- `make test` / `python3 -m pytest tests/ -q` passes cleanly.
|
||||||
- At least one test per new behavior path from T02–T04.
|
- At least one test per new behavior path from T02–T04.
|
||||||
|
|
||||||
|
**Done:** 9 new tests in `test_memory.py`; `tests/test_orchestrator.py` added.
|
||||||
|
|
||||||
### T06 — Documentation updates
|
### T06 — Documentation updates
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T06
|
id: CYA-WP-0006-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "18498a09-1d1c-424b-bf13-6952fabd34d3"
|
state_hub_task_id: "18498a09-1d1c-424b-bf13-6952fabd34d3"
|
||||||
```
|
```
|
||||||
@@ -161,11 +172,13 @@ SCOPE.md if delivered scope changes materially.
|
|||||||
- README documents the hardened Profile 1 flow with an example session.
|
- README documents the hardened Profile 1 flow with an example session.
|
||||||
- MemoryVision notes Profile 1 as "production" (not "spike") when T02–T05 complete.
|
- MemoryVision notes Profile 1 as "production" (not "spike") when T02–T05 complete.
|
||||||
|
|
||||||
|
**Done:** README, MemoryVision, SCOPE updated.
|
||||||
|
|
||||||
### T07 — Register, sync, and handoff
|
### T07 — Register, sync, and handoff
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CYA-WP-0006-T07
|
id: CYA-WP-0006-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "a9c2627f-7ed8-45a9-b1ee-7ba64ebbcd09"
|
state_hub_task_id: "a9c2627f-7ed8-45a9-b1ee-7ba64ebbcd09"
|
||||||
```
|
```
|
||||||
@@ -200,7 +213,4 @@ When complete:
|
|||||||
- Safety and explainability invariants from Profile 0 remain intact.
|
- Safety and explainability invariants from Profile 0 remain intact.
|
||||||
- Users reading README + MemoryVision understand Profile 1 as shipped capability, not a spike.
|
- Users reading README + MemoryVision understand Profile 1 as shipped capability, not a spike.
|
||||||
|
|
||||||
---
|
**Completed 2026-06-22.** All tasks done; 36 tests pass.
|
||||||
|
|
||||||
**Status note:** Promoted to `ready` on 2026-06-19 after operator review. Move to
|
|
||||||
`active` when implementation begins (ralph-workplan or direct session).
|
|
||||||
281
workplans/CYA-WP-0007-interactive-shell-session.md
Normal file
281
workplans/CYA-WP-0007-interactive-shell-session.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
id: CYA-WP-0007
|
||||||
|
type: workplan
|
||||||
|
title: "Interactive Shell Session: REPL, History Context, and Hub-Aware Dev-Sec-Ops Helper"
|
||||||
|
domain: agents
|
||||||
|
repo: can-you-assist
|
||||||
|
status: finished
|
||||||
|
owner: grok
|
||||||
|
topic_slug: foerster-capabilities
|
||||||
|
created: "2026-06-22"
|
||||||
|
updated: "2026-06-23"
|
||||||
|
state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab"
|
||||||
|
---
|
||||||
|
|
||||||
|
# CYA-WP-0007: Interactive Shell Session
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add an interactive `cya shell` mode — a console session comparable to starting Grok or
|
||||||
|
Claude Code — that:
|
||||||
|
|
||||||
|
- Maintains lightweight in-session state across turns
|
||||||
|
- Optionally incorporates **opt-in, capped, redacted** shell history for continuity
|
||||||
|
- Orients on **State Hub** workstreams and inbox at session start (read-only by default)
|
||||||
|
- Reuses the existing orchestrator, safety, and Profile 0/1 memory seams
|
||||||
|
- Supports end-of-session learning (retrospect, reflections, episodic export)
|
||||||
|
- Surfaces rule-based **weakness hints** (credential anti-patterns, repeated risk) for
|
||||||
|
knowledge transfer — never auto-executes or downgrades safety
|
||||||
|
|
||||||
|
`cya` remains a helper **alongside** the shell, not a replacement for it.
|
||||||
|
|
||||||
|
## Background & References
|
||||||
|
|
||||||
|
- **INTENT.md:** interaction history under user control; recent command history when permitted.
|
||||||
|
- **SCOPE.md (current):** REPL and shell history explicitly deferred — this workplan
|
||||||
|
proposes a scoped update in T01.
|
||||||
|
- **CYA-WP-0006 (done):** Profile 1 reflections + `cya retrospect` — end-of-session learning.
|
||||||
|
- **wiki/CyaSpeechModeExtension.md:** prior session-mode thinking (voice bridge later).
|
||||||
|
- **Collector contract:** `src/cya/context/collector.py` — history excluded until opt-in.
|
||||||
|
- **State Hub:** non-runtime per `repo-boundary.md`; HTTP read at session start, write on
|
||||||
|
explicit operator action only.
|
||||||
|
|
||||||
|
## Non-Goals (for this slice)
|
||||||
|
|
||||||
|
- Autonomous or background command execution
|
||||||
|
- Full phase-memory graph / Profile 2 synthesis
|
||||||
|
- Voice / phone bridge (see wiki extension doc)
|
||||||
|
- Team-shared or hosted session state
|
||||||
|
- Deep semantic repo indexing or embeddings
|
||||||
|
- CI / PyPI automation
|
||||||
|
- Replacing State Hub MCP or custodian workflows
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **CYA-WP-0008** (llm-connect adapter): required for *conversational quality* in the REPL;
|
||||||
|
CYA-WP-0007 MVP (T02–T05) can ship with `FakeLLMAdapter` for scaffolding and
|
||||||
|
history/hub/explain paths.
|
||||||
|
- **llm-connect** repo: stable client behind `LLMAdapter` Protocol.
|
||||||
|
- **State Hub API** at `http://127.0.0.1:8000` (or tunnel) for hub orientation tasks.
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
### T01 — Design doc and SCOPE alignment
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52"
|
||||||
|
```
|
||||||
|
|
||||||
|
Produce `docs/cya-interactive-shell-session-design.md` covering:
|
||||||
|
|
||||||
|
- REPL UX (`cya shell`), slash commands, session file layout
|
||||||
|
- Shell history opt-in model (config + flag), caps, redaction rules, provenance
|
||||||
|
- State Hub read/write boundaries and slash commands
|
||||||
|
- Episodic session log schema (`kind: session_turn` precursor)
|
||||||
|
- Weakness-hint rule catalog (v1, deterministic)
|
||||||
|
- SCOPE.md update: move REPL from out-of-scope to owned capability
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Design doc reviewed against INTENT, SCOPE, MemoryVision, repo-boundary.
|
||||||
|
- Explicit checklist gates T02–T08.
|
||||||
|
|
||||||
|
### T02 — REPL skeleton and session state
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29"
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement `cya shell` Typer subcommand:
|
||||||
|
|
||||||
|
- Readline or `prompt_toolkit` loop with prompt `cya> `
|
||||||
|
- Session id + user-owned artifact at `~/.config/cya/sessions/<id>.jsonl`
|
||||||
|
- In-session turn buffer (user + assistant text, timestamps)
|
||||||
|
- Slash commands: `/exit`, `/help`, `/explain` (last turn context)
|
||||||
|
- Graceful handling of EOF, Ctrl-C, non-TTY
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `cya shell` starts, accepts input, persists turns to jsonl, exits cleanly.
|
||||||
|
- No LLM required for loop mechanics (may echo or use FakeLLMAdapter).
|
||||||
|
|
||||||
|
### T03 — Opt-in shell history collector
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend context collection (new module or `collector` extension):
|
||||||
|
|
||||||
|
- Opt-in via `cya shell --with-history` and/or `[shell_history]` in config TOML
|
||||||
|
- Read last N lines from `$HISTFILE` or shell-appropriate fallback (`fc -l`)
|
||||||
|
- Hard cap (default 50 lines), redact secrets (API keys, tokens, passwords)
|
||||||
|
- Provenance per line in context envelope; visible via `/explain`
|
||||||
|
- Default **off** — no history without explicit enable
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- History appears in explain output when enabled; absent when disabled.
|
||||||
|
- Redaction tested with fixture lines containing fake secrets.
|
||||||
|
- Collector core invariants preserved for one-shot `cya "..."` (history off by default).
|
||||||
|
|
||||||
|
### T04 — State Hub session orientation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d"
|
||||||
|
```
|
||||||
|
|
||||||
|
At session start, load orientation (graceful if hub offline):
|
||||||
|
|
||||||
|
- `.custodian-brief.md` when present
|
||||||
|
- Active workstreams for topic `64418556-3206-457a-ba29-6884b5b12cf3`
|
||||||
|
- Unread inbox for `to_agent=can-you-assist`
|
||||||
|
|
||||||
|
Slash commands:
|
||||||
|
|
||||||
|
- `/hub` — show active workstreams + open tasks for current repo
|
||||||
|
- `/hub log "summary"` — POST progress event (operator confirms)
|
||||||
|
- `/inbox` — show unread messages (mark read only after explicit action)
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Session banner shows brief summary when hub reachable.
|
||||||
|
- Hub offline degrades gracefully; shell still usable.
|
||||||
|
- No automatic hub writes without confirmation.
|
||||||
|
|
||||||
|
### T05 — Wire REPL through orchestrator and safety
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T05
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16"
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect each REPL turn to existing pipeline:
|
||||||
|
|
||||||
|
- `collect()` + optional history + memory recall + `classify()` + adapter + render
|
||||||
|
- Mandatory confirmation for non-safe levels (same as one-shot)
|
||||||
|
- Memory surfacing (Profile 1 reflections) in multi-turn output
|
||||||
|
- `/explain` shows full envelope for last turn
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Destructive intent in REPL still requires confirmation; memory cannot bypass.
|
||||||
|
- Turn output consistent with one-shot `cya "..."` for same input.
|
||||||
|
|
||||||
|
### T06 — End-of-session learning and export
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T06
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16"
|
||||||
|
```
|
||||||
|
|
||||||
|
On `/exit` or session end:
|
||||||
|
|
||||||
|
- Offer inline Profile 1 lesson capture (reuse `memory/reflections.py` helpers)
|
||||||
|
- Optional `cya retrospect`-style mini-flow without leaving shell history
|
||||||
|
- `/export-session` — write redacted session summary JSON for knowledge transfer
|
||||||
|
- Store episodic turns with `kind: session_turn` (new constant) when user confirms
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Session export is user-triggered and inspectable.
|
||||||
|
- Reflections from shell session appear in `cya memory reflections`.
|
||||||
|
- Skip path leaves no orphan records.
|
||||||
|
|
||||||
|
### T07 — Weakness hints (rule-based v1)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T07
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "fbdd4f04-76ef-4a18-8c40-b41a134a743b"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deterministic post-turn or end-session hints:
|
||||||
|
|
||||||
|
- Credential anti-patterns in discussed commands (paste keys, `warden route` bypass)
|
||||||
|
- Repeated destructive suggestions without confirmation
|
||||||
|
- State Hub alignment warnings (active work not matching stated goal)
|
||||||
|
|
||||||
|
Surface as informational panels — not auto-fixes. Optional save as reflection.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- At least three hint rules with tests.
|
||||||
|
- Hints never change risk level or skip confirmation.
|
||||||
|
|
||||||
|
### T08 — Tests, docs, and operator guide
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T08
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tests/test_shell_session.py` — REPL loop, history opt-in, hub degrade, safety
|
||||||
|
- README section: starting a shell session, history opt-in, hub commands
|
||||||
|
- AGENTS.md command reference update
|
||||||
|
- Example operator session in docs
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `make test` passes.
|
||||||
|
- README documents `cya shell` with example transcript.
|
||||||
|
|
||||||
|
### T09 — Register, sync, and handoff
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0007-T09
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "3c932e69-ee0a-4135-be11-f890da09509d"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `make fix-consistency REPO=can-you-assist`, log progress, move to `active` when
|
||||||
|
implementation begins.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
When complete:
|
||||||
|
|
||||||
|
- Operators can run `cya shell`, pick up recent shell context (opt-in), see hub work,
|
||||||
|
get multi-turn assistance with full safety, and export learnings for transfer.
|
||||||
|
- All artifacts remain user-controlled and explainable.
|
||||||
|
- SCOPE reflects interactive session as an owned capability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
179
workplans/CYA-WP-0008-llm-connect-adapter-integration.md
Normal file
179
workplans/CYA-WP-0008-llm-connect-adapter-integration.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
---
|
||||||
|
id: CYA-WP-0008
|
||||||
|
type: workplan
|
||||||
|
title: "llm-connect Adapter Integration for Production Assistance"
|
||||||
|
domain: agents
|
||||||
|
repo: can-you-assist
|
||||||
|
status: finished
|
||||||
|
owner: grok
|
||||||
|
topic_slug: foerster-capabilities
|
||||||
|
created: "2026-06-22"
|
||||||
|
updated: "2026-06-22"
|
||||||
|
state_hub_workstream_id: "f713db12-5b90-4453-8fbb-a0e50f61699b"
|
||||||
|
---
|
||||||
|
|
||||||
|
# CYA-WP-0008: llm-connect Adapter Integration
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace `FakeLLMAdapter` on production code paths with a real **llm-connect** client
|
||||||
|
behind the existing `LLMAdapter` Protocol, so one-shot `cya "..."` and `cya shell`
|
||||||
|
(CYA-WP-0007) can deliver genuinely useful multi-turn assistance.
|
||||||
|
|
||||||
|
Preserve the seam: `cya` never hard-codes a vendor; all provider config lives in
|
||||||
|
llm-connect. Credential routing via `warden route` before requesting secrets.
|
||||||
|
|
||||||
|
## Background & References
|
||||||
|
|
||||||
|
- **Seam:** `src/cya/llm/adapter.py` — `LLMAdapter` Protocol + `FakeLLMAdapter`
|
||||||
|
- **INTENT.md:** `cya` asks; `llm-connect` reaches infrastructure
|
||||||
|
- **SCOPE.md:** real client listed as explicit out-of-scope / future work
|
||||||
|
- **CYA-WP-0007:** REPL usable with fake adapter for scaffolding; quality needs this slice
|
||||||
|
- **Credential routing:** `AGENTS.md` / `warden route find` — no secrets in repo
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Provider-specific UI inside `cya` (model picker beyond minimal config)
|
||||||
|
- Token billing dashboards or cost analytics
|
||||||
|
- Embedding / RAG pipelines
|
||||||
|
- Bypassing `LLMAdapter` Protocol for any production path
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **llm-connect** package/API stability (coordinate with that repo owner)
|
||||||
|
- Operator-provided credentials via OpenBao paths surfaced by `warden route`
|
||||||
|
- CYA-WP-0007 T05 can proceed in parallel using `FakeLLMAdapter`
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
### T01 — Adapter contract review and llm-connect API survey
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "483d13bb-aabe-48ad-96c2-8df83de5f442"
|
||||||
|
```
|
||||||
|
|
||||||
|
Document mapping from `AssistanceRequest` / `AssistanceResponse` to llm-connect calls.
|
||||||
|
Identify config surface (TOML keys, env vars). Note gaps requiring llm-connect changes.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Short integration note in `docs/` or workplan appendix.
|
||||||
|
- Credential route catalog id(s) documented via `warden route find`.
|
||||||
|
|
||||||
|
**Delivered:** `docs/llm-connect-integration.md`
|
||||||
|
|
||||||
|
### T02 — Implement `LLMConnectAdapter`
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "0fc17ad5-d90b-4ad1-b060-a1a2f9c25ea8"
|
||||||
|
```
|
||||||
|
|
||||||
|
New class in `src/cya/llm/` implementing `LLMAdapter`:
|
||||||
|
|
||||||
|
- Delegates to llm-connect client
|
||||||
|
- Graceful degrade message when llm-connect unavailable / misconfigured
|
||||||
|
- `FakeLLMAdapter` remains for tests and `--offline` mode
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Protocol-compliant; swap via config or env (`CYA_LLM_ADAPTER=connect|fake`).
|
||||||
|
|
||||||
|
**Delivered:** `src/cya/llm/connect_adapter.py`, `src/cya/llm/factory.py`
|
||||||
|
|
||||||
|
### T03 — Configuration and developer ergonomics
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T03
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "e8470a37-ecec-42f1-920b-ccd8b98b5512"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `~/.config/cya/config.toml` `[llm]` section (backend, model hints)
|
||||||
|
- Document `warden route` steps in README
|
||||||
|
- `make dev-install` optional extra `[llm]` dependency group if needed
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Operator can configure adapter without editing source.
|
||||||
|
- No secrets committed; example config uses placeholders.
|
||||||
|
|
||||||
|
**Delivered:** `src/cya/config.py`, `docs/cya-config.example.toml`, README section
|
||||||
|
|
||||||
|
### T04 — Orchestrator and shell integration
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T04
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "f2781963-fecf-4576-96de-bd745df271a0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire `handle_request()` and CYA-WP-0007 shell turns to adapter selection:
|
||||||
|
|
||||||
|
- Default fake when unconfigured (current behavior)
|
||||||
|
- Real adapter when config present
|
||||||
|
- Multi-turn context: pass recent session turns in `AssistanceRequest.context`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- One-shot and shell paths use same adapter factory.
|
||||||
|
- Session context bounded (token/line budget documented).
|
||||||
|
|
||||||
|
**Delivered:** `get_adapter()` wired in orchestrator; `session_turns` + `bound_session_turns()` ready for CYA-WP-0007 shell
|
||||||
|
|
||||||
|
### T05 — Tests and offline CI strategy
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T05
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "32de980b-1a24-4159-9550-7c516570cae3"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mock llm-connect for unit tests (no live API in default `make test`)
|
||||||
|
- Optional integration test marker `@pytest.mark.llm_live` for manual runs
|
||||||
|
- Fake adapter remains default in CI
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `make test` passes without network or API keys.
|
||||||
|
- Live test documented for operator manual verification.
|
||||||
|
|
||||||
|
**Delivered:** `tests/test_llm_factory.py`, `tests/test_llm_connect_adapter.py`, `tests/test_llm_prompt.py`
|
||||||
|
|
||||||
|
### T06 — Documentation and SCOPE update
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T06
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "2d152d4b-e4b2-4a94-8f85-d8f033e55d5f"
|
||||||
|
```
|
||||||
|
|
||||||
|
Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Clear "configured vs offline" operator paths documented.
|
||||||
|
|
||||||
|
### T07 — Register, sync, and handoff
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CYA-WP-0008-T07
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "2fb42517-b2df-43d3-8195-f02d310107dc"
|
||||||
|
```
|
||||||
|
|
||||||
|
`make fix-consistency`, progress event, coordinate with llm-connect repo if API gaps found.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- Configured `cya` uses llm-connect for real inference.
|
||||||
|
- Unconfigured / test environments behave exactly as today (fake adapter).
|
||||||
|
- CYA-WP-0007 shell session becomes practically useful once both workplans complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status note:** `finished` on 2026-06-22. Integration doc: `docs/llm-connect-integration.md`.
|
||||||
Reference in New Issue
Block a user