generated from coulomb/repo-seed
Compare commits
11 Commits
f0ee0636c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5df8ee5b | |||
| 6018e42930 | |||
| f15d253e64 | |||
| a6266f7e2c | |||
| 8e39e8e27c | |||
| a0f79af2ab | |||
| 79736d8b08 | |||
| d95dbb8ef5 | |||
| 019f6e7dc7 | |||
| cd5db14fbf | |||
| 34528308bb |
@@ -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
|
||||
|
||||
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 |
|
||||
| `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
|
||||
|
||||
**Request pipeline** (`src/cya/orchestrator.py`):
|
||||
1. Collect local context (`context/collector.py`)
|
||||
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.
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- Memory contract: `MemoryVision.md`
|
||||
- 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
|
||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# 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**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
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
|
||||
|
||||
Prefer `warden route find` for repo-specific needs. Common routes:
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | 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 |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
## 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.
|
||||
|
||||
**Step 1 — Read, don't write**
|
||||
- `INTENT.md`, `README.md`, `SCOPE.md`, `AGENTS.md`, `MemoryVision.md`
|
||||
- Scan repo root: `src/cya/`, `workplans/`, `tests/`
|
||||
- `~/the-custodian/canon/projects/agents/project_charter_v0.1.md` — purpose, scope
|
||||
- `~/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**
|
||||
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||
|
||||
**Step 3 — Propose workstreams to Bernd**
|
||||
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to
|
||||
INTENT/SCOPE. **Wait for approval before creating.**
|
||||
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||
roadmap phase. **Wait for approval before creating.**
|
||||
|
||||
**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**
|
||||
```
|
||||
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",
|
||||
topic_id="64418556-3206-457a-ba29-6884b5b12cf3",
|
||||
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
|
||||
|
||||
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`
|
||||
- Durable memory storage, profile planners, graph/event stores, or lifecycle algorithms → `phase-memory`
|
||||
- Global work tracking, decisions, or cross-repo coordination → State Hub / custodian
|
||||
- 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.
|
||||
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||
- SSH key management → railiance-infra/
|
||||
- State hub code → state-hub/
|
||||
-->
|
||||
|
||||
@@ -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
|
||||
**Topic ID:** 64418556-3206-457a-ba29-6884b5b12cf3
|
||||
**Workplan prefix:** CYA-WP-
|
||||
**Command name:** cya
|
||||
@@ -1,6 +1,7 @@
|
||||
## 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**
|
||||
|
||||
@@ -10,12 +11,11 @@ cat .custodian-brief.md
|
||||
```
|
||||
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:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
|
||||
| python3 -m json.tool
|
||||
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||
```
|
||||
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/
|
||||
```
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`todo`/`in_progress` tasks.
|
||||
`wait`/`todo`/`progress` tasks.
|
||||
|
||||
**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
|
||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||
- `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`).
|
||||
|
||||
**During work:**
|
||||
- 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()`
|
||||
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||
|
||||
> 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).
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **Language:** Python 3.11+
|
||||
- **CLI:** Typer + Rich
|
||||
- **Config:** TOML
|
||||
- **Packaging:** setuptools + setuptools_scm (`src/` layout)
|
||||
- **Tests:** pytest
|
||||
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||
- **Language:**
|
||||
- **Key deps:**
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
# Recommended: install latest development code
|
||||
make dev-install
|
||||
# TODO: Fill in the standard commands for this repo
|
||||
|
||||
# Install dependencies
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
# or: python3 -m pytest tests/ -q
|
||||
|
||||
# Build distribution packages
|
||||
make dist
|
||||
make release-prep
|
||||
make check-dist
|
||||
# Lint / type check
|
||||
|
||||
# Run the assistant
|
||||
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
|
||||
# Build / package (if applicable)
|
||||
```
|
||||
|
||||
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
|
||||
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 — can-you-assist
|
||||
|
||||
**Domain:** capabilities
|
||||
**Last synced:** 2026-06-21 23:25 UTC
|
||||
**Domain:** agents
|
||||
**Last synced:** 2026-06-23 12:26 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Current Goal
|
||||
@@ -17,6 +17,6 @@ Console-native user-controlled LLM assistant
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("capabilities")`
|
||||
`get_domain_summary("agents")`
|
||||
This provides richer cross-domain context.
|
||||
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.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
uv.lock
|
||||
|
||||
# poetry
|
||||
# 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
|
||||
305
AGENTS.md
305
AGENTS.md
@@ -1,227 +1,108 @@
|
||||
# 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.
|
||||
# can-you-assist — Agent Instructions
|
||||
|
||||
## Repo Identity
|
||||
|
||||
**Purpose:** Console-native, backend-agnostic LLM assistant 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
|
||||
**Topic ID:** `64418556-3206-457a-ba29-6884b5b12cf3`
|
||||
**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
|
||||
|
||||
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 |
|
||||
|---------|-----|
|
||||
| Local workstation | `http://127.0.0.1:8000` |
|
||||
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||
|
||||
### Orient At Session Start
|
||||
### Orient at session start
|
||||
|
||||
```bash
|
||||
# Offline brief - generated by State Hub consistency tooling when available
|
||||
# Offline brief — works without hub connection
|
||||
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" \
|
||||
| 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" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
Mark a message read:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
### Update Task Status
|
||||
|
||||
```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:
|
||||
### Log progress (required at session close)
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"summary": "what changed",
|
||||
"summary": "what was done",
|
||||
"event_type": "note",
|
||||
"author": "grok",
|
||||
"author": "codex",
|
||||
"workstream_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
|
||||
|
||||
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`.
|
||||
2. Read `.custodian-brief.md` if present; if absent, use the State Hub API
|
||||
queries above.
|
||||
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:**
|
||||
- Update task statuses in workplan files as tasks progress
|
||||
- Record significant decisions via `POST /decisions/`
|
||||
|
||||
During work:
|
||||
|
||||
- Keep changes small and inspectable.
|
||||
- Treat command execution safety as product behavior: explain destructive or
|
||||
broad filesystem commands and require explicit confirmation before suggesting
|
||||
execution.
|
||||
- Before requesting API keys, SSH access, login tokens, or database passwords,
|
||||
run `warden route find` / `warden route show` per **Credential and access routing**.
|
||||
- Do not commit secrets, API keys, local transcripts, private notes, or hidden
|
||||
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
|
||||
# Memory: Profile 0 baseline + production Profile 1 (CYA-WP-0006).
|
||||
# Profiles 2–3 (hierarchical synthesis, procedural rules) remain roadmap — see MemoryVision.md.
|
||||
|
||||
# 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` (finished)
|
||||
- `workplans/CYA-WP-0007-interactive-shell-session.md` (ready — interactive REPL + history + hub)
|
||||
- `workplans/CYA-WP-0008-llm-connect-adapter-integration.md` (ready — real LLM behind adapter seam)
|
||||
**Close:**
|
||||
1. Update workplan file task statuses to reflect progress
|
||||
2. Log: `POST /progress/` with a summary of what changed
|
||||
3. Note for the custodian operator: after workplan file changes, run from
|
||||
`~/state-hub`:
|
||||
```bash
|
||||
make fix-consistency REPO=can-you-assist
|
||||
```
|
||||
This syncs task status from files into the hub DB.
|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
@@ -247,14 +128,13 @@ Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run wa
|
||||
|
||||
### Quick routing table
|
||||
|
||||
Prefer `warden route find` for repo-specific needs. Common routes:
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | 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 |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
@@ -272,52 +152,89 @@ get wrong.
|
||||
|
||||
**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
|
||||
layer that rebuilds from files.
|
||||
Work items originate as files in this repo — not in the hub. The hub is a
|
||||
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
|
||||
`workplans/ADHOC-YYYY-MM-DD.md` with task ids like
|
||||
`ADHOC-YYYY-MM-DD-T01`. Use this only for low-risk work completed directly.
|
||||
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||
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
|
||||
---
|
||||
id: CYA-WP-NNNN
|
||||
id: CAN-WP-NNNN
|
||||
type: workplan
|
||||
title: "..."
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: grok
|
||||
topic_slug: foerster-capabilities
|
||||
owner: codex
|
||||
topic_slug: ...
|
||||
created: "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
|
||||
id: CYA-WP-NNNN-T01
|
||||
status: todo | in_progress | done | blocked
|
||||
` ` `task
|
||||
id: CAN-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
|
||||
```
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
` ` `
|
||||
|
||||
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/repo-boundary.md
|
||||
@.claude/rules/credential-routing.md
|
||||
@.claude/rules/agents.md
|
||||
@.claude/rules/agents.md
|
||||
|
||||
97
README.md
97
README.md
@@ -16,7 +16,7 @@ usable after `pip install -e .`:
|
||||
- `cya "your request in plain English"`
|
||||
- `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
|
||||
- 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
|
||||
|
||||
@@ -58,6 +58,41 @@ git pull
|
||||
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
|
||||
|
||||
```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
|
||||
clear preview + confirmation prompt. Nothing executes without your explicit yes.
|
||||
|
||||
## Interactive Shell Sessions
|
||||
|
||||
Start a stateful console session when you want continuity across turns:
|
||||
|
||||
```bash
|
||||
cya shell
|
||||
cya shell --offline --no-hub # local smoke / air-gapped mode
|
||||
cya shell --with-history # opt in to capped, redacted shell history
|
||||
```
|
||||
|
||||
Each session writes a user-owned JSONL artifact under:
|
||||
|
||||
```text
|
||||
~/.config/cya/sessions/<session-id>.jsonl
|
||||
```
|
||||
|
||||
Useful slash commands:
|
||||
|
||||
```text
|
||||
/help show shell commands
|
||||
/explain show last turn context, risk, history, hub, hints
|
||||
/hub show State Hub workstreams and inbox orientation
|
||||
/hub log "summary" post a progress note after confirmation
|
||||
/inbox show unread State Hub messages
|
||||
/export-session write a redacted JSON session summary
|
||||
/learn capture Profile 1 reflections now
|
||||
/exit close the session
|
||||
```
|
||||
|
||||
Shell history is off by default. `--with-history` or `[shell_history]` config
|
||||
includes at most 50 recent lines from `$HISTFILE` or common shell history files,
|
||||
redacts secret-like values, and exposes provenance in `/explain`. One-shot
|
||||
`cya "..."` requests remain history-free by default.
|
||||
|
||||
Example transcript:
|
||||
|
||||
```text
|
||||
$ cya shell --offline --no-hub
|
||||
cya> summarize recent git changes
|
||||
... standard orchestrator response ...
|
||||
cya> /explain
|
||||
... context envelope, memory, shell history status, hub summary, and hints ...
|
||||
cya> /export-session
|
||||
Exported session summary: ~/.config/cya/sessions/cya-...-summary.json
|
||||
cya> /exit
|
||||
Session saved: ~/.config/cya/sessions/cya-...jsonl
|
||||
```
|
||||
|
||||
State Hub writes never happen automatically. `/hub log` and `/inbox read` are
|
||||
explicit operator actions; `/hub log` also asks for confirmation.
|
||||
|
||||
See `docs/cya-interactive-shell-session-design.md` and
|
||||
`docs/cya-shell-operator-session.md` for the full design and operator example.
|
||||
|
||||
## Safety (core product behavior)
|
||||
|
||||
- Genuine rule-based assessment is the primary mechanism.
|
||||
@@ -190,9 +279,11 @@ decisions, and integration guide.
|
||||
```bash
|
||||
# Recommended one-liner (see Installation section above)
|
||||
make dev-install
|
||||
pip install -e ~/llm-connect # optional, for live inference
|
||||
|
||||
pytest tests/ -q
|
||||
cya "..." # manual verification
|
||||
pytest tests/ -q # offline mocks only; no API keys
|
||||
pytest -m llm_live # manual live check (requires OPENROUTER_API_KEY)
|
||||
cya --offline "..." # manual verification without network
|
||||
make version # show current dev version
|
||||
```
|
||||
|
||||
|
||||
21
SCOPE.md
21
SCOPE.md
@@ -6,7 +6,7 @@
|
||||
|
||||
It allows users to express intent in natural language from the terminal and receive safe, explainable, context-aware assistance while keeping memory, history, preferences, and adaptation under explicit user control.
|
||||
|
||||
## Current Status (Post CYA-WP-0004 Packaging & Distribution Slice)
|
||||
## Current Status (Post CYA-WP-0007 Interactive Shell Session Slice)
|
||||
|
||||
Four implementation slices have been delivered:
|
||||
|
||||
@@ -16,17 +16,19 @@ Four implementation slices have been delivered:
|
||||
- **Profile 0 baseline (post-0003, formalized in CYA-WP-0005 T02)**: The current shipped memory implementation (local JSON + kinds + activation_context + provenance + retrospection helper) is now explicitly documented as **Profile 0** — the stable, high-quality foundation for future self-improving profiles 1–3. See MemoryVision.md for the full baseline description.
|
||||
- **CYA-WP-0005 (Agentic Memory Profiles + first self-improvement capability)**: Complete profile model (Profile 0 baseline + detailed definitions + integration plans + Capability Matrix for Profiles 1–3) plus initial **Profile 1** delivery. **CYA-WP-0006** hardened Profile 1 to production quality: guided 1–3 lesson capture with preview in `cya retrospect`, `cya memory reflections`, near-duplicate compaction, and surfacing in responses / `--explain-context`. See MemoryVision.md and `docs/CYA-WP-0006-profile-1-gap-checklist.md`.
|
||||
- **CYA-WP-0004 (Dev-Head Install & Release Packaging)**: Reliable installation from development head (`make dev-install`, direct `git+` installs), dynamic versioning via `setuptools_scm`, clean distribution package building (`python -m build` + verification), lightweight release process, and supporting documentation/Makefile.
|
||||
- **CYA-WP-0007 (Interactive Shell Session)**: `cya shell` REPL, session JSONL artifacts, optional capped/redacted shell history, read-only State Hub orientation, explicit hub slash-command writes, end-session learning/export, and deterministic weakness hints.
|
||||
|
||||
Core capabilities now include:
|
||||
- Natural language request handling via clean Typer CLI.
|
||||
- Bounded, transparent local context collection.
|
||||
- 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).
|
||||
- Automatic memory activation based on working directory/git root.
|
||||
- `cya retrospect` for structured reflection and goal setting, with production Profile 1 verbal lesson capture, review (`cya memory reflections`), and compaction.
|
||||
- Full developer workflow: dev-head install, testing, building distribution packages, and a documented release process.
|
||||
- Transparent, inspectable behavior via `--explain-context`.
|
||||
- Transparent, inspectable behavior via `--explain-context` and shell `/explain`.
|
||||
- Stateful `cya shell` sessions with local JSONL artifacts, slash commands, optional history context, hub orientation, export, and opt-in learning.
|
||||
|
||||
All LLM interaction flows through the documented adapter seam. Memory flows through explicit ports. Packaging and distribution are now first-class concerns with a clear path forward. No production path bypasses the defined boundaries.
|
||||
|
||||
@@ -39,7 +41,8 @@ All LLM interaction flows through the documented adapter seam. Memory flows thro
|
||||
- Orchestration of the request → context → safety → LLM adapter → response pipeline.
|
||||
- The stable `LLMAdapter` Protocol and the contract for how `cya` talks to LLM backends.
|
||||
- Explicit, now real (persisting) integration with user-controlled memory via `phase-memory` ports.
|
||||
- Transparent, inspectable behavior (especially via `--explain-context`).
|
||||
- Transparent, inspectable behavior (especially via `--explain-context` and shell `/explain`).
|
||||
- Interactive `cya shell` sessions: prompt loop, session state, local artifacts, slash commands, optional shell-history context, hub orientation, export, and end-session learning prompts.
|
||||
- User-facing documentation, examples, and safety guarantees for the CLI tool.
|
||||
|
||||
## Does Not Own
|
||||
@@ -51,7 +54,7 @@ All LLM interaction flows through the documented adapter seam. Memory flows thro
|
||||
- Deep repository indexing, embeddings, or large-scale content analysis (explicit non-goal of the first slice).
|
||||
- Voice, speech, phone-bridge, or non-terminal interfaces (future work).
|
||||
- Production PyPI publishing and automated release CI (documented process and local tooling exist; actual publication is future work).
|
||||
- Long-lived conversational REPL or session state (one-shot + very lightweight session only).
|
||||
- Hosted, team-shared, or background session state; `cya shell` artifacts remain local and user-owned.
|
||||
|
||||
## Integrates With
|
||||
|
||||
@@ -77,10 +80,10 @@ See the individual workplans for detailed scope per slice.
|
||||
## 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).
|
||||
- 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.
|
||||
- Automatic command execution (even "safe" suggestions) — explicit user confirmation remains mandatory for anything non-safe.
|
||||
- Rich multi-turn conversational state beyond lightweight scoped memory + retrospection.
|
||||
- Hosted/team-shared shell sessions or autonomous background agents.
|
||||
- Cost tracking, token budgeting, or usage dashboards.
|
||||
- Team/shared memory or collaboration features.
|
||||
- Plugin system or domain-specific extensions.
|
||||
@@ -93,6 +96,7 @@ See the individual workplans for detailed scope per slice.
|
||||
- `cya/safety/risk.py` — the `_RULES` table and `classify()` function (memory-aware).
|
||||
- `cya/context/collector.py` — collection functions and ignore policy.
|
||||
- `cya/orchestrator.py` — the main coordination entry point.
|
||||
- `cya/shell_session.py` — interactive shell runtime, slash commands, session artifacts, optional history, hub orientation, and weakness hints.
|
||||
- Packaging & distribution: `Makefile`, `pyproject.toml`, `docs/release-process.md`, and `MANIFEST.in` (first-class concern with registered future work).
|
||||
|
||||
## Success Criteria (Current State)
|
||||
@@ -101,6 +105,7 @@ A new user or contributor can:
|
||||
- Install the latest development code easily (`make dev-install` or direct git+) and use `cya` for realistic tasks after reading the README.
|
||||
- Understand exactly what context and memory influenced a response via `--explain-context`.
|
||||
- Trust that dangerous actions will never execute without explicit confirmation.
|
||||
- Start `cya shell` for multi-turn help, inspect `/explain`, export a redacted session, and opt in explicitly before shell history or session-turn memory is used.
|
||||
- Use `cya retrospect` to reflect on usage and set goals that influence future behavior.
|
||||
- Build and verify distribution packages locally.
|
||||
- See a clear path for how real `llm-connect`, deeper `phase-memory`, and future PyPI releases will integrate.
|
||||
@@ -109,7 +114,7 @@ Sibling project owners (llm-connect, phase-memory, State Hub) can read the workp
|
||||
|
||||
---
|
||||
|
||||
**This SCOPE document reflects the state after CYA-WP-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.
|
||||
|
||||
|
||||
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 = [
|
||||
"typer[standard]>=0.12.0",
|
||||
"rich>=13.0.0",
|
||||
"tomli>=2.0.0; python_version<'3.11'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -26,6 +27,9 @@ dev = [
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
]
|
||||
llm = [
|
||||
# Install llm-connect from a sibling checkout, e.g. pip install -e ~/llm-connect
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cya = "cya.cli.main:run"
|
||||
@@ -57,6 +61,7 @@ python_functions = ["test_*"]
|
||||
addopts = "-q --tb=short"
|
||||
markers = [
|
||||
"safety: core safety and risk classifier invariants (always run)",
|
||||
"llm_live: live llm-connect inference (requires API key; manual runs only)",
|
||||
]
|
||||
|
||||
[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
|
||||
updated: '2026-06-16'
|
||||
updated: '2026-07-06'
|
||||
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
|
||||
|
||||
@@ -66,6 +66,11 @@ def main(
|
||||
"-n",
|
||||
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(
|
||||
None,
|
||||
"--version",
|
||||
@@ -106,6 +111,53 @@ def main(
|
||||
request,
|
||||
explain_context=explain_context,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -189,8 +241,136 @@ def retrospect(
|
||||
run_retrospection(scope=scope, limit=limit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
def _dispatch_shell_argv(argv: list[str] | None = None) -> bool:
|
||||
"""Handle `cya shell ...` before Typer's root REQUEST argument sees it."""
|
||||
import argparse
|
||||
|
||||
argv = list(sys.argv if argv is None else argv)
|
||||
if len(argv) < 2 or argv[1] != "shell":
|
||||
return False
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=f"{argv[0]} shell",
|
||||
description="Start an interactive, stateful cya shell session.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-history",
|
||||
action="store_true",
|
||||
help="Opt in to capped, redacted shell-history context for this session.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--history-limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum history lines to include, capped at the configured safe limit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline",
|
||||
action="store_true",
|
||||
help="Use the deterministic FakeLLMAdapter for this shell session.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hub-url",
|
||||
default="http://127.0.0.1:8000",
|
||||
help="State Hub base URL for read-only session orientation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-hub",
|
||||
action="store_true",
|
||||
help="Skip State Hub HTTP orientation; still reads .custodian-brief.md if present.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session-lessons",
|
||||
dest="offer_session_lessons",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Offer end-of-session Profile 1 reflection and session_turn memory capture.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-session-lessons",
|
||||
dest="offer_session_lessons",
|
||||
action="store_false",
|
||||
help="Skip end-of-session learning prompts.",
|
||||
)
|
||||
args = parser.parse_args(argv[2:])
|
||||
|
||||
from cya.shell_session import run_shell
|
||||
|
||||
run_shell(
|
||||
with_history=args.with_history,
|
||||
history_limit=args.history_limit,
|
||||
offline=args.offline,
|
||||
hub_url=args.hub_url,
|
||||
no_hub=args.no_hub,
|
||||
offer_end_learning=args.offer_session_lessons,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _dispatch_memory_argv(argv: list[str] | None = None) -> bool:
|
||||
"""Handle `cya memory ...` before the root REQUEST argument sees it."""
|
||||
import argparse
|
||||
|
||||
argv = list(sys.argv if argv is None else argv)
|
||||
if len(argv) < 2 or argv[1] != "memory":
|
||||
return False
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=f"{argv[0]} memory",
|
||||
description="Inspect and manage user-controlled memory.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
reflections = sub.add_parser("reflections", help="List or export Profile 1 reflections.")
|
||||
reflections.add_argument(
|
||||
"--scope",
|
||||
"-s",
|
||||
default=".",
|
||||
help="Directory or scope to list reflections for.",
|
||||
)
|
||||
reflections.add_argument(
|
||||
"--json",
|
||||
dest="export_json",
|
||||
action="store_true",
|
||||
help="Export reflections as JSON.",
|
||||
)
|
||||
args = parser.parse_args(argv[2:])
|
||||
|
||||
if args.command == "reflections":
|
||||
memory_reflections(scope=args.scope, export_json=args.export_json)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _dispatch_retrospect_argv(argv: list[str] | None = None) -> bool:
|
||||
"""Handle `cya retrospect ...` before the root REQUEST argument sees it."""
|
||||
import argparse
|
||||
|
||||
argv = list(sys.argv if argv is None else argv)
|
||||
if len(argv) < 2 or argv[1] != "retrospect":
|
||||
return False
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=f"{argv[0]} retrospect",
|
||||
description="Start a guided retrospection session.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
"-s",
|
||||
default=".",
|
||||
help="Directory or scope to run retrospection on.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Number of recent memory items to review.",
|
||||
)
|
||||
args = parser.parse_args(argv[2:])
|
||||
|
||||
from cya.orchestrator import run_retrospection
|
||||
|
||||
run_retrospection(scope=args.scope, limit=args.limit)
|
||||
return True
|
||||
|
||||
|
||||
def run() -> None:
|
||||
@@ -200,4 +380,10 @@ def run() -> None:
|
||||
Using a thin wrapper around app() lets us keep the full-featured
|
||||
@app.callback(invoke_without_command=True) + ctx signature for Typer/Click.
|
||||
"""
|
||||
if _dispatch_shell_argv() or _dispatch_memory_argv() or _dispatch_retrospect_argv():
|
||||
return
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
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,
|
||||
FakeLLMAdapter,
|
||||
)
|
||||
from .connect_adapter import LLMConnectAdapter
|
||||
from .factory import get_adapter
|
||||
|
||||
__all__ = [
|
||||
"AssistanceRequest",
|
||||
"AssistanceResponse",
|
||||
"LLMAdapter",
|
||||
"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_INTERACTION_GOAL = "interaction_goal"
|
||||
KIND_REFLECTION = "reflection" # Profile 1 (Reflexion-style verbal lessons) — T05 minimal spike
|
||||
KIND_SESSION_TURN = "session_turn" # CYA-WP-0007 episodic shell turn precursor
|
||||
|
||||
|
||||
def _warn_not_connected(feature: str) -> None:
|
||||
@@ -311,5 +312,6 @@ __all__ = [
|
||||
"KIND_RETROSPECTION",
|
||||
"KIND_INTERACTION_GOAL",
|
||||
"KIND_REFLECTION",
|
||||
"KIND_SESSION_TURN",
|
||||
]
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ See workplan CYA-WP-0001 (core) + CYA-WP-0003 (memory wiring + retrospect)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
@@ -48,18 +50,58 @@ from cya.memory.reflections import (
|
||||
session_provenance,
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorResult:
|
||||
"""Structured result from a single assistance turn.
|
||||
|
||||
CLI callers still receive the rich terminal rendering. Interactive clients
|
||||
use this object to persist session turns and explain the last turn.
|
||||
"""
|
||||
|
||||
user_request: str
|
||||
assistant: str
|
||||
explanation: str = ""
|
||||
rationale: str = ""
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
memory: dict[str, Any] = field(default_factory=dict)
|
||||
risk: dict[str, Any] = field(default_factory=dict)
|
||||
cancelled: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
def _build_assistance_context(
|
||||
envelope: Any,
|
||||
memory: dict[str, Any],
|
||||
*,
|
||||
session_turns: list[dict[str, str]] | None = None,
|
||||
extra_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ctx = (envelope.to_dict() if envelope else {}) or {}
|
||||
ctx["memory"] = memory
|
||||
if session_turns:
|
||||
ctx["session_turns"] = bound_session_turns(session_turns)
|
||||
if extra_context:
|
||||
ctx["session"] = extra_context
|
||||
return ctx
|
||||
|
||||
|
||||
def handle_request(
|
||||
user_request: str,
|
||||
*,
|
||||
explain_context: 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.
|
||||
|
||||
This is what the CLI (and future tests / other front-ends) should call.
|
||||
@@ -131,8 +173,29 @@ def handle_request(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if explain_context and extra_context:
|
||||
try:
|
||||
import json
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
json.dumps(extra_context, indent=2, default=str),
|
||||
title="Shell Session Context",
|
||||
border_style="cyan",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Risk classification + mandatory confirmation (T03 safety; T04 memory signals)
|
||||
assessment = classify(user_request, envelope, memory=memory)
|
||||
ctx = _build_assistance_context(
|
||||
envelope,
|
||||
memory,
|
||||
session_turns=session_turns,
|
||||
extra_context=extra_context,
|
||||
)
|
||||
|
||||
if assessment.requires_confirmation:
|
||||
from rich.table import Table
|
||||
@@ -151,17 +214,31 @@ def handle_request(
|
||||
console.print(table)
|
||||
|
||||
if not get_user_confirmation(assessment):
|
||||
console.print("[yellow]Action cancelled by user. No changes made.[/yellow]")
|
||||
return
|
||||
msg = "Action cancelled by user. No changes made."
|
||||
console.print(f"[yellow]{msg}[/yellow]")
|
||||
return OrchestratorResult(
|
||||
user_request=user_request,
|
||||
assistant=msg,
|
||||
context=ctx,
|
||||
memory=memory,
|
||||
risk=assessment.to_dict(),
|
||||
cancelled=True,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
console.print("[green]--dry-run acknowledged.[/green] No side-effects.")
|
||||
return
|
||||
msg = "--dry-run acknowledged. No side-effects."
|
||||
console.print(f"[green]{msg}[/green]")
|
||||
return OrchestratorResult(
|
||||
user_request=user_request,
|
||||
assistant=msg,
|
||||
context=ctx,
|
||||
memory=memory,
|
||||
risk=assessment.to_dict(),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
# 3. Call through the single LLMAdapter boundary (T04)
|
||||
adapter = FakeLLMAdapter()
|
||||
ctx = (envelope.to_dict() if envelope else {}) or {}
|
||||
ctx["memory"] = memory # T03: memory now in context passed to LLM (for personalization + explain)
|
||||
# 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008)
|
||||
adapter = get_adapter(offline=offline)
|
||||
llm_request = AssistanceRequest(
|
||||
user_request=user_request,
|
||||
context=ctx,
|
||||
@@ -191,6 +268,16 @@ def handle_request(
|
||||
"[green]✓[/green] Request processed by orchestrator (T02+T03+T04 coordinated by T06)."
|
||||
)
|
||||
|
||||
return OrchestratorResult(
|
||||
user_request=user_request,
|
||||
assistant=llm_response.suggestion,
|
||||
explanation=llm_response.explanation,
|
||||
rationale=llm_response.rationale,
|
||||
context=ctx,
|
||||
memory=memory,
|
||||
risk=assessment.to_dict(),
|
||||
)
|
||||
|
||||
|
||||
def run_retrospection(scope: str = ".", limit: int = 8) -> None:
|
||||
"""Guided retrospection session (T04 of CYA-WP-0003).
|
||||
@@ -382,4 +469,4 @@ def _offer_reflection_compaction(scope: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["handle_request", "run_retrospection"]
|
||||
__all__ = ["OrchestratorResult", "handle_request", "run_retrospection"]
|
||||
|
||||
862
src/cya/shell_session.py
Normal file
862
src/cya/shell_session.py
Normal file
@@ -0,0 +1,862 @@
|
||||
"""Interactive shell session runtime for ``cya shell`` (CYA-WP-0007).
|
||||
|
||||
The shell is a thin interactive client for the existing orchestrator. It owns
|
||||
session-local state, optional history context, State Hub orientation, slash
|
||||
commands, JSONL persistence, and user-triggered learning/export flows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib import parse, request
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cya.config import ShellHistorySettings, load_shell_history_settings
|
||||
from cya.memory import KIND_SESSION_TURN, remember_preference
|
||||
from cya.memory.reflections import (
|
||||
REFLECTION_CAPTURE_PROMPTS,
|
||||
collect_lessons_from_answers,
|
||||
preview_lessons,
|
||||
save_reflection_lessons,
|
||||
)
|
||||
from cya.orchestrator import OrchestratorResult, handle_request
|
||||
|
||||
TOPIC_ID = "64418556-3206-457a-ba29-6884b5b12cf3"
|
||||
DEFAULT_AGENT = "can-you-assist"
|
||||
DEFAULT_HUB_URL = os.environ.get("CYA_STATE_HUB_URL", "http://127.0.0.1:8000")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellHistoryContext:
|
||||
enabled: bool
|
||||
source: str
|
||||
limit: int
|
||||
histfile: str | None = None
|
||||
lines: list[dict[str, Any]] = field(default_factory=list)
|
||||
redactions: int = 0
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubOrientation:
|
||||
base_url: str
|
||||
topic_id: str
|
||||
agent: str
|
||||
brief: str | None = None
|
||||
active_workstreams: list[dict[str, Any]] = field(default_factory=list)
|
||||
inbox: list[dict[str, Any]] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
remote_checked: bool = False
|
||||
|
||||
@property
|
||||
def hub_available(self) -> bool:
|
||||
return self.remote_checked and not self.errors
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["hub_available"] = self.hub_available
|
||||
return data
|
||||
|
||||
def compact(self) -> dict[str, Any]:
|
||||
return {
|
||||
"base_url": self.base_url,
|
||||
"topic_id": self.topic_id,
|
||||
"agent": self.agent,
|
||||
"hub_available": self.hub_available,
|
||||
"active_workstream_count": len(self.active_workstreams),
|
||||
"inbox_count": len(self.inbox),
|
||||
"errors": self.errors,
|
||||
"workstreams": [
|
||||
{
|
||||
"id": w.get("id") or w.get("workstream_id"),
|
||||
"title": w.get("title") or w.get("name"),
|
||||
"status": w.get("status"),
|
||||
}
|
||||
for w in self.active_workstreams[:5]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(
|
||||
r"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET)[A-Z0-9_]*)"
|
||||
r"(\s*=\s*)([^\s;]+)"
|
||||
),
|
||||
re.compile(r"(?i)\b(--(?:api-key|token|password|secret)\s+)([^\s;]+)"),
|
||||
re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"),
|
||||
re.compile(r"\b(gh[pousr]_[A-Za-z0-9_]{12,})\b"),
|
||||
re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]{12,})\b"),
|
||||
re.compile(r"\b(AKIA[0-9A-Z]{12,})\b"),
|
||||
)
|
||||
|
||||
|
||||
InputFunc = Callable[[str], str]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def make_session_id() -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
return f"cya-{stamp}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def session_root() -> Path:
|
||||
root = Path.home() / ".config" / "cya" / "sessions"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def default_session_path(session_id: str) -> Path:
|
||||
return session_root() / f"{session_id}.jsonl"
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> tuple[str, int]:
|
||||
redacted = text
|
||||
count = 0
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
groups = match.groups()
|
||||
if len(groups) >= 3:
|
||||
return f"{groups[0]}{groups[1]}[REDACTED]"
|
||||
return "[REDACTED_SECRET]"
|
||||
|
||||
for pattern in _SECRET_PATTERNS:
|
||||
redacted, n = pattern.subn(_replace, redacted)
|
||||
count += n
|
||||
return redacted, count
|
||||
|
||||
|
||||
def _normalize_history_line(line: str) -> str:
|
||||
text = line.strip()
|
||||
if text.startswith(": ") and ";" in text:
|
||||
return text.split(";", 1)[1].strip()
|
||||
if text.startswith("- cmd: "):
|
||||
return text.removeprefix("- cmd: ").strip()
|
||||
return text
|
||||
|
||||
|
||||
def _default_histfile(env: dict[str, str], home: Path) -> Path | None:
|
||||
explicit = env.get("HISTFILE")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
for candidate in (
|
||||
home / ".bash_history",
|
||||
home / ".zsh_history",
|
||||
home / ".local" / "share" / "fish" / "fish_history",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def collect_shell_history(
|
||||
settings: ShellHistorySettings,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> ShellHistoryContext:
|
||||
env = env or os.environ
|
||||
home = home or Path.home()
|
||||
ctx = ShellHistoryContext(
|
||||
enabled=settings.enabled,
|
||||
source=settings.source,
|
||||
limit=settings.limit,
|
||||
histfile=settings.histfile,
|
||||
notes=list(settings.notes),
|
||||
)
|
||||
|
||||
if not settings.enabled:
|
||||
ctx.notes.append("Shell history disabled; no history was collected.")
|
||||
return ctx
|
||||
if settings.limit <= 0:
|
||||
ctx.notes.append("Shell history enabled with limit 0; no lines collected.")
|
||||
return ctx
|
||||
|
||||
histfile = Path(settings.histfile).expanduser() if settings.histfile else _default_histfile(env, home)
|
||||
if not histfile:
|
||||
ctx.notes.append("No shell history file found from HISTFILE or known fallbacks.")
|
||||
return ctx
|
||||
|
||||
ctx.histfile = str(histfile)
|
||||
try:
|
||||
raw = histfile.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as exc:
|
||||
ctx.notes.append(f"Could not read history file: {exc}")
|
||||
return ctx
|
||||
|
||||
normalized = [_normalize_history_line(line) for line in raw]
|
||||
normalized = [line for line in normalized if line]
|
||||
start = max(0, len(normalized) - settings.limit)
|
||||
for offset, line in enumerate(normalized[start:], start=start + 1):
|
||||
clean, count = redact_secrets(line[:1000])
|
||||
ctx.redactions += count
|
||||
ctx.lines.append(
|
||||
{
|
||||
"line_number": offset,
|
||||
"command": clean,
|
||||
"source": str(histfile),
|
||||
"provenance": "shell_history.histfile",
|
||||
}
|
||||
)
|
||||
if ctx.redactions:
|
||||
ctx.notes.append(f"Redacted {ctx.redactions} secret-like value(s).")
|
||||
return ctx
|
||||
|
||||
|
||||
def render_shell_history_explanation(history: ShellHistoryContext) -> str:
|
||||
lines = [
|
||||
f"enabled: {history.enabled}",
|
||||
f"source: {history.source}",
|
||||
f"limit: {history.limit}",
|
||||
f"histfile: {history.histfile or '(none)'}",
|
||||
f"lines: {len(history.lines)}",
|
||||
]
|
||||
if history.notes:
|
||||
lines.append("notes:")
|
||||
lines.extend(f" - {note}" for note in history.notes)
|
||||
if history.lines:
|
||||
lines.append("commands:")
|
||||
for item in history.lines:
|
||||
lines.append(
|
||||
f" {item['line_number']}: {item['command']} ({item['provenance']})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_json_url(url: str, *, timeout: float) -> Any:
|
||||
req = request.Request(url, headers={"User-Agent": "cya-shell/0.1"})
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
if not data.strip():
|
||||
return []
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _post_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"},
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
if not data.strip():
|
||||
return {}
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _patch_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="PATCH",
|
||||
headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"},
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
if not data.strip():
|
||||
return {}
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
for key in ("items", "results", "data"):
|
||||
if isinstance(value.get(key), list):
|
||||
return [item for item in value[key] if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def load_hub_orientation(
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
base_url: str = DEFAULT_HUB_URL,
|
||||
topic_id: str = TOPIC_ID,
|
||||
agent: str = DEFAULT_AGENT,
|
||||
timeout: float = 1.5,
|
||||
include_remote: bool = True,
|
||||
) -> HubOrientation:
|
||||
cwd = cwd or Path.cwd()
|
||||
orientation = HubOrientation(base_url=base_url.rstrip("/"), topic_id=topic_id, agent=agent)
|
||||
|
||||
brief = cwd / ".custodian-brief.md"
|
||||
if brief.is_file():
|
||||
try:
|
||||
orientation.brief = brief.read_text(encoding="utf-8", errors="replace")[:6000]
|
||||
except OSError as exc:
|
||||
orientation.errors.append(f"brief read failed: {exc}")
|
||||
|
||||
if not include_remote:
|
||||
orientation.errors.append("State Hub remote orientation skipped by --no-hub.")
|
||||
return orientation
|
||||
|
||||
try:
|
||||
qs = parse.urlencode({"topic_id": topic_id, "status": "active"})
|
||||
data = _read_json_url(f"{orientation.base_url}/workstreams/?{qs}", timeout=timeout)
|
||||
orientation.active_workstreams = _as_list(data)
|
||||
orientation.remote_checked = True
|
||||
except Exception as exc:
|
||||
orientation.errors.append(f"workstreams unavailable: {exc}")
|
||||
|
||||
try:
|
||||
qs = parse.urlencode({"to_agent": agent, "unread_only": "true"})
|
||||
data = _read_json_url(f"{orientation.base_url}/messages/?{qs}", timeout=timeout)
|
||||
orientation.inbox = _as_list(data)
|
||||
orientation.remote_checked = True
|
||||
except Exception as exc:
|
||||
orientation.errors.append(f"inbox unavailable: {exc}")
|
||||
|
||||
return orientation
|
||||
|
||||
|
||||
def render_hub_summary(orientation: HubOrientation) -> str:
|
||||
lines: list[str] = []
|
||||
if orientation.brief:
|
||||
for line in orientation.brief.splitlines():
|
||||
clean = line.strip("# ").strip()
|
||||
if clean:
|
||||
lines.append(clean)
|
||||
break
|
||||
if orientation.remote_checked:
|
||||
lines.append(f"Active workstreams: {len(orientation.active_workstreams)}")
|
||||
lines.append(f"Unread inbox messages: {len(orientation.inbox)}")
|
||||
else:
|
||||
lines.append("State Hub remote orientation not available.")
|
||||
if orientation.errors:
|
||||
lines.append("Notes: " + "; ".join(orientation.errors[:2]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_hub_details(orientation: HubOrientation) -> str:
|
||||
lines = [render_hub_summary(orientation), ""]
|
||||
if orientation.active_workstreams:
|
||||
lines.append("Workstreams:")
|
||||
for ws in orientation.active_workstreams[:8]:
|
||||
title = ws.get("title") or ws.get("name") or "(untitled)"
|
||||
ident = ws.get("id") or ws.get("workstream_id") or "?"
|
||||
status = ws.get("status") or "?"
|
||||
lines.append(f" - {title} [{status}] {ident}")
|
||||
tasks = ws.get("tasks") or ws.get("open_tasks") or []
|
||||
if isinstance(tasks, list):
|
||||
for task in tasks[:5]:
|
||||
if isinstance(task, dict):
|
||||
t_title = task.get("title") or task.get("name") or "(task)"
|
||||
t_status = task.get("status") or "?"
|
||||
lines.append(f" * {t_title} [{t_status}]")
|
||||
if orientation.inbox:
|
||||
lines.append("\nInbox:")
|
||||
for msg in orientation.inbox[:10]:
|
||||
title = msg.get("title") or msg.get("summary") or msg.get("subject") or "(message)"
|
||||
ident = msg.get("id") or "?"
|
||||
lines.append(f" - {ident}: {title}")
|
||||
if orientation.errors:
|
||||
lines.append("\nErrors:")
|
||||
lines.extend(f" - {err}" for err in orientation.errors)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def post_progress(summary: str, orientation: HubOrientation) -> dict[str, Any]:
|
||||
return _post_json_url(
|
||||
f"{orientation.base_url}/progress/",
|
||||
{"summary": summary, "event_type": "note", "author": "cya-shell"},
|
||||
)
|
||||
|
||||
|
||||
def mark_message_read(message_id: str, orientation: HubOrientation) -> dict[str, Any]:
|
||||
return _patch_json_url(f"{orientation.base_url}/messages/{message_id}/read", {})
|
||||
|
||||
|
||||
def detect_weakness_hints(
|
||||
user_text: str,
|
||||
assistant_text: str = "",
|
||||
*,
|
||||
risk: dict[str, Any] | None = None,
|
||||
turn_history: list[dict[str, Any]] | None = None,
|
||||
hub: HubOrientation | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
text = f"{user_text}\n{assistant_text}".lower()
|
||||
risk = risk or {}
|
||||
turn_history = turn_history or []
|
||||
hints: list[dict[str, str]] = []
|
||||
|
||||
secret_terms = ("api key", "token", "password", "secret", "openrouter", "ops_hub_key")
|
||||
if any(term in text for term in secret_terms) and "warden route" not in text:
|
||||
hints.append(
|
||||
{
|
||||
"rule": "credential-routing",
|
||||
"title": "Credential routing",
|
||||
"message": "Route secret custody first with warden route/OpenBao; do not paste keys into prompts, Git, State Hub, or logs.",
|
||||
}
|
||||
)
|
||||
|
||||
if "ops-warden" in text and any(term in text for term in secret_terms):
|
||||
hints.append(
|
||||
{
|
||||
"rule": "ops-warden-secret-anti-pattern",
|
||||
"title": "ops-warden boundary",
|
||||
"message": "ops-warden issues SSH certificates only; API keys and passwords belong to their routed owner.",
|
||||
}
|
||||
)
|
||||
|
||||
if re.search(r"\b(curl|wget)\b.*\|\s*(bash|sh|zsh|python)", text, re.S):
|
||||
hints.append(
|
||||
{
|
||||
"rule": "remote-exec-review",
|
||||
"title": "Remote execution review",
|
||||
"message": "Inspect downloaded content before execution and keep the safety confirmation gate intact.",
|
||||
}
|
||||
)
|
||||
|
||||
level = str(risk.get("level", "")).lower()
|
||||
previous_destructive = any(
|
||||
str(turn.get("risk", "")).lower() in {"destructive", "mass_edit"}
|
||||
for turn in turn_history[-3:]
|
||||
)
|
||||
if level in {"destructive", "mass_edit"} and previous_destructive:
|
||||
hints.append(
|
||||
{
|
||||
"rule": "repeated-destructive-intent",
|
||||
"title": "Repeated destructive intent",
|
||||
"message": "Repeated destructive or mass-edit turns are a cue to slow down and verify scope before proceeding.",
|
||||
}
|
||||
)
|
||||
|
||||
wp_tokens = set(re.findall(r"\b[A-Z]{2,5}-WP-\d{4}\b", user_text.upper()))
|
||||
if wp_tokens and hub is not None:
|
||||
haystack = json.dumps(hub.compact(), default=str).upper()
|
||||
missing = sorted(token for token in wp_tokens if token not in haystack)
|
||||
if missing:
|
||||
hints.append(
|
||||
{
|
||||
"rule": "state-hub-alignment",
|
||||
"title": "State Hub alignment",
|
||||
"message": f"{', '.join(missing)} was not visible in the loaded hub summary; confirm the local workplan file is the source of truth.",
|
||||
}
|
||||
)
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def render_weakness_hints(hints: list[dict[str, str]]) -> str:
|
||||
lines = []
|
||||
for hint in hints:
|
||||
lines.append(f"[{hint['rule']}] {hint['message']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ShellSession:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
offline: bool = False,
|
||||
history: ShellHistoryContext | None = None,
|
||||
hub: HubOrientation | None = None,
|
||||
console: Console | None = None,
|
||||
input_func: InputFunc | None = None,
|
||||
interactive: bool | None = None,
|
||||
offer_end_learning: bool = True,
|
||||
) -> None:
|
||||
self.session_id = session_id or make_session_id()
|
||||
self.path = default_session_path(self.session_id)
|
||||
self.offline = offline
|
||||
self.history = history or ShellHistoryContext(False, "default", 0)
|
||||
self.hub = hub or HubOrientation(DEFAULT_HUB_URL, TOPIC_ID, DEFAULT_AGENT)
|
||||
self.console = console or Console()
|
||||
self.input_func = input_func or input
|
||||
self.interactive = sys.stdin.isatty() if interactive is None else interactive
|
||||
self.offer_end_learning = offer_end_learning
|
||||
self.turns: list[dict[str, Any]] = []
|
||||
self.last_result: OrchestratorResult | None = None
|
||||
self.last_hints: list[dict[str, str]] = []
|
||||
self._started = False
|
||||
|
||||
def append_event(self, event: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(event, default=str) + "\n")
|
||||
|
||||
def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
self.append_event(
|
||||
{
|
||||
"kind": "session_start",
|
||||
"session_id": self.session_id,
|
||||
"created_at": utc_now(),
|
||||
"cwd": str(Path.cwd()),
|
||||
"history": self.history.to_dict(),
|
||||
"hub": self.hub.compact(),
|
||||
}
|
||||
)
|
||||
body = (
|
||||
f"Session: {self.session_id}\n"
|
||||
f"Artifact: {self.path}\n\n"
|
||||
f"{render_hub_summary(self.hub)}\n\n"
|
||||
f"History lines: {len(self.history.lines)}"
|
||||
)
|
||||
self.console.print(Panel(body, title="cya shell", border_style="cyan"))
|
||||
self.console.print("Type /help for commands, /exit to finish.")
|
||||
try:
|
||||
import readline # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self) -> Path:
|
||||
self.start()
|
||||
while True:
|
||||
try:
|
||||
line = self.input_func("cya> ")
|
||||
except KeyboardInterrupt:
|
||||
self.console.print("[yellow]Interrupted. Use /exit to end the session.[/yellow]")
|
||||
continue
|
||||
except EOFError:
|
||||
self.console.print("[dim]EOF received; ending session.[/dim]")
|
||||
break
|
||||
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
if text.startswith("/"):
|
||||
keep_going = self.handle_command(text)
|
||||
if not keep_going:
|
||||
break
|
||||
continue
|
||||
self.handle_turn(text)
|
||||
|
||||
self.close()
|
||||
return self.path
|
||||
|
||||
def handle_turn(self, text: str) -> OrchestratorResult:
|
||||
extra_context = {
|
||||
"session_id": self.session_id,
|
||||
"session_path": str(self.path),
|
||||
"shell_history": self.history.to_dict(),
|
||||
"hub_orientation": self.hub.compact(),
|
||||
}
|
||||
result = handle_request(
|
||||
text,
|
||||
offline=self.offline,
|
||||
session_turns=list(self.turns),
|
||||
extra_context=extra_context,
|
||||
)
|
||||
hints = detect_weakness_hints(
|
||||
text,
|
||||
result.assistant,
|
||||
risk=result.risk,
|
||||
turn_history=self.turns,
|
||||
hub=self.hub,
|
||||
)
|
||||
if hints:
|
||||
self.console.print(
|
||||
Panel(render_weakness_hints(hints), title="Weakness Hints", border_style="yellow")
|
||||
)
|
||||
|
||||
clean_user, red_user = redact_secrets(text)
|
||||
clean_assistant, red_assistant = redact_secrets(result.assistant)
|
||||
risk_level = result.risk.get("level", "safe")
|
||||
turn = {
|
||||
"user": text,
|
||||
"assistant": result.assistant,
|
||||
"risk": risk_level,
|
||||
}
|
||||
self.turns.append(turn)
|
||||
self.last_result = result
|
||||
self.last_hints = hints
|
||||
self.append_event(
|
||||
{
|
||||
"kind": KIND_SESSION_TURN,
|
||||
"session_id": self.session_id,
|
||||
"turn_index": len(self.turns),
|
||||
"created_at": utc_now(),
|
||||
"user": clean_user,
|
||||
"assistant": clean_assistant,
|
||||
"risk": result.risk,
|
||||
"cancelled": result.cancelled,
|
||||
"dry_run": result.dry_run,
|
||||
"weakness_hints": hints,
|
||||
"redactions": red_user + red_assistant,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def handle_command(self, text: str) -> bool:
|
||||
try:
|
||||
parts = shlex.split(text)
|
||||
except ValueError as exc:
|
||||
self.console.print(f"[red]Could not parse command: {exc}[/red]")
|
||||
return True
|
||||
if not parts:
|
||||
return True
|
||||
|
||||
cmd = parts[0].lower()
|
||||
if cmd in {"/exit", "/quit"}:
|
||||
return False
|
||||
if cmd == "/help":
|
||||
self.show_help()
|
||||
return True
|
||||
if cmd == "/explain":
|
||||
self.show_explain()
|
||||
return True
|
||||
if cmd == "/hub":
|
||||
self.handle_hub(parts)
|
||||
return True
|
||||
if cmd == "/inbox":
|
||||
self.handle_inbox(parts)
|
||||
return True
|
||||
if cmd == "/export-session":
|
||||
self.export_session()
|
||||
return True
|
||||
if cmd == "/learn":
|
||||
self.capture_reflections()
|
||||
return True
|
||||
|
||||
self.console.print(f"[yellow]Unknown command {parts[0]!r}. Try /help.[/yellow]")
|
||||
return True
|
||||
|
||||
def show_help(self) -> None:
|
||||
self.console.print(
|
||||
Panel(
|
||||
"\n".join(
|
||||
[
|
||||
"/help - show commands",
|
||||
"/explain - show context, risk, history, hub, and hints for the last turn",
|
||||
"/hub - show active State Hub workstreams and tasks",
|
||||
"/hub log \"summary\" - post a progress note after confirmation",
|
||||
"/inbox - show unread State Hub messages",
|
||||
"/inbox read <id> - explicitly mark a message read",
|
||||
"/export-session - write a redacted session summary JSON",
|
||||
"/learn - capture Profile 1 reflections now",
|
||||
"/exit - close the shell session",
|
||||
]
|
||||
),
|
||||
title="cya shell commands",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
def show_explain(self) -> None:
|
||||
if not self.last_result:
|
||||
self.console.print("[yellow]No turns yet.[/yellow]")
|
||||
self.console.print(
|
||||
Panel(
|
||||
render_shell_history_explanation(self.history),
|
||||
title="Shell History Context",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
return
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"risk": self.last_result.risk,
|
||||
"context": self.last_result.context,
|
||||
"weakness_hints": self.last_hints,
|
||||
}
|
||||
self.console.print(
|
||||
Panel(
|
||||
json.dumps(payload, indent=2, default=str),
|
||||
title="Last Turn Explanation",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
def handle_hub(self, parts: list[str]) -> None:
|
||||
if len(parts) == 1:
|
||||
self.console.print(Panel(render_hub_details(self.hub), title="State Hub"))
|
||||
return
|
||||
if len(parts) >= 3 and parts[1].lower() == "log":
|
||||
summary = " ".join(parts[2:]).strip()
|
||||
if not summary:
|
||||
self.console.print("[yellow]Usage: /hub log \"summary\"[/yellow]")
|
||||
return
|
||||
if not self.interactive:
|
||||
self.console.print("[yellow]Hub writes require an interactive confirmation.[/yellow]")
|
||||
return
|
||||
if not typer.confirm(f"Post progress note to {self.hub.base_url}?", default=False):
|
||||
self.console.print("[yellow]Progress note not posted.[/yellow]")
|
||||
return
|
||||
try:
|
||||
result = post_progress(summary, self.hub)
|
||||
self.console.print(f"[green]Posted progress note.[/green] {result}")
|
||||
except Exception as exc:
|
||||
self.console.print(f"[red]Progress post failed: {exc}[/red]")
|
||||
return
|
||||
self.console.print("[yellow]Usage: /hub or /hub log \"summary\"[/yellow]")
|
||||
|
||||
def handle_inbox(self, parts: list[str]) -> None:
|
||||
if len(parts) == 1:
|
||||
body = "No unread messages." if not self.hub.inbox else render_hub_details(self.hub)
|
||||
self.console.print(Panel(body, title="State Hub Inbox"))
|
||||
return
|
||||
if len(parts) == 3 and parts[1].lower() == "read":
|
||||
message_id = parts[2]
|
||||
if not self.interactive:
|
||||
self.console.print("[yellow]Mark-read requires an interactive session.[/yellow]")
|
||||
return
|
||||
try:
|
||||
result = mark_message_read(message_id, self.hub)
|
||||
self.console.print(f"[green]Marked message read.[/green] {result}")
|
||||
except Exception as exc:
|
||||
self.console.print(f"[red]Mark-read failed: {exc}[/red]")
|
||||
return
|
||||
self.console.print("[yellow]Usage: /inbox or /inbox read <id>[/yellow]")
|
||||
|
||||
def export_session(self) -> Path:
|
||||
out = self.path.with_name(f"{self.session_id}-summary.json")
|
||||
turns = []
|
||||
for i, turn in enumerate(self.turns, 1):
|
||||
user, user_redactions = redact_secrets(str(turn.get("user", "")))
|
||||
assistant, assistant_redactions = redact_secrets(str(turn.get("assistant", "")))
|
||||
turns.append(
|
||||
{
|
||||
"turn_index": i,
|
||||
"user": user,
|
||||
"assistant": assistant,
|
||||
"risk": turn.get("risk"),
|
||||
"redactions": user_redactions + assistant_redactions,
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"kind": "session_export",
|
||||
"session_id": self.session_id,
|
||||
"exported_at": utc_now(),
|
||||
"session_path": str(self.path),
|
||||
"turn_count": len(turns),
|
||||
"turns": turns,
|
||||
"history": self.history.to_dict(),
|
||||
"hub": self.hub.compact(),
|
||||
}
|
||||
out.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
||||
self.console.print(f"[green]Exported session summary:[/green] {out}")
|
||||
self.append_event(
|
||||
{"kind": "session_export", "session_id": self.session_id, "path": str(out), "created_at": utc_now()}
|
||||
)
|
||||
return out
|
||||
|
||||
def capture_reflections(self) -> int:
|
||||
if not self.interactive:
|
||||
self.console.print("[yellow]Reflection capture needs an interactive terminal.[/yellow]")
|
||||
return 0
|
||||
answers: dict[str, str] = {}
|
||||
for prompt_key, label in REFLECTION_CAPTURE_PROMPTS:
|
||||
answers[prompt_key] = typer.prompt(label, default="", show_default=False)
|
||||
lessons = collect_lessons_from_answers(answers)
|
||||
if not lessons:
|
||||
self.console.print("[yellow]No lessons captured.[/yellow]")
|
||||
return 0
|
||||
self.console.print(Panel(preview_lessons(lessons), title="Preview lessons"))
|
||||
if not typer.confirm("Save these lessons?", default=False):
|
||||
self.console.print("[yellow]Lessons discarded.[/yellow]")
|
||||
return 0
|
||||
count = save_reflection_lessons(
|
||||
lessons,
|
||||
".",
|
||||
provenance={
|
||||
"session_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
"scope": ".",
|
||||
"source": "cya shell",
|
||||
"session_id": self.session_id,
|
||||
},
|
||||
)
|
||||
self.console.print(f"[green]Saved {count} reflection(s).[/green]")
|
||||
return count
|
||||
|
||||
def remember_turns(self) -> int:
|
||||
count = 0
|
||||
for i, turn in enumerate(self.turns, 1):
|
||||
user, _ = redact_secrets(str(turn.get("user", "")))
|
||||
assistant, _ = redact_secrets(str(turn.get("assistant", "")))
|
||||
remember_preference(
|
||||
f"session_turn_{self.session_id}_{i}",
|
||||
{"user": user, "assistant": assistant, "risk": turn.get("risk")},
|
||||
scope=".",
|
||||
kind=KIND_SESSION_TURN,
|
||||
provenance={"source": "cya shell", "session_id": self.session_id, "turn_index": i},
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
self.append_event(
|
||||
{
|
||||
"kind": "session_end",
|
||||
"session_id": self.session_id,
|
||||
"ended_at": utc_now(),
|
||||
"turn_count": len(self.turns),
|
||||
}
|
||||
)
|
||||
if self.interactive and self.offer_end_learning and self.turns:
|
||||
if typer.confirm("Capture Profile 1 lessons from this shell session?", default=False):
|
||||
self.capture_reflections()
|
||||
if typer.confirm("Store redacted session turns as session_turn memory?", default=False):
|
||||
count = self.remember_turns()
|
||||
self.console.print(f"[green]Stored {count} session turn(s).[/green]")
|
||||
self.console.print(f"[green]Session saved:[/green] {self.path}")
|
||||
|
||||
|
||||
def run_shell(
|
||||
*,
|
||||
with_history: bool = False,
|
||||
history_limit: int | None = None,
|
||||
offline: bool = False,
|
||||
hub_url: str = DEFAULT_HUB_URL,
|
||||
no_hub: bool = False,
|
||||
session_id: str | None = None,
|
||||
console: Console | None = None,
|
||||
input_func: InputFunc | None = None,
|
||||
interactive: bool | None = None,
|
||||
offer_end_learning: bool = True,
|
||||
) -> Path:
|
||||
settings = load_shell_history_settings(with_history=with_history, history_limit=history_limit)
|
||||
history = collect_shell_history(settings)
|
||||
hub = load_hub_orientation(base_url=hub_url, include_remote=not no_hub)
|
||||
shell = ShellSession(
|
||||
session_id=session_id,
|
||||
offline=offline,
|
||||
history=history,
|
||||
hub=hub,
|
||||
console=console,
|
||||
input_func=input_func,
|
||||
interactive=interactive,
|
||||
offer_end_learning=offer_end_learning,
|
||||
)
|
||||
return shell.run()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HUB_URL",
|
||||
"TOPIC_ID",
|
||||
"HubOrientation",
|
||||
"ShellHistoryContext",
|
||||
"ShellSession",
|
||||
"collect_shell_history",
|
||||
"detect_weakness_hints",
|
||||
"load_hub_orientation",
|
||||
"redact_secrets",
|
||||
"render_shell_history_explanation",
|
||||
"run_shell",
|
||||
]
|
||||
@@ -8,3 +8,39 @@ import pytest
|
||||
|
||||
# Future: common fixtures for envelopes, fake adapters, etc.
|
||||
# For now this file exists to establish the test layout.
|
||||
|
||||
# The llm-connect integration tests mock llm_connect symbols. When the optional
|
||||
# sibling package is not installed in a default dev checkout, provide a tiny
|
||||
# import target so unittest.mock.patch can attach to it. Real installations win.
|
||||
def pytest_configure(config):
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
|
||||
if importlib.util.find_spec("llm_connect") is not None:
|
||||
return
|
||||
if "llm_connect" in sys.modules:
|
||||
return
|
||||
|
||||
llm_connect = types.ModuleType("llm_connect")
|
||||
llm_config = types.ModuleType("llm_connect.config")
|
||||
llm_models = types.ModuleType("llm_connect.models")
|
||||
|
||||
class RunConfig:
|
||||
def __init__(self, *, model_name, temperature, max_tokens):
|
||||
self.model_name = model_name
|
||||
self.temperature = temperature
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
def _missing_create_adapter(*args, **kwargs):
|
||||
raise RuntimeError("llm_connect test stub was not patched")
|
||||
|
||||
llm_connect.create_adapter = _missing_create_adapter
|
||||
llm_config.resolve_api_key = lambda env_var=None: None
|
||||
llm_models.RunConfig = RunConfig
|
||||
llm_connect.config = llm_config
|
||||
llm_connect.models = llm_models
|
||||
|
||||
sys.modules["llm_connect"] = llm_connect
|
||||
sys.modules["llm_connect.config"] = llm_config
|
||||
sys.modules["llm_connect.models"] = llm_models
|
||||
|
||||
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
|
||||
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
|
||||
type: workplan
|
||||
title: "Console-Native MVP: CLI Skeleton, Safe Assistance Flow, and Integration Boundaries"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: finished
|
||||
owner: grok
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: CYA-WP-0002
|
||||
type: workplan
|
||||
title: "Memory Integration Roadmap: From Thin Ports to Profile-Driven phase-memory Backing"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: done
|
||||
owner: grok
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: CYA-WP-0003
|
||||
type: workplan
|
||||
title: "Contextual Memory Activation and Retrospection Loops for Continuous Optimization"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: done
|
||||
owner: grok
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: CYA-WP-0004
|
||||
type: workplan
|
||||
title: "Developer Installation from Git and Release Distribution Packaging"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: done
|
||||
owner: grok
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: CYA-WP-0005
|
||||
type: workplan
|
||||
title: "Agentic Memory Profiles (0–3) and phase-memory Interface Optimization"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: done
|
||||
owner: grok
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: CYA-WP-0006
|
||||
type: workplan
|
||||
title: "Profile 1 Production Hardening: Reflection UX, Compaction, and Surfacing"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: finished
|
||||
owner: grok
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
id: CYA-WP-0007
|
||||
type: workplan
|
||||
title: "Interactive Shell Session: REPL, History Context, and Hub-Aware Dev-Sec-Ops Helper"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: ready
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: foerster-capabilities
|
||||
created: "2026-06-22"
|
||||
updated: "2026-06-22"
|
||||
updated: "2026-06-23"
|
||||
state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab"
|
||||
---
|
||||
|
||||
# CYA-WP-0007: Interactive Shell Session
|
||||
@@ -63,8 +64,9 @@ Claude Code — that:
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52"
|
||||
```
|
||||
|
||||
Produce `docs/cya-interactive-shell-session-design.md` covering:
|
||||
@@ -84,8 +86,9 @@ Produce `docs/cya-interactive-shell-session-design.md` covering:
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29"
|
||||
```
|
||||
|
||||
Implement `cya shell` Typer subcommand:
|
||||
@@ -104,8 +107,9 @@ Implement `cya shell` Typer subcommand:
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5"
|
||||
```
|
||||
|
||||
Extend context collection (new module or `collector` extension):
|
||||
@@ -125,8 +129,9 @@ Extend context collection (new module or `collector` extension):
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d"
|
||||
```
|
||||
|
||||
At session start, load orientation (graceful if hub offline):
|
||||
@@ -150,8 +155,9 @@ Slash commands:
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16"
|
||||
```
|
||||
|
||||
Connect each REPL turn to existing pipeline:
|
||||
@@ -169,8 +175,9 @@ Connect each REPL turn to existing pipeline:
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16"
|
||||
```
|
||||
|
||||
On `/exit` or session end:
|
||||
@@ -189,8 +196,9 @@ On `/exit` or session end:
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "fbdd4f04-76ef-4a18-8c40-b41a134a743b"
|
||||
```
|
||||
|
||||
Deterministic post-turn or end-session hints:
|
||||
@@ -209,8 +217,9 @@ Surface as informational panels — not auto-fixes. Optional save as reflection.
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
|
||||
```
|
||||
|
||||
- `tests/test_shell_session.py` — REPL loop, history opt-in, hub degrade, safety
|
||||
@@ -226,8 +235,9 @@ priority: high
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T09
|
||||
status: todo
|
||||
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
|
||||
@@ -245,4 +255,27 @@ When complete:
|
||||
---
|
||||
|
||||
**Status note:** Promoted to `ready` on 2026-06-22 after operator approval of direction.
|
||||
Pair with CYA-WP-0008 for production LLM quality in the REPL.
|
||||
Pair with CYA-WP-0008 for production LLM quality in the REPL.
|
||||
|
||||
## Completion Note - 2026-06-23
|
||||
|
||||
Implemented in this repo by Codex. Delivered `cya shell` with local JSONL
|
||||
session artifacts, optional capped/redacted shell history, State Hub orientation
|
||||
and explicit hub slash-command writes, `/explain`, `/export-session`, end-session
|
||||
Profile 1 learning hooks, opt-in `session_turn` memory persistence, deterministic
|
||||
weakness hints, docs, and tests.
|
||||
|
||||
Verification:
|
||||
|
||||
```bash
|
||||
make test
|
||||
python3 -m cya.cli.main --offline hello
|
||||
python3 -m cya.cli.main shell --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Operator follow-up: after this workplan file change, run from `~/state-hub`:
|
||||
|
||||
```bash
|
||||
make fix-consistency REPO=can-you-assist
|
||||
```
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
id: CYA-WP-0008
|
||||
type: workplan
|
||||
title: "llm-connect Adapter Integration for Production Assistance"
|
||||
domain: capabilities
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: ready
|
||||
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
|
||||
@@ -49,8 +50,9 @@ llm-connect. Credential routing via `warden route` before requesting secrets.
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "483d13bb-aabe-48ad-96c2-8df83de5f442"
|
||||
```
|
||||
|
||||
Document mapping from `AssistanceRequest` / `AssistanceResponse` to llm-connect calls.
|
||||
@@ -60,12 +62,15 @@ Identify config surface (TOML keys, env vars). Note gaps requiring llm-connect c
|
||||
- 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: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0fc17ad5-d90b-4ad1-b060-a1a2f9c25ea8"
|
||||
```
|
||||
|
||||
New class in `src/cya/llm/` implementing `LLMAdapter`:
|
||||
@@ -77,12 +82,15 @@ New class in `src/cya/llm/` implementing `LLMAdapter`:
|
||||
**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: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e8470a37-ecec-42f1-920b-ccd8b98b5512"
|
||||
```
|
||||
|
||||
- `~/.config/cya/config.toml` `[llm]` section (backend, model hints)
|
||||
@@ -93,12 +101,15 @@ priority: medium
|
||||
- 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: todo
|
||||
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:
|
||||
@@ -111,12 +122,15 @@ Wire `handle_request()` and CYA-WP-0007 shell turns to adapter selection:
|
||||
- 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: todo
|
||||
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`)
|
||||
@@ -127,12 +141,15 @@ priority: high
|
||||
- `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: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "2d152d4b-e4b2-4a94-8f85-d8f033e55d5f"
|
||||
```
|
||||
|
||||
Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md.
|
||||
@@ -144,8 +161,9 @@ Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T07
|
||||
status: todo
|
||||
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.
|
||||
@@ -158,4 +176,4 @@ priority: low
|
||||
|
||||
---
|
||||
|
||||
**Status note:** `ready` on 2026-06-22. Can start T01–T03 in parallel with CYA-WP-0007 T02–T04.
|
||||
**Status note:** `finished` on 2026-06-22. Integration doc: `docs/llm-connect-integration.md`.
|
||||
Reference in New Issue
Block a user