generated from coulomb/repo-seed
Compare commits
21 Commits
0a85539d8d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5df8ee5b | |||
| 6018e42930 | |||
| f15d253e64 | |||
| a6266f7e2c | |||
| 8e39e8e27c | |||
| a0f79af2ab | |||
| 79736d8b08 | |||
| d95dbb8ef5 | |||
| 019f6e7dc7 | |||
| cd5db14fbf | |||
| 34528308bb | |||
| f0ee0636c0 | |||
| c14d09c14d | |||
| a0d24a31eb | |||
| 099ba99a6a | |||
| 04ec282a1d | |||
| b68164b237 | |||
| f4e965cc04 | |||
| ad11632bd4 | |||
| 88ec1381e3 | |||
| c60b3b5001 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## Kaizen Agents
|
||||
|
||||
Specialized agent personas available on demand via the state-hub MCP.
|
||||
|
||||
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||
|
||||
Common agents:
|
||||
|
||||
| Agent | Category | When to use |
|
||||
|-------|----------|-------------|
|
||||
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||
| `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.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Architecture
|
||||
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
|
||||
## Quick Reference
|
||||
|
||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Credential and access routing
|
||||
|
||||
**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`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=can-you-assist` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| 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)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## First Session Protocol
|
||||
|
||||
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**
|
||||
- `~/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 a
|
||||
roadmap phase. **Wait for approval before creating.**
|
||||
|
||||
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||
```
|
||||
workplans/CYA-WP-NNNN-<slug>.md ← write this first
|
||||
```
|
||||
Then register in the hub:
|
||||
```
|
||||
create_workstream(topic_id="64418556-3206-457a-ba29-6884b5b12cf3", title="...", owner="...", description="...")
|
||||
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||
```
|
||||
|
||||
**Step 5 — Record the setup**
|
||||
```
|
||||
add_progress_event(
|
||||
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}
|
||||
)
|
||||
```
|
||||
|
||||
<!-- Delete or archive this file once past first session -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Repo boundary
|
||||
|
||||
This repo owns **can-you-assist** only. It does not own:
|
||||
|
||||
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||
- SSH key management → railiance-infra/
|
||||
- State hub code → state-hub/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
||||
**Purpose:** Console-native, backend-agnostic LLM helper for practical local shell, repository, filesystem, and note workflows.
|
||||
|
||||
**Domain:** agents
|
||||
**Repo slug:** can-you-assist
|
||||
**Topic ID:** 64418556-3206-457a-ba29-6884b5b12cf3
|
||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
||||
## Session Protocol
|
||||
|
||||
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||
MCP server name in `~/.claude.json`: `dev-hub`
|
||||
|
||||
**Step 1 — Orient**
|
||||
|
||||
Read the offline-safe brief first — it works without a live hub connection:
|
||||
```bash
|
||||
cat .custodian-brief.md
|
||||
```
|
||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||
```
|
||||
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/state/summary" | python3 -m json.tool
|
||||
```
|
||||
If the hub is offline: `cd ~/state-hub && make api`
|
||||
|
||||
**Step 2 — Check inbox**
|
||||
With MCP tools:
|
||||
```
|
||||
get_messages(to_agent="can-you-assist", unread_only=True)
|
||||
```
|
||||
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||
requests before proceeding.
|
||||
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=can-you-assist&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
**Step 3 — Scan workplans**
|
||||
```bash
|
||||
ls workplans/
|
||||
```
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`wait`/`todo`/`progress` tasks.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
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"*
|
||||
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||
4. **Suggested next action** — highest-priority open item
|
||||
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||
|
||||
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||
|
||||
**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).
|
||||
|
||||
**Session close:**
|
||||
With MCP tools:
|
||||
```
|
||||
add_progress_event(summary="...", topic_id="64418556-3206-457a-ba29-6884b5b12cf3", workstream_id="<uuid>")
|
||||
```
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic_id":"64418556-3206-457a-ba29-6884b5b12cf3","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||
```
|
||||
If workplan files were modified, ensure the local copy is up to date first:
|
||||
```bash
|
||||
git -C <repo_path> pull --ff-only
|
||||
cd ~/state-hub && make fix-consistency REPO=can-you-assist
|
||||
```
|
||||
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||
use the combined target which pulls before fixing:
|
||||
```bash
|
||||
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.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Stack
|
||||
|
||||
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||
- **Language:**
|
||||
- **Key deps:**
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
# TODO: Fill in the standard commands for this repo
|
||||
|
||||
# Install dependencies
|
||||
|
||||
# Run tests
|
||||
|
||||
# Lint / type check
|
||||
|
||||
# Build / package (if applicable)
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
File location: `workplans/CYA-WP-NNNN-<slug>.md`
|
||||
ID prefix: `CYA-WP-`
|
||||
|
||||
Work items originate as files in this repo **before** being registered in the hub.
|
||||
|
||||
Canonical workplan/workstream frontmatter statuses are:
|
||||
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||
repo state, and `finished` when implementation is complete. `stalled` and
|
||||
`needs_review` are derived health labels, not stored statuses.
|
||||
|
||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||
prefix: `YYMMDD-CYA-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
unchanged; the prefix is only for quick visual reference.
|
||||
|
||||
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||
multiple planned phases into a normal workplan.
|
||||
|
||||
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.
|
||||
|
||||
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-05-26 22:19 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
|
||||
314
AGENTS.md
314
AGENTS.md
@@ -1,258 +1,240 @@
|
||||
# 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:
|
||||
**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.
|
||||
|
||||
- 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.
|
||||
- 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:
|
||||
## Credential and access routing
|
||||
|
||||
1. Update workplan task statuses to match reality.
|
||||
2. Log a progress event in State Hub.
|
||||
3. Ask the custodian operator to run:
|
||||
**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`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
cd ~/state-hub && make fix-consistency REPO=can-you-assist
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
That syncs repo workplan files into the State Hub DB and regenerates
|
||||
`.custodian-brief.md`.
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
## Current Grok Handoff
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=can-you-assist` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
State Hub registration has been created for `can-you-assist` under the
|
||||
`capabilities` domain. Look for active workstream
|
||||
`repo-integration-can-you-assist`.
|
||||
### Quick routing table
|
||||
|
||||
First useful worker moves:
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
## Commands
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
```bash
|
||||
# === Packaging & Installation (CYA-WP-0004) ===
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
# Recommended: Stay on latest development code
|
||||
make dev-install
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
# Build distribution packages (sdist + wheel)
|
||||
make dist
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
|
||||
# Prepare a release (tests + clean build)
|
||||
make release-prep
|
||||
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||
<!-- Append repo-specific agent instructions below this marker.
|
||||
The state-hub template sync preserves content after this line. -->
|
||||
|
||||
# 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"
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
# Memory features (0003 + 0005)
|
||||
cya retrospect # Guided reflection session
|
||||
# Current memory implementation is Profile 0 (see CYA-WP-0005 and MemoryVision "Profile 0 Baseline").
|
||||
# Future Profiles 1–3 (verbal self-improvement, hierarchical synthesis, procedural rules) are tracked in that workplan.
|
||||
Work items originate as files in this repo — not in the hub. The hub is a
|
||||
read/cache/index layer that rebuilds from files.
|
||||
|
||||
# Tests
|
||||
python -m pytest tests/ -q
|
||||
**File location:** `workplans/CAN-WP-NNNN-<slug>.md`
|
||||
|
||||
# Git / inspection
|
||||
git status --short
|
||||
git log --oneline -5
|
||||
rg --files
|
||||
```
|
||||
**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.
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
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` (current)
|
||||
|
||||
## Workplan Convention
|
||||
|
||||
Work items originate as files in this repo. The hub is a read/cache/index
|
||||
layer that rebuilds from files.
|
||||
|
||||
**File location:** `workplans/CYA-WP-NNNN-<slug>.md`
|
||||
|
||||
**Archived location:** `workplans/archived/YYMMDD-CYA-WP-NNNN-<slug>.md`
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# can-you-assist — Claude Code Instructions
|
||||
|
||||
@SCOPE.md
|
||||
@.claude/rules/repo-identity.md
|
||||
@.claude/rules/session-protocol.md
|
||||
@.claude/rules/first-session.md
|
||||
@.claude/rules/workplan-convention.md
|
||||
@.claude/rules/stack-and-commands.md
|
||||
@.claude/rules/architecture.md
|
||||
@.claude/rules/repo-boundary.md
|
||||
@.claude/rules/credential-routing.md
|
||||
@.claude/rules/agents.md
|
||||
@@ -205,6 +205,8 @@ The three profiles are ordered by increasing agentic power and implementation co
|
||||
|
||||
### Profile 1 — Reflexion-Style Verbal Self-Improvement Loop
|
||||
|
||||
**Status:** Production (CYA-WP-0006). Shipped with guided capture in `cya retrospect`, `cya memory reflections`, explicit compaction, and budget-capped surfacing in responses and `--explain-context`.
|
||||
|
||||
**Intent:** Enable lightweight, high-explainability self-improvement by capturing and preferentially activating natural-language "lessons" and verbal reflections from user interactions and retrospection sessions.
|
||||
|
||||
**Core Loop (condensed):**
|
||||
|
||||
124
README.md
124
README.md
@@ -16,7 +16,7 @@ usable after `pip install -e .`:
|
||||
- `cya "your request in plain English"`
|
||||
- `cya --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.
|
||||
@@ -115,12 +204,29 @@ Run a guided reflection session to review how memory was used and explicitly set
|
||||
cya retrospect
|
||||
```
|
||||
|
||||
During the session `cya` will (Profile 1 spike in T05 adds optional verbal lesson capture at the end):
|
||||
During the session `cya` will:
|
||||
- Show recent memory items that were activated.
|
||||
- Help you reflect on what worked or didn't.
|
||||
- Let you record new **interaction goals** (e.g. "be more concise", "always show one safe alternative for destructive commands").
|
||||
- Optionally capture **1–3 verbal lessons** (Profile 1) with guided prompts, preview, and confirmation.
|
||||
|
||||
These goals are stored as first-class retrospection memory and will influence future activations and responses.
|
||||
Example Profile 1 flow at the end of `cya retrospect`:
|
||||
|
||||
```
|
||||
Capture 1–3 verbal lessons from this session? (y/n) y
|
||||
What went well that you want to remember? (or 'skip') Safety warnings were clear
|
||||
What should cya remember for next time? (or 'skip') Always suggest git status first
|
||||
What should cya avoid in this scope? (or 'skip') skip
|
||||
|
||||
Preview — verbal lessons to save
|
||||
1. [went well] Safety warnings were clear
|
||||
2. [remember] Always suggest git status first
|
||||
|
||||
Save these lessons? (y/n) y
|
||||
Saved 2 verbal reflection(s) (Profile 1).
|
||||
```
|
||||
|
||||
These goals and lessons are stored as first-class memory and will influence future activations and responses.
|
||||
|
||||
### Inspecting and Controlling Memory
|
||||
|
||||
@@ -132,6 +238,8 @@ All memory is stored in plain, user-editable JSON:
|
||||
Useful commands:
|
||||
```bash
|
||||
cya --explain-context "..." # See exactly what memory was activated and why
|
||||
cya memory reflections # List verbal reflections for the current scope
|
||||
cya memory reflections --json # Export reflections as JSON
|
||||
# (You can also use the memory ports directly in Python if you want to script it.)
|
||||
```
|
||||
|
||||
@@ -142,7 +250,9 @@ Memory also feeds the safety layer: a "never auto-run" preference you set during
|
||||
- Activation is automatic based on cwd + git root (with full provenance).
|
||||
- Retrospection outcomes are stored with special kinds (`retrospection`, `interaction_goal`) and get preferential treatment in future context building.
|
||||
- Everything is designed to be replaced/enriched by a full `phase-memory` implementation later (see MemoryVision.md).
|
||||
- Current implementation is formally **Profile 0** (post-0003 local JSON + activation + retrospection loop). See CYA-WP-0005 and the "Profile 0 Baseline" section in MemoryVision.md for the exact definition and the roadmap to Profiles 1–3 (self-improving verbal reflections, hierarchical synthesis, and procedural rules).
|
||||
- **Profile 0** (post-0003 local JSON + activation + retrospection loop) is the stable foundation.
|
||||
- **Profile 1** (verbal reflections) is production-ready as of CYA-WP-0006: guided capture in `cya retrospect`, `cya memory reflections`, compaction, and surfacing in responses / `--explain-context`.
|
||||
- Profiles 2–3 (hierarchical synthesis, procedural rules) remain roadmap items — see MemoryVision.md.
|
||||
|
||||
See:
|
||||
- `docs/cya-memory-activation-and-retrospection-concept.md` (the T01 design)
|
||||
@@ -169,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
|
||||
```
|
||||
|
||||
|
||||
25
SCOPE.md
25
SCOPE.md
@@ -6,7 +6,7 @@
|
||||
|
||||
It allows users to express intent in natural language from the terminal and receive safe, explainable, context-aware assistance while keeping memory, history, preferences, and adaptation under explicit user control.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -14,19 +14,21 @@ Four implementation slices have been delivered:
|
||||
- **CYA-WP-0002 (Memory Integration)**: Real user-controlled, persisting memory (scoped JSON) behind explicit ports, wired into context and safety.
|
||||
- **CYA-WP-0003 (Contextual Activation & Retrospection)**: Directory/project-bound automatic memory activation, `cya retrospect` guided reflection sessions, retrospection outcomes feeding future behavior (continuous user-driven optimization loop).
|
||||
- **Profile 0 baseline (post-0003, formalized in CYA-WP-0005 T02)**: The current shipped memory implementation (local JSON + kinds + activation_context + provenance + retrospection helper) is now explicitly documented as **Profile 0** — the stable, high-quality foundation for future self-improving profiles 1–3. See MemoryVision.md for the full baseline description.
|
||||
- **CYA-WP-0005 (Agentic Memory Profiles + first self-improvement capability)**: Complete profile model (Profile 0 baseline + detailed definitions + integration plans + Capability Matrix for Profiles 1–3) plus a minimal but fully working **Profile 1** (Reflexion-style verbal reflections/lessons) spike: `remember_reflection()` + `KIND_REFLECTION`, optional "capture verbal lesson" step inside `cya retrospect`, preferential activation when reflections are present, and surfacing in responses / `--explain-context`. The sister-repo optimization suggestions document for phase-memory was also finalized. See the workplan, MemoryVision.md, and `docs/phase-memory-optimization-suggestions.md`.
|
||||
- **CYA-WP-0005 (Agentic Memory Profiles + first self-improvement capability)**: Complete profile model (Profile 0 baseline + detailed definitions + integration plans + Capability Matrix for Profiles 1–3) plus initial **Profile 1** delivery. **CYA-WP-0006** hardened Profile 1 to production quality: guided 1–3 lesson capture with preview in `cya retrospect`, `cya memory reflections`, near-duplicate compaction, and surfacing in responses / `--explain-context`. See MemoryVision.md and `docs/CYA-WP-0006-profile-1-gap-checklist.md`.
|
||||
- **CYA-WP-0004 (Dev-Head Install & Release Packaging)**: Reliable installation from development head (`make dev-install`, direct `git+` installs), dynamic versioning via `setuptools_scm`, clean distribution package building (`python -m build` + verification), lightweight release process, and supporting documentation/Makefile.
|
||||
- **CYA-WP-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, now with optional verbal lesson capture (first delivered Profile 1 self-improvement behavior).
|
||||
- `cya retrospect` for structured reflection and goal setting, with production Profile 1 verbal lesson capture, review (`cya memory reflections`), and compaction.
|
||||
- Full developer workflow: dev-head install, testing, building distribution packages, and a documented release process.
|
||||
- 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.
|
||||
|
||||
|
||||
20
docs/CYA-WP-0006-profile-1-gap-checklist.md
Normal file
20
docs/CYA-WP-0006-profile-1-gap-checklist.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# CYA-WP-0006 — Profile 1 Gap Checklist
|
||||
|
||||
Audit of shipped Profile 1 spike (CYA-WP-0005-T05) vs MemoryVision Profile 1 acceptance criteria.
|
||||
Gates implementation tasks T02–T05.
|
||||
|
||||
| Gap | Spike state | Target (production) | Task |
|
||||
|-----|-------------|---------------------|------|
|
||||
| Capture UX | Single optional prompt; no preview | Guided 1–3 prompts (well / remember / avoid); preview + confirm; skip leaves no empty records | T02 |
|
||||
| Provenance | Plain key/value only | Session date + scope in reflection provenance | T02 |
|
||||
| Review / export | Manual JSON inspection | `cya memory reflections` lists/exports by scope | T03 |
|
||||
| Compaction | None | Near-duplicate detection; explicit merge/replace (no silent delete) | T03 |
|
||||
| Activation boost | Kind filter only | Reflections sorted ahead when recalled | T04 |
|
||||
| Explain surfacing | Item count + sample keys | Reflection count + truncated lesson text + provenance | T04 |
|
||||
| Response surfacing | Two reflections, 60-char truncate | Budget-capped "reflections influenced this" line | T04 |
|
||||
| Observability | `by_kind` in export | Reflection counts by scope in export | T05 |
|
||||
| Safety regression | Preference "never" tested | Reflections cannot downgrade destructive confirmation | T05 |
|
||||
| Tests | Single spike test | Paths for capture, compaction, surfacing, safety | T05 |
|
||||
| Docs | "spike" wording | README example session; MemoryVision "production" | T06 |
|
||||
|
||||
**Profile 0 invariants (must not regress):** user-controlled JSON, full provenance, memory adds caution only.
|
||||
16
docs/cya-config.example.toml
Normal file
16
docs/cya-config.example.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Example ~/.config/cya/config.toml — placeholders only; do not commit secrets.
|
||||
|
||||
[llm]
|
||||
adapter = "connect"
|
||||
backend = "openrouter"
|
||||
model = "anthropic/claude-sonnet-4"
|
||||
temperature = 0.3
|
||||
max_tokens = 2000
|
||||
api_key_env = "OPENROUTER_API_KEY"
|
||||
|
||||
# Optional interactive shell history context. Off by default.
|
||||
# Values are capped/redacted before they enter session context.
|
||||
[shell_history]
|
||||
enabled = false
|
||||
limit = 50
|
||||
# histfile = "~/.bash_history"
|
||||
172
docs/cya-interactive-shell-session-design.md
Normal file
172
docs/cya-interactive-shell-session-design.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# cya Interactive Shell Session Design
|
||||
|
||||
Status: implemented for CYA-WP-0007
|
||||
Date: 2026-06-23
|
||||
|
||||
## Scope Alignment
|
||||
|
||||
This design moves `cya shell` from the old deferred REPL note in SCOPE.md into
|
||||
owned product scope. It preserves the core INTENT principle: `cya` helps from
|
||||
inside the console while the user keeps control of context, history, memory,
|
||||
backend choice, and safety decisions.
|
||||
|
||||
Reviewed against:
|
||||
|
||||
- INTENT.md: console-native, user-controlled, explainable assistance.
|
||||
- SCOPE.md: `cya` owns UX, orchestration, safety, context, and documentation;
|
||||
State Hub remains non-runtime coordination.
|
||||
- MemoryVision.md: session turns are an episodic precursor with explicit
|
||||
`kind: session_turn`, user-triggered export, and opt-in persistence to memory.
|
||||
- AGENTS.md / State Hub boundary: hub reads are allowed for orientation;
|
||||
hub writes require explicit operator confirmation.
|
||||
|
||||
## REPL UX
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
cya shell
|
||||
cya shell --offline --no-hub
|
||||
cya shell --with-history
|
||||
```
|
||||
|
||||
Prompt: `cya> `
|
||||
|
||||
The shell is intentionally still a helper alongside the user's shell. It never
|
||||
auto-executes commands. Natural-language turns are passed through the existing
|
||||
orchestrator path: context collection, memory recall, risk classification,
|
||||
mandatory confirmation for non-safe levels, adapter call, and rendering.
|
||||
|
||||
Slash commands:
|
||||
|
||||
- `/help` - show the shell command list.
|
||||
- `/exit` or `/quit` - close the shell session.
|
||||
- `/explain` - show the last turn's risk, context envelope, shell history
|
||||
provenance, hub summary, and weakness hints.
|
||||
- `/hub` - show loaded active workstreams, open tasks, inbox, and hub errors.
|
||||
- `/hub log "summary"` - post a State Hub progress note only after interactive
|
||||
confirmation.
|
||||
- `/inbox` - show unread messages.
|
||||
- `/inbox read <id>` - explicitly mark a message read.
|
||||
- `/export-session` - write a redacted JSON session summary.
|
||||
- `/learn` - capture Profile 1 reflections immediately.
|
||||
|
||||
EOF exits cleanly. Ctrl-C interrupts the current prompt and keeps the session
|
||||
alive. Non-TTY input is accepted for smoke/scripted use; end-session learning
|
||||
prompts are skipped unless the session is interactive.
|
||||
|
||||
## Session File Layout
|
||||
|
||||
Session artifacts are user-owned and local:
|
||||
|
||||
```text
|
||||
~/.config/cya/sessions/<session-id>.jsonl
|
||||
~/.config/cya/sessions/<session-id>-summary.json
|
||||
```
|
||||
|
||||
Session ids use `cya-YYYYMMDD-HHMMSS-<random>`.
|
||||
|
||||
JSONL event kinds:
|
||||
|
||||
- `session_start`: session metadata, cwd, loaded history context, compact hub
|
||||
orientation.
|
||||
- `session_turn`: one redacted turn with user text, assistant text, risk,
|
||||
cancellation/dry-run flags, weakness hints, and redaction count.
|
||||
- `session_export`: emitted when `/export-session` writes a summary file.
|
||||
- `session_end`: final turn count and timestamp.
|
||||
|
||||
`session_turn` is also registered as a memory kind. End-of-session memory writes
|
||||
are opt-in and store only redacted turn summaries through the existing memory
|
||||
port, not a new private store.
|
||||
|
||||
## Shell History Model
|
||||
|
||||
History is off by default.
|
||||
|
||||
Opt-in paths:
|
||||
|
||||
```bash
|
||||
cya shell --with-history
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```toml
|
||||
[shell_history]
|
||||
enabled = true
|
||||
limit = 50
|
||||
histfile = "~/.bash_history"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Default hard cap: 50 lines.
|
||||
- Source: `$HISTFILE`, then `.bash_history`, `.zsh_history`, or fish history.
|
||||
- Per-line provenance: source path, line number, and `shell_history.histfile`.
|
||||
- Redaction before session context or persistence: API keys, tokens, passwords,
|
||||
common provider key forms, and secret-like env assignments.
|
||||
- `/explain` shows whether history was enabled, how many lines were included,
|
||||
redaction count, and provenance.
|
||||
- One-shot `cya "..."` remains history-free by default; no collector invariant
|
||||
changes are required for one-shot mode.
|
||||
|
||||
## State Hub Boundary
|
||||
|
||||
Session start reads:
|
||||
|
||||
- `.custodian-brief.md` if present.
|
||||
- `GET /workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active`.
|
||||
- `GET /messages/?to_agent=can-you-assist&unread_only=true`.
|
||||
|
||||
Remote hub failures are non-fatal and are recorded in the session context.
|
||||
|
||||
Writes are explicit slash commands only:
|
||||
|
||||
- `/hub log "summary"` posts a progress note after confirmation.
|
||||
- `/inbox read <id>` marks one message read by explicit command.
|
||||
|
||||
No automatic State Hub writes occur on shell start, normal turns, export, or
|
||||
session close.
|
||||
|
||||
## Weakness Hints v1
|
||||
|
||||
Hints are deterministic informational panels. They never change the risk level,
|
||||
never skip confirmation, and never execute fixes.
|
||||
|
||||
Rules:
|
||||
|
||||
- `credential-routing`: secret-like topics without `warden route` guidance.
|
||||
- `ops-warden-secret-anti-pattern`: requests that imply ops-warden should vend
|
||||
API keys or passwords.
|
||||
- `remote-exec-review`: `curl`/`wget` piped to interpreters.
|
||||
- `repeated-destructive-intent`: repeated destructive or mass-edit turns.
|
||||
- `state-hub-alignment`: requested workplan id not visible in loaded hub
|
||||
summary, reminding the user that local workplan files are source of truth.
|
||||
|
||||
## End-of-Session Learning
|
||||
|
||||
On interactive close, the shell offers:
|
||||
|
||||
1. Profile 1 lesson capture using the same reflection helpers as
|
||||
`cya retrospect`.
|
||||
2. Optional redacted `session_turn` memory persistence.
|
||||
|
||||
Skipping either path writes no reflection or session-turn memory records.
|
||||
`/export-session` remains separate and always writes only a redacted JSON
|
||||
summary under the sessions directory.
|
||||
|
||||
## Gate Checklist
|
||||
|
||||
- T02 REPL skeleton: `cya shell` command, prompt loop, JSONL session file,
|
||||
`/help`, `/exit`, `/explain`, EOF/Ctrl-C handling.
|
||||
- T03 history: off by default, `--with-history`, config support, caps,
|
||||
redaction, provenance, tests.
|
||||
- T04 hub: brief + active workstreams + inbox, graceful offline behavior,
|
||||
`/hub`, `/hub log`, `/inbox`, explicit mark-read.
|
||||
- T05 orchestrator/safety: every turn uses `handle_request`; non-safe levels
|
||||
still require confirmation; `/explain` shows the full session context.
|
||||
- T06 learning/export: Profile 1 capture, `/export-session`, opt-in
|
||||
`session_turn` memory persistence.
|
||||
- T07 hints: at least three deterministic rules with tests; advisory only.
|
||||
- T08 docs/tests: README, AGENTS command reference, operator example, and
|
||||
pytest coverage.
|
||||
43
docs/cya-shell-operator-session.md
Normal file
43
docs/cya-shell-operator-session.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Example Operator Session
|
||||
|
||||
This example uses offline mode and skips remote hub calls for a local smoke.
|
||||
In normal repo work, omit `--no-hub` so the shell reads State Hub orientation.
|
||||
|
||||
```text
|
||||
$ cya shell --offline --no-hub
|
||||
cya shell
|
||||
Session: cya-20260623-101500-1a2b3c4d
|
||||
Artifact: ~/.config/cya/sessions/cya-20260623-101500-1a2b3c4d.jsonl
|
||||
|
||||
State Hub remote orientation not available.
|
||||
History lines: 0
|
||||
|
||||
Type /help for commands, /exit to finish.
|
||||
cya> summarize what changed in this repo
|
||||
... standard orchestrator response ...
|
||||
cya> /explain
|
||||
... context envelope, memory, shell history status, hub summary, and hints ...
|
||||
cya> /hub
|
||||
... active workstreams or offline notes ...
|
||||
cya> /export-session
|
||||
Exported session summary: ~/.config/cya/sessions/cya-...-summary.json
|
||||
cya> /exit
|
||||
Session saved: ~/.config/cya/sessions/cya-...jsonl
|
||||
```
|
||||
|
||||
For history-aware continuity, opt in explicitly:
|
||||
|
||||
```bash
|
||||
cya shell --with-history
|
||||
```
|
||||
|
||||
The history context is capped, redacted, and shown in `/explain`. Secret values
|
||||
should still be routed through the approved custody path, not pasted into the
|
||||
session.
|
||||
|
||||
State Hub writes require a slash command and interactive confirmation:
|
||||
|
||||
```text
|
||||
cya> /hub log "Implemented CYA-WP-0007 shell skeleton and tests"
|
||||
Post progress note to http://127.0.0.1:8000? [y/N]: y
|
||||
```
|
||||
100
docs/llm-connect-integration.md
Normal file
100
docs/llm-connect-integration.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# llm-connect Integration (CYA-WP-0008)
|
||||
|
||||
## Mapping: cya ↔ llm-connect
|
||||
|
||||
| cya (`AssistanceRequest`) | llm-connect |
|
||||
|---------------------------|-------------|
|
||||
| `user_request` | User message body (after context framing) |
|
||||
| `context` (envelope + memory + `session_turns`) | Serialized into the prompt via `cya.llm.prompt.build_assistance_prompt` |
|
||||
| `hints` (`model`, `temperature`, `max_tokens`) | `RunConfig` fields for `execute_prompt` |
|
||||
| `AssistanceResponse.suggestion` | `LLMResponse.content` |
|
||||
| `AssistanceResponse.metadata` | `LLMResponse.model`, `usage`, `finish_reason` |
|
||||
|
||||
llm-connect owns provider clients (`create_adapter`), API key resolution, retries, and
|
||||
token usage. `cya` never imports vendor SDKs directly.
|
||||
|
||||
## Configuration
|
||||
|
||||
User config: `~/.config/cya/config.toml`
|
||||
|
||||
```toml
|
||||
[llm]
|
||||
adapter = "connect" # "connect" | "fake" (default: fake when absent)
|
||||
backend = "openrouter" # openrouter | openai | gemini | claude-code | mock
|
||||
model = "anthropic/claude-sonnet-4"
|
||||
temperature = 0.3
|
||||
max_tokens = 2000
|
||||
api_key_env = "OPENROUTER_API_KEY" # optional override
|
||||
# system_prompt = "..." # optional; uses cya default when omitted
|
||||
```
|
||||
|
||||
Optional project override: `.cya.toml` (same `[llm]` section; merged over user config).
|
||||
|
||||
Environment overrides:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `CYA_LLM_ADAPTER` | `connect` or `fake` |
|
||||
| `CYA_LLM_BACKEND` / `CYA_LLM_PROVIDER` | Provider name |
|
||||
| `CYA_LLM_MODEL` | Model id |
|
||||
|
||||
CLI: `cya --offline "..."` forces `FakeLLMAdapter`.
|
||||
|
||||
## Session context budget (multi-turn / `cya shell`)
|
||||
|
||||
Recent turns are passed in `AssistanceRequest.context["session_turns"]` as
|
||||
`{"user": "...", "assistant": "..."}` records.
|
||||
|
||||
Bounds (see `cya.config`):
|
||||
|
||||
- **Max turns:** 10
|
||||
- **Max characters:** 4000 (total across included turns)
|
||||
|
||||
Older or oversized history is dropped from the prompt automatically.
|
||||
|
||||
## Credential routing
|
||||
|
||||
Do **not** commit API keys. Before requesting secrets, route custody:
|
||||
|
||||
```bash
|
||||
warden route find "OpenRouter API key" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Typical ownership:
|
||||
|
||||
| Need | Owner | ops-warden executes? |
|
||||
|------|-------|----------------------|
|
||||
| `OPENROUTER_API_KEY` | OpenBao (`railiance-platform`) | No — route only |
|
||||
| `OPENAI_API_KEY` | OpenBao | No — route only |
|
||||
| `GEMINI_API_KEY` | OpenBao | No — route only |
|
||||
|
||||
llm-connect resolves keys via `resolve_api_key()` (explicit arg → env var → project key file).
|
||||
|
||||
## Adapter selection
|
||||
|
||||
`cya.llm.factory.get_adapter()` is the single factory for one-shot and shell paths:
|
||||
|
||||
1. `--offline` or `CYA_LLM_ADAPTER=fake` → `FakeLLMAdapter`
|
||||
2. `adapter = "connect"` in config/env → `LLMConnectAdapter` (graceful degrade on failure)
|
||||
3. Otherwise → `FakeLLMAdapter` (current default)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
make dev-install
|
||||
pip install -e ~/llm-connect # sibling checkout
|
||||
```
|
||||
|
||||
Optional extra group (placeholder for packaging): `pip install -e ".[llm]"`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Default CI: `make test` — mocks llm-connect; no network.
|
||||
- Manual live check: `pytest -m llm_live` (requires configured API key).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- Structured JSON output schema not enforced yet (free-form model text).
|
||||
- `claude-code` backend does not require an API key; other backends do.
|
||||
- Per-directory `.cya.toml` overrides user config but does not yet mirror llm-connect's full 7-layer resolution.
|
||||
129
history/2026-05-28-CYA-Intent-Scope-Gap-Analysis-Post-0005.md
Normal file
129
history/2026-05-28-CYA-Intent-Scope-Gap-Analysis-Post-0005.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Gap Analysis: INTENT.md vs SCOPE.md (Post CYA-WP-0005)
|
||||
|
||||
**Date:** 2026-05-28
|
||||
**Repo:** can-you-assist
|
||||
**Workplan:** CYA-WP-0005 (completed)
|
||||
**Previous Analysis:** 2026-05-28 (Post 0004)
|
||||
**Author:** Grok
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Since the May 28 analysis (post-0004), the major workplan **CYA-WP-0005: Agentic Memory Profiles and phase-memory Interface Optimization** has been completed via ralph loop.
|
||||
|
||||
Key deliveries:
|
||||
- Formal **Profile 0 baseline** (post-0003 local JSON + kinds + activation_context + provenance + retrospection) explicitly documented as the stable foundation.
|
||||
- Complete profile model: detailed definitions, cya integration plans, and a Capability Matrix for **Profiles 1–3** (Reflexion verbal, hierarchical synthesis, procedural/meta-policy evolution).
|
||||
- First delivered self-improving capability: **minimal but working Profile 1** (Reflexion-style verbal reflections/lessons) spike — `remember_reflection()` + `KIND_REFLECTION`, optional "capture verbal lesson" step inside `cya retrospect`, preferential activation, and surfacing in responses / `--explain-context`.
|
||||
- Finalized the `docs/phase-memory-optimization-suggestions.md` handoff artifact for the sister repo.
|
||||
|
||||
**Overall Assessment**: Very strong progress on the most important remaining gap from INTENT.md — the "Personalized Console Helper" that improves over time through user-controlled memory. The memory & longitudinal adaptation gap has moved from "Small-Medium" to "Small". The project now has both the conceptual framework (profiles) and a concrete first implementation (Profile 1 verbal loop) that directly addresses INTENT's vision of longitudinal adaptation via user-steerable memory.
|
||||
|
||||
Remaining gaps are now primarily about *depth* (richer context understanding, deeper phase-memory integration for Profiles 2/3, procedural evolution) rather than missing foundational mechanisms.
|
||||
|
||||
## Strong Alignments (Updated)
|
||||
|
||||
| Area | INTENT.md Position | Current Reality (Post-0005) | Assessment |
|
||||
|-----------------------------------|--------------------------------------------------|---------------------------------------------------------------------------------------------|-------------------------|
|
||||
| Console-native experience | Foundational | Excellent | Strong match |
|
||||
| Safety & mandatory confirmation | Important | Core behavior + memory-aware (reflections can force confirmation) | Exceeded |
|
||||
| Explainability & transparency | Strong requirement | Very strong (provenance everywhere, reflections surfaced explicitly, `--explain-context`) | Strong |
|
||||
| Backend agnosticism | Must use llm-connect seam | Clean LLMAdapter Protocol + Fake | Excellent |
|
||||
| User-controlled memory | Central principle | Real + now architected with explicit profiles (0 baseline + 1–3 roadmap) | Major improvement |
|
||||
| Longitudinal adaptation | Personalized helper that improves over time | `cya retrospect` + automatic activation + first working self-improvement loop (Profile 1) | Significantly better |
|
||||
| Packaging & distribution | Not heavily emphasized | Dev-head install + release process first-class | Major positive shift |
|
||||
| Clear boundaries | cya / llm-connect / phase-memory separation | Explicit ports + MemoryVision contract + phase-memory feedback doc | Good |
|
||||
| Profile-driven / self-improving behavior | Strong vision in "Personalized Console Helper" | Full model (T03) + first implementation (T05) | New major strength |
|
||||
|
||||
## Key Gaps (Post-0005)
|
||||
|
||||
### 1. Memory & Longitudinal Adaptation — Now a Clear Strength with Clear Next Steps
|
||||
|
||||
**Previous Status (Post-0004)**: Small-Medium. Good activation + retrospection loop, but still passive local JSON with limited self-improvement.
|
||||
|
||||
**Current Status (Post-0005)**:
|
||||
- Explicit **Profile 0** baseline documented as the shipped foundation.
|
||||
- Complete **Profiles 1–3** model with cya-specific mappings and Capability Matrix (T03).
|
||||
- **Profile 1** (Reflexion verbal) is not just designed — a working spike is shipped: verbal lessons can be captured during `cya retrospect`, stored with dedicated kind, preferentially activated, and surfaced to the user.
|
||||
- Strong explainability and user control preserved.
|
||||
|
||||
**Remaining Sub-Gaps** (now much smaller and more targeted):
|
||||
- Profile 1 is still a minimal spike (capture step + basic activation). Production hardening, better UX, and compaction of reflections would be natural follow-on work.
|
||||
- Profiles 2 (hierarchical synthesis) and 3 (procedural/meta-policy) remain design + research artifacts. No implementation yet (explicitly registered as debt in 0005).
|
||||
- Full wiring to `phase-memory` planners, graph store, and lifecycle rules is still future work (as intended).
|
||||
|
||||
**Assessment**: Large positive movement. The core INTENT vision of a user-controlled assistant that "improves over time" now has both architecture and a concrete first capability. This was the single largest gap for most of the project's history.
|
||||
|
||||
### 2. Depth of Local Context Understanding
|
||||
|
||||
**INTENT.md** envisions rich assistance with code repositories, notes, project structures, and conventions.
|
||||
|
||||
**Current State**:
|
||||
- Context collector remains intentionally shallow (top-level entries + basic git).
|
||||
- Memory (especially the new Profile 1 verbal lessons + user-declared retrospection goals) helps significantly with *user-taught* project conventions and patterns.
|
||||
- No automatic deep semantic understanding, embeddings, or large-scale repository analysis.
|
||||
|
||||
**Assessment**: Still a medium gap. The memory/profile work makes the *user* far more effective at bringing rich context into the assistant, but the system itself does not yet deeply understand repositories or notes on its own. This is consistent with original scope boundaries.
|
||||
|
||||
### 3. One-Shot vs Rich Longitudinal Value
|
||||
|
||||
**INTENT.md**: Strong vision of the assistant becoming more useful over time through memory of habits, conventions, and recurring workflows.
|
||||
|
||||
**Current Reality (Post-0005)**:
|
||||
- Users can teach the system via normal use + explicit `cya retrospect`.
|
||||
- Directory/project-bound activation + the new verbal reflection mechanism create a real, usable self-improvement loop.
|
||||
- Profile model provides a clear roadmap for richer future behavior (synthesis, procedural rules).
|
||||
|
||||
**Assessment**: Significantly improved. The basic mechanism for longitudinal, user-controlled adaptation now exists and is demonstrable with Profile 1. Richer automatic pattern learning and procedural evolution (Profiles 2/3) remain the natural next deepening.
|
||||
|
||||
### 4. Packaging & Distribution — Remains Strong
|
||||
|
||||
No regression. Still first-class with documented process.
|
||||
|
||||
### 5. Safety & Explainability — Continued Strength
|
||||
|
||||
Remains excellent. The new Profile 1 reflections flow through the same rule-based RiskClassifier and are fully visible in `--explain-context`.
|
||||
|
||||
## Summary Table (Updated)
|
||||
|
||||
| Gap Area | Severity (Post-0002) | Severity (Post-0004) | Severity (Post-0005) | Trend | Notes |
|
||||
|-----------------------------------|----------------------|----------------------|----------------------|------------------------|-------|
|
||||
| Memory & Longitudinal Adaptation | Medium | Small-Medium | Small | Major improvement | Profile model + first working self-improvement loop (Profile 1) |
|
||||
| Depth of Context Understanding | Medium-Large | Medium | Medium | Stable / slight improvement via user memory | Collector still shallow; user memory helps |
|
||||
| Packaging & Distribution | Medium (gap) | Small | Small | Stable | Remains first-class |
|
||||
| Safety & Explainability | Positive | Positive | Positive | Stable | Remains a core strength |
|
||||
| One-shot vs Rich Longitudinal | Medium | Small | Small | Stable / improved | Real loop exists; richer synthesis is future work |
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Continue the memory evolution roadmap** as documented in CYA-WP-0005 and MemoryVision.md:
|
||||
- Production-harden Profile 1 (better capture UX, reflection compaction, more surfacing).
|
||||
- Begin exploration spikes for Profile 2 (synthesis) when ready.
|
||||
- Treat Profile 3 (procedural) as higher-risk / higher-reward and keep strong guardrails.
|
||||
|
||||
2. **Deepen phase-memory coordination** using the finalized `docs/phase-memory-optimization-suggestions.md`. The 9 categories (especially synthesis planner hooks, procedural support, and dry-run proposal semantics) are now well-scoped asks.
|
||||
|
||||
3. **Consider whether "Context Depth" becomes a first-class work item**. If richer automatic understanding of repositories/notes is desired, it will likely need its own focused slice (separate from the memory/profile work).
|
||||
|
||||
4. **Keep the seam discipline**. The explicit ports, activation model, and safety integration from 0002/0003/0005 remain excellent boundaries. Future work should extend rather than bypass them.
|
||||
|
||||
5. **Packaging follow-through** (unchanged from prior analysis): move the registered debt items (CI gate, PyPI workflow, automation) forward when capacity allows.
|
||||
|
||||
## Conclusion
|
||||
|
||||
CYA-WP-0005 has delivered the single most important conceptual and practical advance since the memory integration work of 0002/0003. The project now has:
|
||||
- An explicit, documented profile model that directly maps to INTENT's vision of longitudinal adaptation.
|
||||
- A working, user-visible first self-improving capability (Profile 1 verbal reflections).
|
||||
|
||||
The product is now substantially closer to the "Personalized Console Helper" described in INTENT.md than at any previous point. The remaining gaps are about *evolving and deepening* capabilities that now have clear architectural homes, rather than about missing foundational pieces.
|
||||
|
||||
This is a strong position from which to plan the next phase of work.
|
||||
|
||||
---
|
||||
|
||||
**Related Documents**
|
||||
- Previous analysis: `history/2026-05-28-CYA-Intent-Scope-Gap-Analysis-Post-0004.md`
|
||||
- Research foundation: `history/2026-05-28-CYA-Agentic-Memory-Research-Variations.md`
|
||||
- Memory vision & profile model: `MemoryVision.md`
|
||||
- Recent workplan: `workplans/CYA-WP-0005-agentic-memory-profiles-and-phase-memory-feedback.md`
|
||||
- Current SCOPE.md (updated as part of this assessment)
|
||||
- phase-memory feedback: `docs/phase-memory-optimization-suggestions.md`
|
||||
@@ -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]
|
||||
|
||||
12
registry/README.md
Normal file
12
registry/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Capability Registry
|
||||
|
||||
Markdown-first capability index for federation and reuse planning.
|
||||
|
||||
## Authoring
|
||||
|
||||
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
|
||||
2. Add the row to `indexes/capabilities.yaml`.
|
||||
3. Run `reuse-surface validate` from a checkout with the CLI installed.
|
||||
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
|
||||
|
||||
Federation contract: reuse-surface `docs/RegistryFederation.md`.
|
||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal file
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`
|
||||
20
registry/indexes/capabilities.yaml
Normal file
20
registry/indexes/capabilities.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
version: 1
|
||||
updated: '2026-07-06'
|
||||
domain: helix_forge
|
||||
capabilities:
|
||||
- id: capability.agents.cli-assistant
|
||||
name: Console-Native LLM Assistant (cya)
|
||||
summary: Console-native, backend-agnostic assistant CLI that expresses user intent in natural language
|
||||
and returns safe, explainable, context-aware help.
|
||||
vector: D2 / A2 / C1 / R0
|
||||
domain: agents
|
||||
status: draft
|
||||
owner: can-you-assist
|
||||
path: registry/capabilities/capability.agents.cli-assistant.md
|
||||
tags:
|
||||
- cli
|
||||
- assistant
|
||||
- llm
|
||||
- safety
|
||||
consumption_modes:
|
||||
- cli
|
||||
@@ -11,6 +11,7 @@ This module will evolve in T06 (orchestrator) but the surface contract stays sta
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import typer
|
||||
@@ -65,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",
|
||||
@@ -105,9 +111,111 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
memory_app = typer.Typer(
|
||||
help="Inspect and manage user-controlled memory (Profile 0 + Profile 1).",
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
app.add_typer(memory_app, name="memory")
|
||||
|
||||
|
||||
@memory_app.command("reflections")
|
||||
def memory_reflections(
|
||||
scope: str = typer.Option(
|
||||
".",
|
||||
"--scope",
|
||||
"-s",
|
||||
help="Directory or scope to list reflections for.",
|
||||
),
|
||||
export_json: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Export reflections as JSON (same as export_memory with kinds filter).",
|
||||
),
|
||||
) -> None:
|
||||
"""List or export verbal reflections for a scope (Profile 1)."""
|
||||
from cya.memory import export_memory, KIND_REFLECTION
|
||||
from cya.memory.reflections import list_reflections, reflection_export_stats
|
||||
|
||||
if export_json:
|
||||
data = export_memory(scope, kinds=[KIND_REFLECTION])
|
||||
console.print(json.dumps(data, indent=2, default=str))
|
||||
return
|
||||
|
||||
items = list_reflections(scope)
|
||||
stats = reflection_export_stats(scope)
|
||||
if not items:
|
||||
console.print(f"[yellow]No reflections in scope {scope!r}.[/yellow]")
|
||||
return
|
||||
|
||||
lines = []
|
||||
for item in items:
|
||||
prov = item.get("provenance") or {}
|
||||
date = prov.get("session_date", "?")
|
||||
lines.append(f"• [bold]{item.get('key')}[/bold] ({date}): {item.get('value')}")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"\n".join(lines),
|
||||
title=f"Reflections in {scope} ({stats['reflection_count']} total)",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
if stats.get("reflection_counts_by_scope"):
|
||||
console.print(
|
||||
f"[dim]By scope: {stats['reflection_counts_by_scope']}[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def retrospect(
|
||||
scope: str = typer.Option(
|
||||
@@ -133,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:
|
||||
@@ -144,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:
|
||||
@@ -99,6 +100,7 @@ def remember_preference(
|
||||
profile: str | None = None,
|
||||
ttl: str | None = None,
|
||||
kind: str = KIND_PREFERENCE,
|
||||
provenance: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Remember a user preference, workflow pattern, retrospection outcome, or goal.
|
||||
|
||||
@@ -118,6 +120,8 @@ def remember_preference(
|
||||
"profile": profile,
|
||||
"kind": kind,
|
||||
}
|
||||
if provenance:
|
||||
item["provenance"] = provenance
|
||||
# avoid exact dups for same key in small stores
|
||||
items = [i for i in items if i.get("key") != key]
|
||||
items.append(item)
|
||||
@@ -166,7 +170,13 @@ def recall_preferences(
|
||||
normal.append(item)
|
||||
items = boosted + normal
|
||||
|
||||
items = items[-limit:] # most recent first (after boosting)
|
||||
# Profile 1: prefer reflections ahead of other kinds when requested
|
||||
if kinds and KIND_REFLECTION in kinds:
|
||||
reflections = [i for i in items if i.get("kind") == KIND_REFLECTION]
|
||||
others = [i for i in items if i.get("kind") != KIND_REFLECTION]
|
||||
items = (reflections + others)[:limit]
|
||||
else:
|
||||
items = items[-limit:] # most recent (after boosting)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
@@ -222,7 +232,13 @@ def export_memory(scope: str = "cwd", *, profile: str | None = None, kinds: list
|
||||
k = item.get("kind", "unknown")
|
||||
by_kind.setdefault(k, []).append(item)
|
||||
|
||||
return {
|
||||
reflection_by_scope: dict[str, int] = {}
|
||||
for item in items:
|
||||
if item.get("kind") == KIND_REFLECTION:
|
||||
s = str(item.get("scope", scope))
|
||||
reflection_by_scope[s] = reflection_by_scope.get(s, 0) + 1
|
||||
|
||||
result = {
|
||||
"status": "real (T02+0003 local json; activation + retrospection ready)",
|
||||
"scope": scope,
|
||||
"profile": profile,
|
||||
@@ -234,6 +250,10 @@ def export_memory(scope: str = "cwd", *, profile: str | None = None, kinds: list
|
||||
"note": "User-controlled. Replace with real phase-memory when available.",
|
||||
"phases": ["ephemeral", "fluid", "stabilized", "rigid"],
|
||||
}
|
||||
if reflection_by_scope:
|
||||
result["reflection_counts_by_scope"] = reflection_by_scope
|
||||
result["reflection_count"] = sum(reflection_by_scope.values())
|
||||
return result
|
||||
except Exception as e:
|
||||
_warn_not_connected(f"export_memory(scope={scope}, profile={profile}) err={e}")
|
||||
return {
|
||||
@@ -264,13 +284,21 @@ def remember_reflection(
|
||||
scope: str = "cwd",
|
||||
*,
|
||||
profile: str | None = None,
|
||||
provenance: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Convenience helper for Profile 1 (Reflexion-style verbal self-improvement).
|
||||
|
||||
Stores verbal lessons/reflections with kind="reflection" for preferential
|
||||
activation in future turns. Thin wrapper for the T05 minimal spike.
|
||||
activation in future turns.
|
||||
"""
|
||||
remember_preference(key, value, scope=scope, profile=profile, kind=KIND_REFLECTION)
|
||||
remember_preference(
|
||||
key,
|
||||
value,
|
||||
scope=scope,
|
||||
profile=profile,
|
||||
kind=KIND_REFLECTION,
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -284,5 +312,6 @@ __all__ = [
|
||||
"KIND_RETROSPECTION",
|
||||
"KIND_INTERACTION_GOAL",
|
||||
"KIND_REFLECTION",
|
||||
"KIND_SESSION_TURN",
|
||||
]
|
||||
|
||||
|
||||
241
src/cya/memory/reflections.py
Normal file
241
src/cya/memory/reflections.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Profile 1 reflection helpers — capture, compaction, and surfacing.
|
||||
|
||||
Pure, testable logic for verbal reflection management. Interactive CLI flows
|
||||
in orchestrator.py call into these functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
from cya.memory import (
|
||||
KIND_REFLECTION,
|
||||
_load,
|
||||
_save,
|
||||
export_memory,
|
||||
remember_reflection,
|
||||
)
|
||||
|
||||
REFLECTION_CAPTURE_PROMPTS: tuple[tuple[str, str], ...] = (
|
||||
("went_well", "What went well that you want to remember? (or 'skip')"),
|
||||
("remember", "What should cya remember for next time? (or 'skip')"),
|
||||
("avoid", "What should cya avoid in this scope? (or 'skip')"),
|
||||
)
|
||||
|
||||
_SKIP_VALUES = frozenset({"", "skip", "s", "n", "no"})
|
||||
_MAX_SURFACING_LESSONS = 3
|
||||
_MAX_LESSON_CHARS_EXPLAIN = 80
|
||||
_MAX_LESSON_CHARS_RESPONSE = 60
|
||||
_SIMILARITY_THRESHOLD = 0.85
|
||||
|
||||
|
||||
def is_skip_answer(text: str) -> bool:
|
||||
return not text or text.strip().lower() in _SKIP_VALUES
|
||||
|
||||
|
||||
def collect_lessons_from_answers(answers: dict[str, str]) -> list[dict[str, str]]:
|
||||
"""Build lesson records from guided prompt answers; skips empty/skip answers."""
|
||||
lessons: list[dict[str, str]] = []
|
||||
for prompt_key, _label in REFLECTION_CAPTURE_PROMPTS:
|
||||
raw = answers.get(prompt_key, "")
|
||||
if is_skip_answer(raw):
|
||||
continue
|
||||
text = raw.strip()
|
||||
if text:
|
||||
lessons.append({"prompt": prompt_key, "text": text})
|
||||
return lessons
|
||||
|
||||
|
||||
def preview_lessons(lessons: list[dict[str, str]]) -> str:
|
||||
if not lessons:
|
||||
return "(no lessons to save)"
|
||||
lines = []
|
||||
for i, lesson in enumerate(lessons, 1):
|
||||
label = lesson.get("prompt", "lesson").replace("_", " ")
|
||||
lines.append(f"{i}. [{label}] {lesson['text']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def session_provenance(scope: str) -> dict[str, Any]:
|
||||
return {
|
||||
"session_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
"scope": scope,
|
||||
"source": "cya retrospect",
|
||||
}
|
||||
|
||||
|
||||
def save_reflection_lessons(
|
||||
lessons: list[dict[str, str]],
|
||||
scope: str,
|
||||
*,
|
||||
provenance: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""Persist confirmed lessons; returns count saved. No-op for empty input."""
|
||||
if not lessons:
|
||||
return 0
|
||||
meta = provenance or session_provenance(scope)
|
||||
saved = 0
|
||||
for lesson in lessons:
|
||||
key = f"reflection_{lesson['prompt']}_{meta['session_date']}"
|
||||
remember_reflection(
|
||||
key,
|
||||
lesson["text"],
|
||||
scope=scope,
|
||||
provenance={**meta, "prompt": lesson["prompt"]},
|
||||
)
|
||||
saved += 1
|
||||
return saved
|
||||
|
||||
|
||||
def normalize_reflection_text(text: str) -> str:
|
||||
collapsed = re.sub(r"\s+", " ", text.strip().lower())
|
||||
return re.sub(r"[^\w\s]", "", collapsed)
|
||||
|
||||
|
||||
def reflection_similarity(a: str, b: str) -> float:
|
||||
na, nb = normalize_reflection_text(a), normalize_reflection_text(b)
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
if na == nb:
|
||||
return 1.0
|
||||
return SequenceMatcher(None, na, nb).ratio()
|
||||
|
||||
|
||||
def find_duplicate_reflection_groups(
|
||||
scope: str,
|
||||
*,
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""Return groups of near-duplicate reflection items in the same scope."""
|
||||
items = [i for i in _load(scope) if i.get("kind") == KIND_REFLECTION]
|
||||
groups: list[list[dict[str, Any]]] = []
|
||||
used: set[str] = set()
|
||||
|
||||
for i, item in enumerate(items):
|
||||
key_i = item.get("key", str(i))
|
||||
if key_i in used:
|
||||
continue
|
||||
group = [item]
|
||||
val_i = str(item.get("value", ""))
|
||||
for j, other in enumerate(items):
|
||||
if j <= i:
|
||||
continue
|
||||
key_j = other.get("key", str(j))
|
||||
if key_j in used:
|
||||
continue
|
||||
val_j = str(other.get("value", ""))
|
||||
if reflection_similarity(val_i, val_j) >= threshold:
|
||||
group.append(other)
|
||||
used.add(key_j)
|
||||
if len(group) > 1:
|
||||
groups.append(group)
|
||||
used.add(key_i)
|
||||
return groups
|
||||
|
||||
|
||||
def compact_reflections(
|
||||
scope: str,
|
||||
*,
|
||||
keep_key: str,
|
||||
remove_keys: list[str],
|
||||
merged_value: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Explicit opt-in compaction: update keeper and remove duplicates."""
|
||||
items = _load(scope)
|
||||
removed: list[str] = []
|
||||
updated = False
|
||||
|
||||
for item in items:
|
||||
if item.get("key") == keep_key and item.get("kind") == KIND_REFLECTION:
|
||||
if merged_value is not None:
|
||||
item["value"] = merged_value
|
||||
updated = True
|
||||
|
||||
new_items = []
|
||||
for item in items:
|
||||
k = item.get("key")
|
||||
if k in remove_keys and item.get("kind") == KIND_REFLECTION:
|
||||
removed.append(k)
|
||||
continue
|
||||
new_items.append(item)
|
||||
|
||||
if removed or updated:
|
||||
_save(scope, new_items)
|
||||
|
||||
return {
|
||||
"kept": keep_key,
|
||||
"removed": removed,
|
||||
"updated": updated,
|
||||
"remaining_reflections": sum(
|
||||
1 for i in new_items if i.get("kind") == KIND_REFLECTION
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def list_reflections(scope: str) -> list[dict[str, Any]]:
|
||||
exported = export_memory(scope, kinds=[KIND_REFLECTION])
|
||||
return exported.get("items", [])
|
||||
|
||||
|
||||
def format_reflection_surfacing(
|
||||
memory: dict[str, Any],
|
||||
*,
|
||||
for_explain: bool = False,
|
||||
) -> str | None:
|
||||
"""Budget-capped reflection summary for explain-context or normal responses."""
|
||||
items = memory.get("items", []) if isinstance(memory, dict) else []
|
||||
reflections = [i for i in items if i.get("kind") == KIND_REFLECTION]
|
||||
if not reflections:
|
||||
return None
|
||||
|
||||
max_chars = _MAX_LESSON_CHARS_EXPLAIN if for_explain else _MAX_LESSON_CHARS_RESPONSE
|
||||
snippets: list[str] = []
|
||||
for item in reflections[:_MAX_SURFACING_LESSONS]:
|
||||
text = str(item.get("value", "")).strip()
|
||||
if len(text) > max_chars:
|
||||
text = text[: max_chars - 3] + "..."
|
||||
prov = item.get("provenance") or {}
|
||||
date = prov.get("session_date", "")
|
||||
prefix = f"({date}) " if date and for_explain else ""
|
||||
snippets.append(f"{prefix}{text}")
|
||||
|
||||
extra = len(reflections) - _MAX_SURFACING_LESSONS
|
||||
suffix = f" (+{extra} more)" if extra > 0 else ""
|
||||
count = len(reflections)
|
||||
noun = "reflection" if count == 1 else "reflections"
|
||||
return f"{count} verbal {noun} influenced this{suffix}: " + "; ".join(snippets)
|
||||
|
||||
|
||||
def reflection_export_stats(scope: str) -> dict[str, Any]:
|
||||
"""Observability: reflection counts and scope breakdown for export."""
|
||||
exported = export_memory(scope, kinds=[KIND_REFLECTION])
|
||||
items = exported.get("items", [])
|
||||
by_scope: dict[str, int] = {}
|
||||
for item in items:
|
||||
s = item.get("scope", scope)
|
||||
by_scope[s] = by_scope.get(s, 0) + 1
|
||||
return {
|
||||
"reflection_count": len(items),
|
||||
"reflection_counts_by_scope": by_scope,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REFLECTION_CAPTURE_PROMPTS",
|
||||
"collect_lessons_from_answers",
|
||||
"preview_lessons",
|
||||
"session_provenance",
|
||||
"save_reflection_lessons",
|
||||
"normalize_reflection_text",
|
||||
"reflection_similarity",
|
||||
"find_duplicate_reflection_groups",
|
||||
"compact_reflections",
|
||||
"list_reflections",
|
||||
"format_reflection_surfacing",
|
||||
"reflection_export_stats",
|
||||
"is_skip_answer",
|
||||
]
|
||||
@@ -23,8 +23,11 @@ See workplan CYA-WP-0001 (core) + CYA-WP-0003 (memory wiring + retrospect)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
@@ -32,24 +35,73 @@ from cya.context.collector import collect, render_explanation
|
||||
from cya.memory import (
|
||||
recall_preferences,
|
||||
remember_retrospection_outcome,
|
||||
remember_reflection,
|
||||
KIND_RETROSPECTION,
|
||||
KIND_INTERACTION_GOAL,
|
||||
KIND_REFLECTION,
|
||||
)
|
||||
from cya.memory.reflections import (
|
||||
REFLECTION_CAPTURE_PROMPTS,
|
||||
collect_lessons_from_answers,
|
||||
compact_reflections,
|
||||
find_duplicate_reflection_groups,
|
||||
format_reflection_surfacing,
|
||||
preview_lessons,
|
||||
save_reflection_lessons,
|
||||
session_provenance,
|
||||
)
|
||||
from cya.safety.risk import classify, get_user_confirmation
|
||||
from cya.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.
|
||||
@@ -98,15 +150,21 @@ def handle_request(
|
||||
if explain_context and memory.get("items"):
|
||||
try:
|
||||
prov = memory.get("provenance", [{}])[0]
|
||||
# Show a couple of activated items for transparency (T03 0003)
|
||||
sample = ", ".join(i.get("key", "?") for i in memory.get("items", [])[:3])
|
||||
act_note = ""
|
||||
if prov.get("activation_context"):
|
||||
act_note = f" | ctx: {prov['activation_context']}"
|
||||
body = (
|
||||
f"Phase: {memory.get('phase')} | {len(memory.get('items', []))} items | "
|
||||
f"{prov.get('source', 'local')}{act_note}\n"
|
||||
f"Sample activated: {sample}"
|
||||
)
|
||||
refl_line = format_reflection_surfacing(memory, for_explain=True)
|
||||
if refl_line:
|
||||
body += f"\n\n[cyan]Reflections:[/cyan] {refl_line}"
|
||||
console.print(
|
||||
Panel(
|
||||
f"Phase: {memory.get('phase')} | {len(memory.get('items', []))} items | {prov.get('source', 'local')}{act_note}\n"
|
||||
f"Sample activated: {sample}",
|
||||
body,
|
||||
title="Memory Activated (T03)",
|
||||
border_style="blue",
|
||||
padding=(0, 1),
|
||||
@@ -115,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
|
||||
@@ -135,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,
|
||||
@@ -156,11 +249,9 @@ def handle_request(
|
||||
mem_line = ""
|
||||
if memory.get("items"):
|
||||
mem_line = f"\n[dim]Memory activated: {len(memory.get('items', []))} items (phase {memory.get('phase')})[/dim]"
|
||||
# Minimal Profile 1 surface (T05 spike)
|
||||
reflections = [i for i in memory.get("items", []) if i.get("kind") == KIND_REFLECTION]
|
||||
if reflections:
|
||||
refl_text = "; ".join(str(i.get("value", ""))[:60] for i in reflections[:2])
|
||||
mem_line += f"\n[cyan]Verbal reflections activated: {len(reflections)} — {refl_text}[/cyan]"
|
||||
refl_line = format_reflection_surfacing(memory, for_explain=False)
|
||||
if refl_line:
|
||||
mem_line += f"\n[cyan]{refl_line}[/cyan]"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
@@ -177,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).
|
||||
@@ -263,29 +364,15 @@ def run_retrospection(scope: str = ".", limit: int = 8) -> None:
|
||||
)
|
||||
console.print("[green]Recorded as safety preference.[/green]")
|
||||
|
||||
# Minimal Profile 1 spike (T05): optional verbal reflection / lesson capture
|
||||
capture_lesson = typer.prompt(
|
||||
"Capture any verbal lessons or reflections from this session? (y/n or short text)",
|
||||
default="n",
|
||||
show_default=False,
|
||||
)
|
||||
if capture_lesson and capture_lesson.lower() not in ("n", "no", "skip", "s", ""):
|
||||
lesson_text = capture_lesson if len(capture_lesson) > 3 else typer.prompt(
|
||||
"What is the key lesson? (1-2 sentences)",
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if lesson_text:
|
||||
remember_reflection(
|
||||
"verbal_lesson", lesson_text, scope=scope
|
||||
)
|
||||
console.print("[green]Recorded as verbal reflection (Profile 1).[/green]")
|
||||
_capture_reflection_lessons(scope)
|
||||
_offer_reflection_compaction(scope)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"Thank you. Your reflections have been stored as retrospection memory.\n"
|
||||
"They will be preferentially activated in future sessions in this scope.\n\n"
|
||||
"You can review them anytime with:\n"
|
||||
f" [bold]cya memory reflections --scope {scope}[/bold]\n"
|
||||
f" [bold]cya --explain-context \"...\"[/bold] (in this directory)\n"
|
||||
f" or inspect the JSON files in [cyan]~/.config/cya/memory/[/cyan]",
|
||||
title="Retrospection Complete",
|
||||
@@ -295,4 +382,91 @@ def run_retrospection(scope: str = ".", limit: int = 8) -> None:
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["handle_request", "run_retrospection"]
|
||||
def _capture_reflection_lessons(scope: str) -> None:
|
||||
"""Profile 1: guided verbal lesson capture with preview and confirmation."""
|
||||
want = typer.prompt(
|
||||
"Capture 1–3 verbal lessons from this session? (y/n)",
|
||||
default="n",
|
||||
show_default=False,
|
||||
)
|
||||
if not want or want.strip().lower() in ("n", "no", "skip", "s", ""):
|
||||
return
|
||||
|
||||
answers: dict[str, str] = {}
|
||||
for prompt_key, label in REFLECTION_CAPTURE_PROMPTS:
|
||||
answers[prompt_key] = typer.prompt(label, default="", show_default=False)
|
||||
|
||||
lessons = collect_lessons_from_answers(answers)
|
||||
if not lessons:
|
||||
console.print("[yellow]No lessons captured — nothing stored.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
preview_lessons(lessons),
|
||||
title="Preview — verbal lessons to save",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
confirm = typer.prompt("Save these lessons? (y/n)", default="n", show_default=False)
|
||||
if confirm.strip().lower() not in ("y", "yes"):
|
||||
console.print("[yellow]Lessons discarded — nothing stored.[/yellow]")
|
||||
return
|
||||
|
||||
count = save_reflection_lessons(
|
||||
lessons,
|
||||
scope,
|
||||
provenance=session_provenance(scope),
|
||||
)
|
||||
console.print(f"[green]Saved {count} verbal reflection(s) (Profile 1).[/green]")
|
||||
|
||||
|
||||
def _offer_reflection_compaction(scope: str) -> None:
|
||||
"""Offer explicit merge of near-duplicate reflections in this scope."""
|
||||
groups = find_duplicate_reflection_groups(scope)
|
||||
if not groups:
|
||||
return
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"Found {len(groups)} group(s) of similar reflections in scope [green]{scope}[/green].\n"
|
||||
"Compaction is opt-in — nothing is deleted without your confirmation.",
|
||||
title="Reflection compaction available",
|
||||
border_style="yellow",
|
||||
)
|
||||
)
|
||||
do_compact = typer.prompt("Review and compact duplicates? (y/n)", default="n", show_default=False)
|
||||
if do_compact.strip().lower() not in ("y", "yes"):
|
||||
return
|
||||
|
||||
for group in groups:
|
||||
console.print("\n[bold]Similar reflections:[/bold]")
|
||||
for item in group:
|
||||
console.print(f" • {item.get('key')}: {item.get('value')}")
|
||||
|
||||
keep = typer.prompt(
|
||||
"Key to keep (or 'skip' this group)",
|
||||
default=group[0].get("key", ""),
|
||||
show_default=True,
|
||||
)
|
||||
if not keep or keep.strip().lower() in ("skip", "s"):
|
||||
continue
|
||||
|
||||
remove_keys = [i.get("key") for i in group if i.get("key") != keep]
|
||||
merge = typer.prompt(
|
||||
"Merged text (Enter to keep existing value of kept key)",
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
result = compact_reflections(
|
||||
scope,
|
||||
keep_key=keep,
|
||||
remove_keys=[k for k in remove_keys if k],
|
||||
merged_value=merge.strip() or None,
|
||||
)
|
||||
console.print(
|
||||
f"[green]Compacted: kept {result['kept']}, removed {len(result['removed'])}[/green]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OrchestratorResult", "handle_request", "run_retrospection"]
|
||||
|
||||
862
src/cya/shell_session.py
Normal file
862
src/cya/shell_session.py
Normal file
@@ -0,0 +1,862 @@
|
||||
"""Interactive shell session runtime for ``cya shell`` (CYA-WP-0007).
|
||||
|
||||
The shell is a thin interactive client for the existing orchestrator. It owns
|
||||
session-local state, optional history context, State Hub orientation, slash
|
||||
commands, JSONL persistence, and user-triggered learning/export flows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib import parse, request
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from cya.config import ShellHistorySettings, load_shell_history_settings
|
||||
from cya.memory import KIND_SESSION_TURN, remember_preference
|
||||
from cya.memory.reflections import (
|
||||
REFLECTION_CAPTURE_PROMPTS,
|
||||
collect_lessons_from_answers,
|
||||
preview_lessons,
|
||||
save_reflection_lessons,
|
||||
)
|
||||
from cya.orchestrator import OrchestratorResult, handle_request
|
||||
|
||||
TOPIC_ID = "64418556-3206-457a-ba29-6884b5b12cf3"
|
||||
DEFAULT_AGENT = "can-you-assist"
|
||||
DEFAULT_HUB_URL = os.environ.get("CYA_STATE_HUB_URL", "http://127.0.0.1:8000")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellHistoryContext:
|
||||
enabled: bool
|
||||
source: str
|
||||
limit: int
|
||||
histfile: str | None = None
|
||||
lines: list[dict[str, Any]] = field(default_factory=list)
|
||||
redactions: int = 0
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubOrientation:
|
||||
base_url: str
|
||||
topic_id: str
|
||||
agent: str
|
||||
brief: str | None = None
|
||||
active_workstreams: list[dict[str, Any]] = field(default_factory=list)
|
||||
inbox: list[dict[str, Any]] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
remote_checked: bool = False
|
||||
|
||||
@property
|
||||
def hub_available(self) -> bool:
|
||||
return self.remote_checked and not self.errors
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["hub_available"] = self.hub_available
|
||||
return data
|
||||
|
||||
def compact(self) -> dict[str, Any]:
|
||||
return {
|
||||
"base_url": self.base_url,
|
||||
"topic_id": self.topic_id,
|
||||
"agent": self.agent,
|
||||
"hub_available": self.hub_available,
|
||||
"active_workstream_count": len(self.active_workstreams),
|
||||
"inbox_count": len(self.inbox),
|
||||
"errors": self.errors,
|
||||
"workstreams": [
|
||||
{
|
||||
"id": w.get("id") or w.get("workstream_id"),
|
||||
"title": w.get("title") or w.get("name"),
|
||||
"status": w.get("status"),
|
||||
}
|
||||
for w in self.active_workstreams[:5]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(
|
||||
r"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|TOKEN|PASSWORD|PASSWD|SECRET)[A-Z0-9_]*)"
|
||||
r"(\s*=\s*)([^\s;]+)"
|
||||
),
|
||||
re.compile(r"(?i)\b(--(?:api-key|token|password|secret)\s+)([^\s;]+)"),
|
||||
re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"),
|
||||
re.compile(r"\b(gh[pousr]_[A-Za-z0-9_]{12,})\b"),
|
||||
re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]{12,})\b"),
|
||||
re.compile(r"\b(AKIA[0-9A-Z]{12,})\b"),
|
||||
)
|
||||
|
||||
|
||||
InputFunc = Callable[[str], str]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def make_session_id() -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
return f"cya-{stamp}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def session_root() -> Path:
|
||||
root = Path.home() / ".config" / "cya" / "sessions"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def default_session_path(session_id: str) -> Path:
|
||||
return session_root() / f"{session_id}.jsonl"
|
||||
|
||||
|
||||
def redact_secrets(text: str) -> tuple[str, int]:
|
||||
redacted = text
|
||||
count = 0
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
groups = match.groups()
|
||||
if len(groups) >= 3:
|
||||
return f"{groups[0]}{groups[1]}[REDACTED]"
|
||||
return "[REDACTED_SECRET]"
|
||||
|
||||
for pattern in _SECRET_PATTERNS:
|
||||
redacted, n = pattern.subn(_replace, redacted)
|
||||
count += n
|
||||
return redacted, count
|
||||
|
||||
|
||||
def _normalize_history_line(line: str) -> str:
|
||||
text = line.strip()
|
||||
if text.startswith(": ") and ";" in text:
|
||||
return text.split(";", 1)[1].strip()
|
||||
if text.startswith("- cmd: "):
|
||||
return text.removeprefix("- cmd: ").strip()
|
||||
return text
|
||||
|
||||
|
||||
def _default_histfile(env: dict[str, str], home: Path) -> Path | None:
|
||||
explicit = env.get("HISTFILE")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
for candidate in (
|
||||
home / ".bash_history",
|
||||
home / ".zsh_history",
|
||||
home / ".local" / "share" / "fish" / "fish_history",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def collect_shell_history(
|
||||
settings: ShellHistorySettings,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> ShellHistoryContext:
|
||||
env = env or os.environ
|
||||
home = home or Path.home()
|
||||
ctx = ShellHistoryContext(
|
||||
enabled=settings.enabled,
|
||||
source=settings.source,
|
||||
limit=settings.limit,
|
||||
histfile=settings.histfile,
|
||||
notes=list(settings.notes),
|
||||
)
|
||||
|
||||
if not settings.enabled:
|
||||
ctx.notes.append("Shell history disabled; no history was collected.")
|
||||
return ctx
|
||||
if settings.limit <= 0:
|
||||
ctx.notes.append("Shell history enabled with limit 0; no lines collected.")
|
||||
return ctx
|
||||
|
||||
histfile = Path(settings.histfile).expanduser() if settings.histfile else _default_histfile(env, home)
|
||||
if not histfile:
|
||||
ctx.notes.append("No shell history file found from HISTFILE or known fallbacks.")
|
||||
return ctx
|
||||
|
||||
ctx.histfile = str(histfile)
|
||||
try:
|
||||
raw = histfile.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as exc:
|
||||
ctx.notes.append(f"Could not read history file: {exc}")
|
||||
return ctx
|
||||
|
||||
normalized = [_normalize_history_line(line) for line in raw]
|
||||
normalized = [line for line in normalized if line]
|
||||
start = max(0, len(normalized) - settings.limit)
|
||||
for offset, line in enumerate(normalized[start:], start=start + 1):
|
||||
clean, count = redact_secrets(line[:1000])
|
||||
ctx.redactions += count
|
||||
ctx.lines.append(
|
||||
{
|
||||
"line_number": offset,
|
||||
"command": clean,
|
||||
"source": str(histfile),
|
||||
"provenance": "shell_history.histfile",
|
||||
}
|
||||
)
|
||||
if ctx.redactions:
|
||||
ctx.notes.append(f"Redacted {ctx.redactions} secret-like value(s).")
|
||||
return ctx
|
||||
|
||||
|
||||
def render_shell_history_explanation(history: ShellHistoryContext) -> str:
|
||||
lines = [
|
||||
f"enabled: {history.enabled}",
|
||||
f"source: {history.source}",
|
||||
f"limit: {history.limit}",
|
||||
f"histfile: {history.histfile or '(none)'}",
|
||||
f"lines: {len(history.lines)}",
|
||||
]
|
||||
if history.notes:
|
||||
lines.append("notes:")
|
||||
lines.extend(f" - {note}" for note in history.notes)
|
||||
if history.lines:
|
||||
lines.append("commands:")
|
||||
for item in history.lines:
|
||||
lines.append(
|
||||
f" {item['line_number']}: {item['command']} ({item['provenance']})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_json_url(url: str, *, timeout: float) -> Any:
|
||||
req = request.Request(url, headers={"User-Agent": "cya-shell/0.1"})
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
if not data.strip():
|
||||
return []
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _post_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"},
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
if not data.strip():
|
||||
return {}
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _patch_json_url(url: str, payload: dict[str, Any], *, timeout: float = 3.0) -> Any:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="PATCH",
|
||||
headers={"Content-Type": "application/json", "User-Agent": "cya-shell/0.1"},
|
||||
)
|
||||
with request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
if not data.strip():
|
||||
return {}
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
for key in ("items", "results", "data"):
|
||||
if isinstance(value.get(key), list):
|
||||
return [item for item in value[key] if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def load_hub_orientation(
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
base_url: str = DEFAULT_HUB_URL,
|
||||
topic_id: str = TOPIC_ID,
|
||||
agent: str = DEFAULT_AGENT,
|
||||
timeout: float = 1.5,
|
||||
include_remote: bool = True,
|
||||
) -> HubOrientation:
|
||||
cwd = cwd or Path.cwd()
|
||||
orientation = HubOrientation(base_url=base_url.rstrip("/"), topic_id=topic_id, agent=agent)
|
||||
|
||||
brief = cwd / ".custodian-brief.md"
|
||||
if brief.is_file():
|
||||
try:
|
||||
orientation.brief = brief.read_text(encoding="utf-8", errors="replace")[:6000]
|
||||
except OSError as exc:
|
||||
orientation.errors.append(f"brief read failed: {exc}")
|
||||
|
||||
if not include_remote:
|
||||
orientation.errors.append("State Hub remote orientation skipped by --no-hub.")
|
||||
return orientation
|
||||
|
||||
try:
|
||||
qs = parse.urlencode({"topic_id": topic_id, "status": "active"})
|
||||
data = _read_json_url(f"{orientation.base_url}/workstreams/?{qs}", timeout=timeout)
|
||||
orientation.active_workstreams = _as_list(data)
|
||||
orientation.remote_checked = True
|
||||
except Exception as exc:
|
||||
orientation.errors.append(f"workstreams unavailable: {exc}")
|
||||
|
||||
try:
|
||||
qs = parse.urlencode({"to_agent": agent, "unread_only": "true"})
|
||||
data = _read_json_url(f"{orientation.base_url}/messages/?{qs}", timeout=timeout)
|
||||
orientation.inbox = _as_list(data)
|
||||
orientation.remote_checked = True
|
||||
except Exception as exc:
|
||||
orientation.errors.append(f"inbox unavailable: {exc}")
|
||||
|
||||
return orientation
|
||||
|
||||
|
||||
def render_hub_summary(orientation: HubOrientation) -> str:
|
||||
lines: list[str] = []
|
||||
if orientation.brief:
|
||||
for line in orientation.brief.splitlines():
|
||||
clean = line.strip("# ").strip()
|
||||
if clean:
|
||||
lines.append(clean)
|
||||
break
|
||||
if orientation.remote_checked:
|
||||
lines.append(f"Active workstreams: {len(orientation.active_workstreams)}")
|
||||
lines.append(f"Unread inbox messages: {len(orientation.inbox)}")
|
||||
else:
|
||||
lines.append("State Hub remote orientation not available.")
|
||||
if orientation.errors:
|
||||
lines.append("Notes: " + "; ".join(orientation.errors[:2]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_hub_details(orientation: HubOrientation) -> str:
|
||||
lines = [render_hub_summary(orientation), ""]
|
||||
if orientation.active_workstreams:
|
||||
lines.append("Workstreams:")
|
||||
for ws in orientation.active_workstreams[:8]:
|
||||
title = ws.get("title") or ws.get("name") or "(untitled)"
|
||||
ident = ws.get("id") or ws.get("workstream_id") or "?"
|
||||
status = ws.get("status") or "?"
|
||||
lines.append(f" - {title} [{status}] {ident}")
|
||||
tasks = ws.get("tasks") or ws.get("open_tasks") or []
|
||||
if isinstance(tasks, list):
|
||||
for task in tasks[:5]:
|
||||
if isinstance(task, dict):
|
||||
t_title = task.get("title") or task.get("name") or "(task)"
|
||||
t_status = task.get("status") or "?"
|
||||
lines.append(f" * {t_title} [{t_status}]")
|
||||
if orientation.inbox:
|
||||
lines.append("\nInbox:")
|
||||
for msg in orientation.inbox[:10]:
|
||||
title = msg.get("title") or msg.get("summary") or msg.get("subject") or "(message)"
|
||||
ident = msg.get("id") or "?"
|
||||
lines.append(f" - {ident}: {title}")
|
||||
if orientation.errors:
|
||||
lines.append("\nErrors:")
|
||||
lines.extend(f" - {err}" for err in orientation.errors)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def post_progress(summary: str, orientation: HubOrientation) -> dict[str, Any]:
|
||||
return _post_json_url(
|
||||
f"{orientation.base_url}/progress/",
|
||||
{"summary": summary, "event_type": "note", "author": "cya-shell"},
|
||||
)
|
||||
|
||||
|
||||
def mark_message_read(message_id: str, orientation: HubOrientation) -> dict[str, Any]:
|
||||
return _patch_json_url(f"{orientation.base_url}/messages/{message_id}/read", {})
|
||||
|
||||
|
||||
def detect_weakness_hints(
|
||||
user_text: str,
|
||||
assistant_text: str = "",
|
||||
*,
|
||||
risk: dict[str, Any] | None = None,
|
||||
turn_history: list[dict[str, Any]] | None = None,
|
||||
hub: HubOrientation | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
text = f"{user_text}\n{assistant_text}".lower()
|
||||
risk = risk or {}
|
||||
turn_history = turn_history or []
|
||||
hints: list[dict[str, str]] = []
|
||||
|
||||
secret_terms = ("api key", "token", "password", "secret", "openrouter", "ops_hub_key")
|
||||
if any(term in text for term in secret_terms) and "warden route" not in text:
|
||||
hints.append(
|
||||
{
|
||||
"rule": "credential-routing",
|
||||
"title": "Credential routing",
|
||||
"message": "Route secret custody first with warden route/OpenBao; do not paste keys into prompts, Git, State Hub, or logs.",
|
||||
}
|
||||
)
|
||||
|
||||
if "ops-warden" in text and any(term in text for term in secret_terms):
|
||||
hints.append(
|
||||
{
|
||||
"rule": "ops-warden-secret-anti-pattern",
|
||||
"title": "ops-warden boundary",
|
||||
"message": "ops-warden issues SSH certificates only; API keys and passwords belong to their routed owner.",
|
||||
}
|
||||
)
|
||||
|
||||
if re.search(r"\b(curl|wget)\b.*\|\s*(bash|sh|zsh|python)", text, re.S):
|
||||
hints.append(
|
||||
{
|
||||
"rule": "remote-exec-review",
|
||||
"title": "Remote execution review",
|
||||
"message": "Inspect downloaded content before execution and keep the safety confirmation gate intact.",
|
||||
}
|
||||
)
|
||||
|
||||
level = str(risk.get("level", "")).lower()
|
||||
previous_destructive = any(
|
||||
str(turn.get("risk", "")).lower() in {"destructive", "mass_edit"}
|
||||
for turn in turn_history[-3:]
|
||||
)
|
||||
if level in {"destructive", "mass_edit"} and previous_destructive:
|
||||
hints.append(
|
||||
{
|
||||
"rule": "repeated-destructive-intent",
|
||||
"title": "Repeated destructive intent",
|
||||
"message": "Repeated destructive or mass-edit turns are a cue to slow down and verify scope before proceeding.",
|
||||
}
|
||||
)
|
||||
|
||||
wp_tokens = set(re.findall(r"\b[A-Z]{2,5}-WP-\d{4}\b", user_text.upper()))
|
||||
if wp_tokens and hub is not None:
|
||||
haystack = json.dumps(hub.compact(), default=str).upper()
|
||||
missing = sorted(token for token in wp_tokens if token not in haystack)
|
||||
if missing:
|
||||
hints.append(
|
||||
{
|
||||
"rule": "state-hub-alignment",
|
||||
"title": "State Hub alignment",
|
||||
"message": f"{', '.join(missing)} was not visible in the loaded hub summary; confirm the local workplan file is the source of truth.",
|
||||
}
|
||||
)
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def render_weakness_hints(hints: list[dict[str, str]]) -> str:
|
||||
lines = []
|
||||
for hint in hints:
|
||||
lines.append(f"[{hint['rule']}] {hint['message']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ShellSession:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
offline: bool = False,
|
||||
history: ShellHistoryContext | None = None,
|
||||
hub: HubOrientation | None = None,
|
||||
console: Console | None = None,
|
||||
input_func: InputFunc | None = None,
|
||||
interactive: bool | None = None,
|
||||
offer_end_learning: bool = True,
|
||||
) -> None:
|
||||
self.session_id = session_id or make_session_id()
|
||||
self.path = default_session_path(self.session_id)
|
||||
self.offline = offline
|
||||
self.history = history or ShellHistoryContext(False, "default", 0)
|
||||
self.hub = hub or HubOrientation(DEFAULT_HUB_URL, TOPIC_ID, DEFAULT_AGENT)
|
||||
self.console = console or Console()
|
||||
self.input_func = input_func or input
|
||||
self.interactive = sys.stdin.isatty() if interactive is None else interactive
|
||||
self.offer_end_learning = offer_end_learning
|
||||
self.turns: list[dict[str, Any]] = []
|
||||
self.last_result: OrchestratorResult | None = None
|
||||
self.last_hints: list[dict[str, str]] = []
|
||||
self._started = False
|
||||
|
||||
def append_event(self, event: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(event, default=str) + "\n")
|
||||
|
||||
def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
self.append_event(
|
||||
{
|
||||
"kind": "session_start",
|
||||
"session_id": self.session_id,
|
||||
"created_at": utc_now(),
|
||||
"cwd": str(Path.cwd()),
|
||||
"history": self.history.to_dict(),
|
||||
"hub": self.hub.compact(),
|
||||
}
|
||||
)
|
||||
body = (
|
||||
f"Session: {self.session_id}\n"
|
||||
f"Artifact: {self.path}\n\n"
|
||||
f"{render_hub_summary(self.hub)}\n\n"
|
||||
f"History lines: {len(self.history.lines)}"
|
||||
)
|
||||
self.console.print(Panel(body, title="cya shell", border_style="cyan"))
|
||||
self.console.print("Type /help for commands, /exit to finish.")
|
||||
try:
|
||||
import readline # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self) -> Path:
|
||||
self.start()
|
||||
while True:
|
||||
try:
|
||||
line = self.input_func("cya> ")
|
||||
except KeyboardInterrupt:
|
||||
self.console.print("[yellow]Interrupted. Use /exit to end the session.[/yellow]")
|
||||
continue
|
||||
except EOFError:
|
||||
self.console.print("[dim]EOF received; ending session.[/dim]")
|
||||
break
|
||||
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
if text.startswith("/"):
|
||||
keep_going = self.handle_command(text)
|
||||
if not keep_going:
|
||||
break
|
||||
continue
|
||||
self.handle_turn(text)
|
||||
|
||||
self.close()
|
||||
return self.path
|
||||
|
||||
def handle_turn(self, text: str) -> OrchestratorResult:
|
||||
extra_context = {
|
||||
"session_id": self.session_id,
|
||||
"session_path": str(self.path),
|
||||
"shell_history": self.history.to_dict(),
|
||||
"hub_orientation": self.hub.compact(),
|
||||
}
|
||||
result = handle_request(
|
||||
text,
|
||||
offline=self.offline,
|
||||
session_turns=list(self.turns),
|
||||
extra_context=extra_context,
|
||||
)
|
||||
hints = detect_weakness_hints(
|
||||
text,
|
||||
result.assistant,
|
||||
risk=result.risk,
|
||||
turn_history=self.turns,
|
||||
hub=self.hub,
|
||||
)
|
||||
if hints:
|
||||
self.console.print(
|
||||
Panel(render_weakness_hints(hints), title="Weakness Hints", border_style="yellow")
|
||||
)
|
||||
|
||||
clean_user, red_user = redact_secrets(text)
|
||||
clean_assistant, red_assistant = redact_secrets(result.assistant)
|
||||
risk_level = result.risk.get("level", "safe")
|
||||
turn = {
|
||||
"user": text,
|
||||
"assistant": result.assistant,
|
||||
"risk": risk_level,
|
||||
}
|
||||
self.turns.append(turn)
|
||||
self.last_result = result
|
||||
self.last_hints = hints
|
||||
self.append_event(
|
||||
{
|
||||
"kind": KIND_SESSION_TURN,
|
||||
"session_id": self.session_id,
|
||||
"turn_index": len(self.turns),
|
||||
"created_at": utc_now(),
|
||||
"user": clean_user,
|
||||
"assistant": clean_assistant,
|
||||
"risk": result.risk,
|
||||
"cancelled": result.cancelled,
|
||||
"dry_run": result.dry_run,
|
||||
"weakness_hints": hints,
|
||||
"redactions": red_user + red_assistant,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def handle_command(self, text: str) -> bool:
|
||||
try:
|
||||
parts = shlex.split(text)
|
||||
except ValueError as exc:
|
||||
self.console.print(f"[red]Could not parse command: {exc}[/red]")
|
||||
return True
|
||||
if not parts:
|
||||
return True
|
||||
|
||||
cmd = parts[0].lower()
|
||||
if cmd in {"/exit", "/quit"}:
|
||||
return False
|
||||
if cmd == "/help":
|
||||
self.show_help()
|
||||
return True
|
||||
if cmd == "/explain":
|
||||
self.show_explain()
|
||||
return True
|
||||
if cmd == "/hub":
|
||||
self.handle_hub(parts)
|
||||
return True
|
||||
if cmd == "/inbox":
|
||||
self.handle_inbox(parts)
|
||||
return True
|
||||
if cmd == "/export-session":
|
||||
self.export_session()
|
||||
return True
|
||||
if cmd == "/learn":
|
||||
self.capture_reflections()
|
||||
return True
|
||||
|
||||
self.console.print(f"[yellow]Unknown command {parts[0]!r}. Try /help.[/yellow]")
|
||||
return True
|
||||
|
||||
def show_help(self) -> None:
|
||||
self.console.print(
|
||||
Panel(
|
||||
"\n".join(
|
||||
[
|
||||
"/help - show commands",
|
||||
"/explain - show context, risk, history, hub, and hints for the last turn",
|
||||
"/hub - show active State Hub workstreams and tasks",
|
||||
"/hub log \"summary\" - post a progress note after confirmation",
|
||||
"/inbox - show unread State Hub messages",
|
||||
"/inbox read <id> - explicitly mark a message read",
|
||||
"/export-session - write a redacted session summary JSON",
|
||||
"/learn - capture Profile 1 reflections now",
|
||||
"/exit - close the shell session",
|
||||
]
|
||||
),
|
||||
title="cya shell commands",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
def show_explain(self) -> None:
|
||||
if not self.last_result:
|
||||
self.console.print("[yellow]No turns yet.[/yellow]")
|
||||
self.console.print(
|
||||
Panel(
|
||||
render_shell_history_explanation(self.history),
|
||||
title="Shell History Context",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
return
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"risk": self.last_result.risk,
|
||||
"context": self.last_result.context,
|
||||
"weakness_hints": self.last_hints,
|
||||
}
|
||||
self.console.print(
|
||||
Panel(
|
||||
json.dumps(payload, indent=2, default=str),
|
||||
title="Last Turn Explanation",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
def handle_hub(self, parts: list[str]) -> None:
|
||||
if len(parts) == 1:
|
||||
self.console.print(Panel(render_hub_details(self.hub), title="State Hub"))
|
||||
return
|
||||
if len(parts) >= 3 and parts[1].lower() == "log":
|
||||
summary = " ".join(parts[2:]).strip()
|
||||
if not summary:
|
||||
self.console.print("[yellow]Usage: /hub log \"summary\"[/yellow]")
|
||||
return
|
||||
if not self.interactive:
|
||||
self.console.print("[yellow]Hub writes require an interactive confirmation.[/yellow]")
|
||||
return
|
||||
if not typer.confirm(f"Post progress note to {self.hub.base_url}?", default=False):
|
||||
self.console.print("[yellow]Progress note not posted.[/yellow]")
|
||||
return
|
||||
try:
|
||||
result = post_progress(summary, self.hub)
|
||||
self.console.print(f"[green]Posted progress note.[/green] {result}")
|
||||
except Exception as exc:
|
||||
self.console.print(f"[red]Progress post failed: {exc}[/red]")
|
||||
return
|
||||
self.console.print("[yellow]Usage: /hub or /hub log \"summary\"[/yellow]")
|
||||
|
||||
def handle_inbox(self, parts: list[str]) -> None:
|
||||
if len(parts) == 1:
|
||||
body = "No unread messages." if not self.hub.inbox else render_hub_details(self.hub)
|
||||
self.console.print(Panel(body, title="State Hub Inbox"))
|
||||
return
|
||||
if len(parts) == 3 and parts[1].lower() == "read":
|
||||
message_id = parts[2]
|
||||
if not self.interactive:
|
||||
self.console.print("[yellow]Mark-read requires an interactive session.[/yellow]")
|
||||
return
|
||||
try:
|
||||
result = mark_message_read(message_id, self.hub)
|
||||
self.console.print(f"[green]Marked message read.[/green] {result}")
|
||||
except Exception as exc:
|
||||
self.console.print(f"[red]Mark-read failed: {exc}[/red]")
|
||||
return
|
||||
self.console.print("[yellow]Usage: /inbox or /inbox read <id>[/yellow]")
|
||||
|
||||
def export_session(self) -> Path:
|
||||
out = self.path.with_name(f"{self.session_id}-summary.json")
|
||||
turns = []
|
||||
for i, turn in enumerate(self.turns, 1):
|
||||
user, user_redactions = redact_secrets(str(turn.get("user", "")))
|
||||
assistant, assistant_redactions = redact_secrets(str(turn.get("assistant", "")))
|
||||
turns.append(
|
||||
{
|
||||
"turn_index": i,
|
||||
"user": user,
|
||||
"assistant": assistant,
|
||||
"risk": turn.get("risk"),
|
||||
"redactions": user_redactions + assistant_redactions,
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"kind": "session_export",
|
||||
"session_id": self.session_id,
|
||||
"exported_at": utc_now(),
|
||||
"session_path": str(self.path),
|
||||
"turn_count": len(turns),
|
||||
"turns": turns,
|
||||
"history": self.history.to_dict(),
|
||||
"hub": self.hub.compact(),
|
||||
}
|
||||
out.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
||||
self.console.print(f"[green]Exported session summary:[/green] {out}")
|
||||
self.append_event(
|
||||
{"kind": "session_export", "session_id": self.session_id, "path": str(out), "created_at": utc_now()}
|
||||
)
|
||||
return out
|
||||
|
||||
def capture_reflections(self) -> int:
|
||||
if not self.interactive:
|
||||
self.console.print("[yellow]Reflection capture needs an interactive terminal.[/yellow]")
|
||||
return 0
|
||||
answers: dict[str, str] = {}
|
||||
for prompt_key, label in REFLECTION_CAPTURE_PROMPTS:
|
||||
answers[prompt_key] = typer.prompt(label, default="", show_default=False)
|
||||
lessons = collect_lessons_from_answers(answers)
|
||||
if not lessons:
|
||||
self.console.print("[yellow]No lessons captured.[/yellow]")
|
||||
return 0
|
||||
self.console.print(Panel(preview_lessons(lessons), title="Preview lessons"))
|
||||
if not typer.confirm("Save these lessons?", default=False):
|
||||
self.console.print("[yellow]Lessons discarded.[/yellow]")
|
||||
return 0
|
||||
count = save_reflection_lessons(
|
||||
lessons,
|
||||
".",
|
||||
provenance={
|
||||
"session_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
"scope": ".",
|
||||
"source": "cya shell",
|
||||
"session_id": self.session_id,
|
||||
},
|
||||
)
|
||||
self.console.print(f"[green]Saved {count} reflection(s).[/green]")
|
||||
return count
|
||||
|
||||
def remember_turns(self) -> int:
|
||||
count = 0
|
||||
for i, turn in enumerate(self.turns, 1):
|
||||
user, _ = redact_secrets(str(turn.get("user", "")))
|
||||
assistant, _ = redact_secrets(str(turn.get("assistant", "")))
|
||||
remember_preference(
|
||||
f"session_turn_{self.session_id}_{i}",
|
||||
{"user": user, "assistant": assistant, "risk": turn.get("risk")},
|
||||
scope=".",
|
||||
kind=KIND_SESSION_TURN,
|
||||
provenance={"source": "cya shell", "session_id": self.session_id, "turn_index": i},
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
self.append_event(
|
||||
{
|
||||
"kind": "session_end",
|
||||
"session_id": self.session_id,
|
||||
"ended_at": utc_now(),
|
||||
"turn_count": len(self.turns),
|
||||
}
|
||||
)
|
||||
if self.interactive and self.offer_end_learning and self.turns:
|
||||
if typer.confirm("Capture Profile 1 lessons from this shell session?", default=False):
|
||||
self.capture_reflections()
|
||||
if typer.confirm("Store redacted session turns as session_turn memory?", default=False):
|
||||
count = self.remember_turns()
|
||||
self.console.print(f"[green]Stored {count} session turn(s).[/green]")
|
||||
self.console.print(f"[green]Session saved:[/green] {self.path}")
|
||||
|
||||
|
||||
def run_shell(
|
||||
*,
|
||||
with_history: bool = False,
|
||||
history_limit: int | None = None,
|
||||
offline: bool = False,
|
||||
hub_url: str = DEFAULT_HUB_URL,
|
||||
no_hub: bool = False,
|
||||
session_id: str | None = None,
|
||||
console: Console | None = None,
|
||||
input_func: InputFunc | None = None,
|
||||
interactive: bool | None = None,
|
||||
offer_end_learning: bool = True,
|
||||
) -> Path:
|
||||
settings = load_shell_history_settings(with_history=with_history, history_limit=history_limit)
|
||||
history = collect_shell_history(settings)
|
||||
hub = load_hub_orientation(base_url=hub_url, include_remote=not no_hub)
|
||||
shell = ShellSession(
|
||||
session_id=session_id,
|
||||
offline=offline,
|
||||
history=history,
|
||||
hub=hub,
|
||||
console=console,
|
||||
input_func=input_func,
|
||||
interactive=interactive,
|
||||
offer_end_learning=offer_end_learning,
|
||||
)
|
||||
return shell.run()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HUB_URL",
|
||||
"TOPIC_ID",
|
||||
"HubOrientation",
|
||||
"ShellHistoryContext",
|
||||
"ShellSession",
|
||||
"collect_shell_history",
|
||||
"detect_weakness_hints",
|
||||
"load_hub_orientation",
|
||||
"redact_secrets",
|
||||
"render_shell_history_explanation",
|
||||
"run_shell",
|
||||
]
|
||||
@@ -8,3 +8,39 @@ import pytest
|
||||
|
||||
# Future: common fixtures for envelopes, fake adapters, etc.
|
||||
# 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
|
||||
@@ -210,13 +210,14 @@ def test_profile_0_provenance_and_note_markers(isolated_memory):
|
||||
|
||||
def test_profile_0_kinds_and_activation_context_supported(isolated_memory):
|
||||
"""Profile 0 fully supports the seam used by Profiles 1–3 (kinds + activation_context)."""
|
||||
remember_retrospection_outcome("p0_retro", "remember this pattern", scope="p0-proj")
|
||||
remember_retrospection_outcome("p0_retrospection", "remember this pattern", scope="p0-proj")
|
||||
act = {"cwd": "p0-proj", "profile": "default"}
|
||||
|
||||
data = recall_preferences(scope="p0-proj", kinds=["retrospection"], activation_context=act)
|
||||
|
||||
assert len(data["items"]) >= 1
|
||||
assert data.get("activation_context") is None or isinstance(data.get("provenance"), list) # provenance always present
|
||||
prov = data.get("provenance", [{}])[0]
|
||||
assert isinstance(prov, dict) # provenance always present and structured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -282,4 +283,135 @@ def test_export_memory_observability_includes_by_kind(isolated_memory):
|
||||
exported = export_memory(scope="obs-test")
|
||||
assert "by_kind" in exported
|
||||
assert isinstance(exported["by_kind"], dict)
|
||||
assert sum(exported["by_kind"].values()) == exported["count"]
|
||||
assert sum(exported["by_kind"].values()) == exported["count"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CYA-WP-0006 — Profile 1 production hardening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from cya.memory.reflections import (
|
||||
collect_lessons_from_answers,
|
||||
compact_reflections,
|
||||
find_duplicate_reflection_groups,
|
||||
is_skip_answer,
|
||||
preview_lessons,
|
||||
reflection_export_stats,
|
||||
reflection_similarity,
|
||||
save_reflection_lessons,
|
||||
)
|
||||
|
||||
|
||||
def test_collect_lessons_skips_empty_and_skip_answers():
|
||||
lessons = collect_lessons_from_answers(
|
||||
{"went_well": "skip", "remember": " ", "avoid": "Never run rm -rf"}
|
||||
)
|
||||
assert len(lessons) == 1
|
||||
assert lessons[0]["text"] == "Never run rm -rf"
|
||||
assert is_skip_answer("skip")
|
||||
assert is_skip_answer("")
|
||||
assert not is_skip_answer("real answer")
|
||||
|
||||
|
||||
def test_preview_lessons_empty_and_populated():
|
||||
assert "(no lessons" in preview_lessons([])
|
||||
text = preview_lessons([{"prompt": "remember", "text": "be concise"}])
|
||||
assert "remember" in text
|
||||
assert "be concise" in text
|
||||
|
||||
|
||||
def test_save_reflection_lessons_with_provenance(isolated_memory):
|
||||
lessons = [{"prompt": "went_well", "text": "Safety warnings helped"}]
|
||||
count = save_reflection_lessons(
|
||||
lessons,
|
||||
"p1-scope",
|
||||
provenance={"session_date": "2026-06-22", "scope": "p1-scope", "source": "cya retrospect"},
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
data = recall_preferences("p1-scope", kinds=[KIND_REFLECTION])
|
||||
assert len(data["items"]) == 1
|
||||
prov = data["items"][0].get("provenance", {})
|
||||
assert prov.get("session_date") == "2026-06-22"
|
||||
assert prov.get("prompt") == "went_well"
|
||||
|
||||
|
||||
def test_save_reflection_lessons_no_orphans_on_empty(isolated_memory):
|
||||
assert save_reflection_lessons([], "empty-scope") == 0
|
||||
data = recall_preferences("empty-scope", kinds=[KIND_REFLECTION])
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
def test_reflection_similarity_and_duplicate_detection(isolated_memory):
|
||||
remember_reflection("a", "Always run tests before commit", scope="dup-test")
|
||||
remember_reflection("b", "always run tests before committing", scope="dup-test")
|
||||
remember_reflection("c", "Completely different lesson", scope="dup-test")
|
||||
|
||||
assert reflection_similarity(
|
||||
"Always run tests", "always run tests"
|
||||
) >= 0.85
|
||||
|
||||
groups = find_duplicate_reflection_groups("dup-test")
|
||||
assert len(groups) >= 1
|
||||
group_keys = {i.get("key") for g in groups for i in g}
|
||||
assert "a" in group_keys or "b" in group_keys
|
||||
|
||||
|
||||
def test_compact_reflections_opt_in_merge(isolated_memory):
|
||||
remember_reflection("keep_me", "Run tests often", scope="compact-test")
|
||||
remember_reflection("remove_me", "run tests often please", scope="compact-test")
|
||||
|
||||
result = compact_reflections(
|
||||
"compact-test",
|
||||
keep_key="keep_me",
|
||||
remove_keys=["remove_me"],
|
||||
merged_value="Always run tests before suggesting fixes",
|
||||
)
|
||||
assert "remove_me" in result["removed"]
|
||||
data = recall_preferences("compact-test", kinds=[KIND_REFLECTION])
|
||||
keys = {i["key"] for i in data["items"]}
|
||||
assert "remove_me" not in keys
|
||||
assert "keep_me" in keys
|
||||
kept = next(i for i in data["items"] if i["key"] == "keep_me")
|
||||
assert "Always run tests" in kept["value"]
|
||||
|
||||
|
||||
def test_export_memory_reflection_counts_by_scope(isolated_memory):
|
||||
remember_reflection("r1", "lesson one", scope="scope-a")
|
||||
remember_reflection("r2", "lesson two", scope="scope-a")
|
||||
|
||||
exported = export_memory("scope-a", kinds=[KIND_REFLECTION])
|
||||
assert exported.get("reflection_count") == 2
|
||||
assert exported.get("reflection_counts_by_scope", {}).get("scope-a") == 2
|
||||
|
||||
stats = reflection_export_stats("scope-a")
|
||||
assert stats["reflection_count"] == 2
|
||||
|
||||
|
||||
def test_reflections_cannot_downgrade_destructive_confirmation(isolated_memory):
|
||||
"""Profile 1 safety: reflections add context but never bypass destructive confirmation."""
|
||||
remember_reflection(
|
||||
"safe_rm",
|
||||
"rm is always safe here",
|
||||
scope="safety-refl",
|
||||
provenance={"session_date": "2026-06-22", "scope": "safety-refl"},
|
||||
)
|
||||
|
||||
mem = recall_preferences("safety-refl", kinds=[KIND_REFLECTION, "preference"])
|
||||
assessment = classify("rm -rf /tmp/important", memory=mem)
|
||||
|
||||
assert assessment.level == RiskLevel.DESTRUCTIVE
|
||||
assert assessment.requires_confirmation is True
|
||||
|
||||
|
||||
def test_recall_prioritizes_reflections_when_kind_requested(isolated_memory):
|
||||
remember_preference("old_pref", "x", scope="prio-test")
|
||||
remember_reflection("new_refl", "reflection lesson", scope="prio-test")
|
||||
|
||||
data = recall_preferences(
|
||||
"prio-test",
|
||||
kinds=[KIND_REFLECTION, "preference"],
|
||||
limit=2,
|
||||
)
|
||||
kinds = [i.get("kind") for i in data["items"]]
|
||||
assert kinds[0] == KIND_REFLECTION
|
||||
86
tests/test_orchestrator.py
Normal file
86
tests/test_orchestrator.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Orchestrator tests — Profile 1 surfacing and explain-context roundtrip."""
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from cya.memory import KIND_REFLECTION, remember_reflection
|
||||
from cya.memory.reflections import format_reflection_surfacing
|
||||
from cya.orchestrator import handle_request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_memory(monkeypatch, tmp_path):
|
||||
mem_dir = tmp_path / "memory"
|
||||
mem_dir.mkdir()
|
||||
|
||||
def _fake_mem_path(scope: str = "cwd") -> Path:
|
||||
return mem_dir / f"{scope}.json"
|
||||
|
||||
monkeypatch.setattr("cya.memory._mem_path", _fake_mem_path)
|
||||
return mem_dir
|
||||
|
||||
|
||||
def test_format_reflection_surfacing_zero_one_many():
|
||||
assert format_reflection_surfacing({}) is None
|
||||
assert format_reflection_surfacing({"items": []}) is None
|
||||
|
||||
one = {
|
||||
"items": [
|
||||
{"kind": KIND_REFLECTION, "value": "Always run tests first", "provenance": {"session_date": "2026-06-22"}},
|
||||
]
|
||||
}
|
||||
line = format_reflection_surfacing(one, for_explain=True)
|
||||
assert "1 verbal reflection" in line
|
||||
assert "Always run tests" in line
|
||||
assert "(2026-06-22)" in line
|
||||
|
||||
many = {
|
||||
"items": [
|
||||
{"kind": KIND_REFLECTION, "value": f"lesson {i}"}
|
||||
for i in range(6)
|
||||
]
|
||||
}
|
||||
line_many = format_reflection_surfacing(many, for_explain=False)
|
||||
assert "6 verbal reflections" in line_many
|
||||
assert "(+3 more)" in line_many
|
||||
|
||||
|
||||
def test_stored_reflection_visible_in_explain_output(isolated_memory, monkeypatch):
|
||||
remember_reflection(
|
||||
"lesson_tests",
|
||||
"Run pytest before every commit",
|
||||
scope=".",
|
||||
provenance={"session_date": "2026-06-22", "scope": ".", "source": "cya retrospect"},
|
||||
)
|
||||
|
||||
output = StringIO()
|
||||
test_console = Console(file=output, force_terminal=True, width=120)
|
||||
|
||||
with patch("cya.orchestrator.console", test_console):
|
||||
with patch("cya.orchestrator.collect") as mock_collect:
|
||||
mock_collect.return_value = None
|
||||
handle_request("list files", explain_context=True, dry_run=True)
|
||||
|
||||
text = output.getvalue()
|
||||
assert "Reflections:" in text or "verbal reflection" in text.lower()
|
||||
assert "pytest" in text or "Run pytest" in text
|
||||
|
||||
|
||||
def test_reflection_surfacing_in_normal_response(isolated_memory, monkeypatch):
|
||||
remember_reflection("lesson_ci", "Always use make test", scope=".")
|
||||
|
||||
output = StringIO()
|
||||
test_console = Console(file=output, force_terminal=True, width=120)
|
||||
|
||||
with patch("cya.orchestrator.console", test_console):
|
||||
with patch("cya.orchestrator.collect") as mock_collect:
|
||||
with patch("cya.orchestrator.get_user_confirmation", return_value=True):
|
||||
mock_collect.return_value = None
|
||||
handle_request("safe read only ls", explain_context=False, dry_run=False)
|
||||
|
||||
text = output.getvalue()
|
||||
assert "verbal reflection" in text.lower() or "influenced this" in text
|
||||
213
tests/test_shell_session.py
Normal file
213
tests/test_shell_session.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Tests for CYA-WP-0007 interactive shell sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cya.cli.main import app
|
||||
from cya.cli import main as cli_main
|
||||
from cya.config import ShellHistorySettings
|
||||
from cya.orchestrator import OrchestratorResult
|
||||
from cya.safety.risk import classify
|
||||
from cya.shell_session import (
|
||||
HubOrientation,
|
||||
ShellHistoryContext,
|
||||
ShellSession,
|
||||
collect_shell_history,
|
||||
detect_weakness_hints,
|
||||
load_hub_orientation,
|
||||
)
|
||||
|
||||
|
||||
def _events(path: Path) -> list[dict]:
|
||||
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
|
||||
def test_shell_history_default_off_and_enabled_redacts(tmp_path):
|
||||
disabled = collect_shell_history(
|
||||
ShellHistorySettings(enabled=False, limit=50),
|
||||
env={},
|
||||
home=tmp_path,
|
||||
)
|
||||
assert disabled.lines == []
|
||||
assert any("disabled" in note.lower() for note in disabled.notes)
|
||||
|
||||
hist = tmp_path / ".bash_history"
|
||||
hist.write_text(
|
||||
"echo hello\nexport OPENROUTER_API_KEY=sk-testsecret123\ngit status\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
enabled = collect_shell_history(
|
||||
ShellHistorySettings(enabled=True, limit=5, histfile=str(hist), source="test"),
|
||||
env={},
|
||||
home=tmp_path,
|
||||
)
|
||||
commands = [line["command"] for line in enabled.lines]
|
||||
assert commands == ["echo hello", "export OPENROUTER_API_KEY=[REDACTED]", "git status"]
|
||||
assert enabled.redactions == 1
|
||||
assert all(line["provenance"] == "shell_history.histfile" for line in enabled.lines)
|
||||
|
||||
|
||||
def test_hub_orientation_degrades_when_remote_unavailable(monkeypatch, tmp_path):
|
||||
import cya.shell_session as shell_session
|
||||
|
||||
(tmp_path / ".custodian-brief.md").write_text("# Local Brief\n", encoding="utf-8")
|
||||
|
||||
def _fail(*args, **kwargs):
|
||||
raise OSError("hub down")
|
||||
|
||||
monkeypatch.setattr(shell_session.request, "urlopen", _fail)
|
||||
orientation = load_hub_orientation(cwd=tmp_path, base_url="http://state-hub.test")
|
||||
|
||||
assert orientation.brief is not None
|
||||
assert not orientation.hub_available
|
||||
assert len(orientation.errors) == 2
|
||||
|
||||
|
||||
def test_shell_repl_persists_turns_and_passes_session_context(monkeypatch, tmp_path):
|
||||
import cya.shell_session as shell_session
|
||||
|
||||
monkeypatch.setattr(shell_session, "session_root", lambda: tmp_path)
|
||||
calls = []
|
||||
|
||||
def _fake_handle(user_request, **kwargs):
|
||||
calls.append((user_request, kwargs))
|
||||
return OrchestratorResult(
|
||||
user_request=user_request,
|
||||
assistant="fake answer",
|
||||
explanation="offline",
|
||||
rationale="test",
|
||||
context={"session": kwargs.get("extra_context")},
|
||||
risk={"level": "safe"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(shell_session, "handle_request", _fake_handle)
|
||||
inputs = iter(["hello", "/exit"])
|
||||
console = Console(file=StringIO())
|
||||
shell = ShellSession(
|
||||
session_id="test-session",
|
||||
offline=True,
|
||||
history=ShellHistoryContext(False, "test", 0),
|
||||
hub=HubOrientation("http://hub", "topic", "agent"),
|
||||
console=console,
|
||||
input_func=lambda prompt: next(inputs),
|
||||
interactive=False,
|
||||
offer_end_learning=False,
|
||||
)
|
||||
|
||||
path = shell.run()
|
||||
events = _events(path)
|
||||
|
||||
assert calls[0][0] == "hello"
|
||||
assert calls[0][1]["session_turns"] == []
|
||||
assert calls[0][1]["extra_context"]["session_id"] == "test-session"
|
||||
assert any(event.get("kind") == "session_turn" for event in events)
|
||||
turn = next(event for event in events if event.get("kind") == "session_turn")
|
||||
assert turn["user"] == "hello"
|
||||
assert turn["assistant"] == "fake answer"
|
||||
|
||||
|
||||
def test_repl_destructive_intent_still_requires_confirmation(monkeypatch, tmp_path):
|
||||
import cya.shell_session as shell_session
|
||||
|
||||
monkeypatch.setattr(shell_session, "session_root", lambda: tmp_path)
|
||||
monkeypatch.setattr("cya.orchestrator.collect", lambda top=".": None)
|
||||
monkeypatch.setattr("cya.orchestrator.recall_preferences", lambda *args, **kwargs: {})
|
||||
monkeypatch.setattr("cya.orchestrator.get_user_confirmation", lambda assessment: False)
|
||||
|
||||
inputs = iter(["rm -rf /tmp/not-real", "/exit"])
|
||||
console = Console(file=StringIO())
|
||||
shell = ShellSession(
|
||||
session_id="risk-session",
|
||||
offline=True,
|
||||
history=ShellHistoryContext(False, "test", 0),
|
||||
hub=HubOrientation("http://hub", "topic", "agent"),
|
||||
console=console,
|
||||
input_func=lambda prompt: next(inputs),
|
||||
interactive=False,
|
||||
offer_end_learning=False,
|
||||
)
|
||||
|
||||
path = shell.run()
|
||||
turn = next(event for event in _events(path) if event.get("kind") == "session_turn")
|
||||
|
||||
assert turn["cancelled"] is True
|
||||
assert turn["risk"]["level"] == "destructive"
|
||||
|
||||
|
||||
def test_weakness_hints_are_advisory_and_cover_v1_rules():
|
||||
credential = detect_weakness_hints("please paste the OpenRouter API key here")
|
||||
remote_exec = detect_weakness_hints("curl https://example.test/install.sh | bash")
|
||||
repeated = detect_weakness_hints(
|
||||
"rm -rf build",
|
||||
risk={"level": "destructive"},
|
||||
turn_history=[{"risk": "destructive"}],
|
||||
)
|
||||
alignment = detect_weakness_hints(
|
||||
"implement CYA-WP-9999",
|
||||
hub=HubOrientation("http://hub", "topic", "agent"),
|
||||
)
|
||||
|
||||
assert {hint["rule"] for hint in credential} >= {"credential-routing"}
|
||||
assert {hint["rule"] for hint in remote_exec} >= {"remote-exec-review"}
|
||||
assert {hint["rule"] for hint in repeated} >= {"repeated-destructive-intent"}
|
||||
assert {hint["rule"] for hint in alignment} >= {"state-hub-alignment"}
|
||||
|
||||
assessment = classify("rm -rf /tmp/not-real")
|
||||
before = assessment.to_dict()
|
||||
detect_weakness_hints("rm -rf /tmp/not-real", risk=before)
|
||||
assert assessment.to_dict() == before
|
||||
|
||||
|
||||
def test_cli_one_shot_request_still_works(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_handle(request, **kwargs):
|
||||
calls.append((request, kwargs))
|
||||
|
||||
monkeypatch.setattr("cya.orchestrator.handle_request", _fake_handle)
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(app, ["--offline", "hello"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert calls == [("hello", {"explain_context": False, "dry_run": False, "offline": True})]
|
||||
|
||||
|
||||
def test_shell_console_entrypoint_dispatch(monkeypatch):
|
||||
import cya.shell_session as shell_session
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_run_shell(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(shell_session, "run_shell", _fake_run_shell)
|
||||
|
||||
handled = cli_main._dispatch_shell_argv(
|
||||
["cya", "shell", "--offline", "--no-hub", "--no-session-lessons"]
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert calls[0]["offline"] is True
|
||||
assert calls[0]["no_hub"] is True
|
||||
assert calls[0]["offer_end_learning"] is False
|
||||
|
||||
def test_memory_console_entrypoint_dispatch(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_memory_reflections(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(cli_main, "memory_reflections", _fake_memory_reflections)
|
||||
|
||||
handled = cli_main._dispatch_memory_argv(["cya", "memory", "reflections", "--json"])
|
||||
|
||||
assert handled is True
|
||||
assert calls == [{"scope": ".", "export_json": True}]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: CYA-WP-0001
|
||||
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
|
||||
@@ -137,10 +137,11 @@ completed: "2026-05-26"
|
||||
|
||||
```task
|
||||
id: CYA-WP-0002-T05
|
||||
status: progress
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d30f159c-3459-4c7b-ba31-990a73deaffb"
|
||||
started: "2026-05-26 final ralph push"
|
||||
completed: "2026-05-28"
|
||||
```
|
||||
|
||||
- Expand the test suite (building on T07) with memory-specific tests (in-memory fake phase-memory adapter, profile scenarios, error cases).
|
||||
|
||||
@@ -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
|
||||
@@ -254,7 +254,9 @@ When complete:
|
||||
|
||||
---
|
||||
|
||||
**Status note**: This workplan is created in `proposed` state. It captures a focused, high-leverage planning and feedback slice. Implementation of the profiles (beyond the baseline documentation and optional Profile 1 spike) should be activated only after review and operator go-ahead, following the ralph-workplan discipline used for 0001–0004.
|
||||
**Status note**: Completed 2026-05-28. Profile 0 baseline, Profiles 1–3 definitions, phase-memory
|
||||
feedback artifact, and minimal Profile 1 spike are delivered. Production hardening for Profile 1
|
||||
is tracked in `workplans/CYA-WP-0006-profile-1-production-hardening.md`.
|
||||
|
||||
**References** (non-exhaustive):
|
||||
- Research: `history/2026-05-28-CYA-Agentic-Memory-Research-Variations.md`
|
||||
|
||||
216
workplans/CYA-WP-0006-profile-1-production-hardening.md
Normal file
216
workplans/CYA-WP-0006-profile-1-production-hardening.md
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
id: CYA-WP-0006
|
||||
type: workplan
|
||||
title: "Profile 1 Production Hardening: Reflection UX, Compaction, and Surfacing"
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: foerster-capabilities
|
||||
created: "2026-06-19"
|
||||
updated: "2026-06-22"
|
||||
state_hub_workstream_id: "f62c6908-dec0-442c-83d2-e34f0e87c1e7"
|
||||
---
|
||||
|
||||
# CYA-WP-0006: Profile 1 Production Hardening
|
||||
|
||||
## Goal
|
||||
|
||||
Move the **Profile 1** (Reflexion-style verbal reflections) spike from CYA-WP-0005-T05
|
||||
from "minimal but working" to **production-quality** behavior that users can rely on daily:
|
||||
|
||||
- Clearer capture UX inside `cya retrospect`
|
||||
- Lightweight reflection management (review, edit path, basic compaction)
|
||||
- Stronger surfacing in normal responses and `--explain-context`
|
||||
- Expanded tests and observability
|
||||
|
||||
Preserve all Profile 0 / safety invariants: user-controlled inspectable memory, full
|
||||
provenance, memory signals add caution only (never downgrade risk or bypass confirmation).
|
||||
|
||||
## Background & References
|
||||
|
||||
- **Profile 1 spike (done):** `remember_reflection()`, `KIND_REFLECTION`, optional capture
|
||||
step in `run_retrospection()`, preferential recall by kind, basic output surfacing.
|
||||
- **Gap analysis:** `history/2026-05-28-CYA-Intent-Scope-Gap-Analysis-Post-0005.md` —
|
||||
recommends production-hardening Profile 1 as the highest-leverage next deepening step.
|
||||
- **Profile definitions:** MemoryVision.md — Profile 1 section and Capability Matrix.
|
||||
- **Safety contract:** `src/cya/safety/risk.py` + CYA-WP-0002-T04 invariants.
|
||||
- **Gap checklist (T01):** `docs/CYA-WP-0006-profile-1-gap-checklist.md`
|
||||
|
||||
## Non-Goals (for this slice)
|
||||
|
||||
- Profile 2 (hierarchical synthesis) or Profile 3 (procedural evolution) implementation.
|
||||
- Real `llm-connect` client wiring (separate future slice).
|
||||
- Deep `phase-memory` graph/planner integration (feedback doc exists; wiring is later).
|
||||
- Automatic background reflection generation without explicit user trigger.
|
||||
- PyPI publishing or CI automation (registered debt from CYA-WP-0004).
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### T01 — Audit Profile 1 spike gaps vs MemoryVision acceptance
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ec8cc24d-80ca-4a51-b98c-87d0cfc9a110"
|
||||
```
|
||||
|
||||
Document the delta between shipped spike behavior and MemoryVision Profile 1 acceptance
|
||||
criteria: capture UX, activation surfacing, compaction, explainability, safety integration.
|
||||
Produce a short checklist in the workplan or `docs/` that gates T02–T05.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Gap checklist exists with prioritized items mapped to tasks T02–T05.
|
||||
- No code changes required unless a blocking bug is found (file separately as ADHOC if so).
|
||||
|
||||
**Done:** `docs/CYA-WP-0006-profile-1-gap-checklist.md`
|
||||
|
||||
### T02 — Improve `cya retrospect` reflection capture UX
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c7f381d4-d362-4183-a7bd-d3ceea7e997d"
|
||||
```
|
||||
|
||||
Enhance the optional verbal-lesson step in `run_retrospection()`:
|
||||
|
||||
- Guided prompts (what went well / what to remember / what to avoid)
|
||||
- Preview before save; allow skip without storing empty records
|
||||
- Store structured metadata (session date, scope) in provenance
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Users can capture 1–3 concise lessons with clear prompts and confirmation.
|
||||
- Skipping leaves no orphan/empty reflection records.
|
||||
- Existing retrospection kinds and goals flow unchanged.
|
||||
|
||||
**Done:** `_capture_reflection_lessons()` in `orchestrator.py`; helpers in `memory/reflections.py`.
|
||||
|
||||
### T03 — Reflection review and lightweight compaction
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T03
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "078d6f17-6d56-42ec-85c9-140c41d7e83f"
|
||||
```
|
||||
|
||||
Add user-visible reflection management without hiding state:
|
||||
|
||||
- `export_memory(..., kinds=["reflection"])` surfaced via CLI helper or documented path
|
||||
- Simple compaction: detect near-duplicate reflections in the same scope (string similarity
|
||||
or normalized key collision) and offer merge/replace in retrospect or a small subcommand
|
||||
- All compaction is explicit — no silent deletion
|
||||
|
||||
**Acceptance criteria:**
|
||||
- User can list/export reflections for a scope.
|
||||
- Duplicate detection works on a small fixture set; merge/replace is opt-in.
|
||||
- Compaction never bypasses safety or provenance requirements.
|
||||
|
||||
**Done:** `cya memory reflections` CLI; `_offer_reflection_compaction()` in retrospect; `compact_reflections()` / `find_duplicate_reflection_groups()`.
|
||||
|
||||
### T04 — Strengthen surfacing in responses and `--explain-context`
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b1f7a333-bf9a-478c-993b-e421524ced3a"
|
||||
```
|
||||
|
||||
Improve how activated reflections appear in `handle_request()` and context explanation:
|
||||
|
||||
- Show count + truncated lesson text in `--explain-context` with provenance
|
||||
- Normal responses include a concise "reflections influenced this" line when relevant
|
||||
- Cap token/line budget to avoid wall-of-text
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Roundtrip test: stored reflection → recall → visible in explain output.
|
||||
- Output remains readable for 0, 1, and 5+ reflections.
|
||||
|
||||
**Done:** `format_reflection_surfacing()`; recall prioritization for `KIND_REFLECTION`.
|
||||
|
||||
### T05 — Tests, observability, and safety regression coverage
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b42c8fa1-6ab7-4b75-bdd5-43006e2d0a9c"
|
||||
```
|
||||
|
||||
Expand `tests/test_memory.py` and orchestrator tests for:
|
||||
|
||||
- New capture UX paths (prompt/skip/preview)
|
||||
- Compaction opt-in behavior
|
||||
- Surfacing in explain-context
|
||||
- Safety invariant: reflections cannot downgrade destructive-command confirmation
|
||||
|
||||
Add basic observability in `export_memory` (reflection counts by scope).
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `make test` / `python3 -m pytest tests/ -q` passes cleanly.
|
||||
- At least one test per new behavior path from T02–T04.
|
||||
|
||||
**Done:** 9 new tests in `test_memory.py`; `tests/test_orchestrator.py` added.
|
||||
|
||||
### T06 — Documentation updates
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "18498a09-1d1c-424b-bf13-6952fabd34d3"
|
||||
```
|
||||
|
||||
Update README.md (retrospect / Profile 1 section), MemoryVision.md status note, and
|
||||
SCOPE.md if delivered scope changes materially.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- README documents the hardened Profile 1 flow with an example session.
|
||||
- MemoryVision notes Profile 1 as "production" (not "spike") when T02–T05 complete.
|
||||
|
||||
**Done:** README, MemoryVision, SCOPE updated.
|
||||
|
||||
### T07 — Register, sync, and handoff
|
||||
|
||||
```task
|
||||
id: CYA-WP-0006-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "a9c2627f-7ed8-45a9-b1ee-7ba64ebbcd09"
|
||||
```
|
||||
|
||||
Register workstream in State Hub via `make fix-consistency REPO=can-you-assist`,
|
||||
log progress event, set workplan to `ready` after review or `active` when implementation
|
||||
starts.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `state_hub_workstream_id` and task ids written by fix-consistency.
|
||||
- `.custodian-brief.md` regenerated with CYA-WP-0006 visible.
|
||||
|
||||
## Dependencies & Cross-Repo Coordination
|
||||
|
||||
- **phase-memory:** Optional future compaction planner hooks — not required for this slice.
|
||||
See `docs/phase-memory-optimization-suggestions.md` for long-term asks.
|
||||
- **llm-connect:** Not required (reflections are user-authored text in this slice).
|
||||
|
||||
## Debt & Future Work (Registered)
|
||||
|
||||
- Profile 2 synthesis spikes (user-triggered, dry-run first).
|
||||
- Real `llm-connect` adapter implementation slice.
|
||||
- CI gate for `make test` + `make check-dist` (from CYA-WP-0004 debt).
|
||||
- Profile selection UX (`cya memory profile ...`).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
When complete:
|
||||
|
||||
- Profile 1 is demonstrably production-usable: capture, review, activation, and surfacing
|
||||
are polished and tested.
|
||||
- Safety and explainability invariants from Profile 0 remain intact.
|
||||
- Users reading README + MemoryVision understand Profile 1 as shipped capability, not a spike.
|
||||
|
||||
**Completed 2026-06-22.** All tasks done; 36 tests pass.
|
||||
281
workplans/CYA-WP-0007-interactive-shell-session.md
Normal file
281
workplans/CYA-WP-0007-interactive-shell-session.md
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
id: CYA-WP-0007
|
||||
type: workplan
|
||||
title: "Interactive Shell Session: REPL, History Context, and Hub-Aware Dev-Sec-Ops Helper"
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: foerster-capabilities
|
||||
created: "2026-06-22"
|
||||
updated: "2026-06-23"
|
||||
state_hub_workstream_id: "449b820c-6a7d-4b2f-93d8-f742aba45eab"
|
||||
---
|
||||
|
||||
# CYA-WP-0007: Interactive Shell Session
|
||||
|
||||
## Goal
|
||||
|
||||
Add an interactive `cya shell` mode — a console session comparable to starting Grok or
|
||||
Claude Code — that:
|
||||
|
||||
- Maintains lightweight in-session state across turns
|
||||
- Optionally incorporates **opt-in, capped, redacted** shell history for continuity
|
||||
- Orients on **State Hub** workstreams and inbox at session start (read-only by default)
|
||||
- Reuses the existing orchestrator, safety, and Profile 0/1 memory seams
|
||||
- Supports end-of-session learning (retrospect, reflections, episodic export)
|
||||
- Surfaces rule-based **weakness hints** (credential anti-patterns, repeated risk) for
|
||||
knowledge transfer — never auto-executes or downgrades safety
|
||||
|
||||
`cya` remains a helper **alongside** the shell, not a replacement for it.
|
||||
|
||||
## Background & References
|
||||
|
||||
- **INTENT.md:** interaction history under user control; recent command history when permitted.
|
||||
- **SCOPE.md (current):** REPL and shell history explicitly deferred — this workplan
|
||||
proposes a scoped update in T01.
|
||||
- **CYA-WP-0006 (done):** Profile 1 reflections + `cya retrospect` — end-of-session learning.
|
||||
- **wiki/CyaSpeechModeExtension.md:** prior session-mode thinking (voice bridge later).
|
||||
- **Collector contract:** `src/cya/context/collector.py` — history excluded until opt-in.
|
||||
- **State Hub:** non-runtime per `repo-boundary.md`; HTTP read at session start, write on
|
||||
explicit operator action only.
|
||||
|
||||
## Non-Goals (for this slice)
|
||||
|
||||
- Autonomous or background command execution
|
||||
- Full phase-memory graph / Profile 2 synthesis
|
||||
- Voice / phone bridge (see wiki extension doc)
|
||||
- Team-shared or hosted session state
|
||||
- Deep semantic repo indexing or embeddings
|
||||
- CI / PyPI automation
|
||||
- Replacing State Hub MCP or custodian workflows
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **CYA-WP-0008** (llm-connect adapter): required for *conversational quality* in the REPL;
|
||||
CYA-WP-0007 MVP (T02–T05) can ship with `FakeLLMAdapter` for scaffolding and
|
||||
history/hub/explain paths.
|
||||
- **llm-connect** repo: stable client behind `LLMAdapter` Protocol.
|
||||
- **State Hub API** at `http://127.0.0.1:8000` (or tunnel) for hub orientation tasks.
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### T01 — Design doc and SCOPE alignment
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b91c7b42-5ad9-4dcd-b237-63394a0f2f52"
|
||||
```
|
||||
|
||||
Produce `docs/cya-interactive-shell-session-design.md` covering:
|
||||
|
||||
- REPL UX (`cya shell`), slash commands, session file layout
|
||||
- Shell history opt-in model (config + flag), caps, redaction rules, provenance
|
||||
- State Hub read/write boundaries and slash commands
|
||||
- Episodic session log schema (`kind: session_turn` precursor)
|
||||
- Weakness-hint rule catalog (v1, deterministic)
|
||||
- SCOPE.md update: move REPL from out-of-scope to owned capability
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Design doc reviewed against INTENT, SCOPE, MemoryVision, repo-boundary.
|
||||
- Explicit checklist gates T02–T08.
|
||||
|
||||
### T02 — REPL skeleton and session state
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6f2361af-312b-4f30-bf2d-17c0ee387d29"
|
||||
```
|
||||
|
||||
Implement `cya shell` Typer subcommand:
|
||||
|
||||
- Readline or `prompt_toolkit` loop with prompt `cya> `
|
||||
- Session id + user-owned artifact at `~/.config/cya/sessions/<id>.jsonl`
|
||||
- In-session turn buffer (user + assistant text, timestamps)
|
||||
- Slash commands: `/exit`, `/help`, `/explain` (last turn context)
|
||||
- Graceful handling of EOF, Ctrl-C, non-TTY
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `cya shell` starts, accepts input, persists turns to jsonl, exits cleanly.
|
||||
- No LLM required for loop mechanics (may echo or use FakeLLMAdapter).
|
||||
|
||||
### T03 — Opt-in shell history collector
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0bce247e-d829-46eb-a8b0-63848bf9dfd5"
|
||||
```
|
||||
|
||||
Extend context collection (new module or `collector` extension):
|
||||
|
||||
- Opt-in via `cya shell --with-history` and/or `[shell_history]` in config TOML
|
||||
- Read last N lines from `$HISTFILE` or shell-appropriate fallback (`fc -l`)
|
||||
- Hard cap (default 50 lines), redact secrets (API keys, tokens, passwords)
|
||||
- Provenance per line in context envelope; visible via `/explain`
|
||||
- Default **off** — no history without explicit enable
|
||||
|
||||
**Acceptance criteria:**
|
||||
- History appears in explain output when enabled; absent when disabled.
|
||||
- Redaction tested with fixture lines containing fake secrets.
|
||||
- Collector core invariants preserved for one-shot `cya "..."` (history off by default).
|
||||
|
||||
### T04 — State Hub session orientation
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "dc468c7e-92a8-48fb-bb10-06cd5da72e4d"
|
||||
```
|
||||
|
||||
At session start, load orientation (graceful if hub offline):
|
||||
|
||||
- `.custodian-brief.md` when present
|
||||
- Active workstreams for topic `64418556-3206-457a-ba29-6884b5b12cf3`
|
||||
- Unread inbox for `to_agent=can-you-assist`
|
||||
|
||||
Slash commands:
|
||||
|
||||
- `/hub` — show active workstreams + open tasks for current repo
|
||||
- `/hub log "summary"` — POST progress event (operator confirms)
|
||||
- `/inbox` — show unread messages (mark read only after explicit action)
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Session banner shows brief summary when hub reachable.
|
||||
- Hub offline degrades gracefully; shell still usable.
|
||||
- No automatic hub writes without confirmation.
|
||||
|
||||
### T05 — Wire REPL through orchestrator and safety
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5e2f2775-56b2-4f23-a2a9-66c6b26dde16"
|
||||
```
|
||||
|
||||
Connect each REPL turn to existing pipeline:
|
||||
|
||||
- `collect()` + optional history + memory recall + `classify()` + adapter + render
|
||||
- Mandatory confirmation for non-safe levels (same as one-shot)
|
||||
- Memory surfacing (Profile 1 reflections) in multi-turn output
|
||||
- `/explain` shows full envelope for last turn
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Destructive intent in REPL still requires confirmation; memory cannot bypass.
|
||||
- Turn output consistent with one-shot `cya "..."` for same input.
|
||||
|
||||
### T06 — End-of-session learning and export
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7ad8ae85-5ba8-40e3-9c9a-facf918f2b16"
|
||||
```
|
||||
|
||||
On `/exit` or session end:
|
||||
|
||||
- Offer inline Profile 1 lesson capture (reuse `memory/reflections.py` helpers)
|
||||
- Optional `cya retrospect`-style mini-flow without leaving shell history
|
||||
- `/export-session` — write redacted session summary JSON for knowledge transfer
|
||||
- Store episodic turns with `kind: session_turn` (new constant) when user confirms
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Session export is user-triggered and inspectable.
|
||||
- Reflections from shell session appear in `cya memory reflections`.
|
||||
- Skip path leaves no orphan records.
|
||||
|
||||
### T07 — Weakness hints (rule-based v1)
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T07
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "fbdd4f04-76ef-4a18-8c40-b41a134a743b"
|
||||
```
|
||||
|
||||
Deterministic post-turn or end-session hints:
|
||||
|
||||
- Credential anti-patterns in discussed commands (paste keys, `warden route` bypass)
|
||||
- Repeated destructive suggestions without confirmation
|
||||
- State Hub alignment warnings (active work not matching stated goal)
|
||||
|
||||
Surface as informational panels — not auto-fixes. Optional save as reflection.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- At least three hint rules with tests.
|
||||
- Hints never change risk level or skip confirmation.
|
||||
|
||||
### T08 — Tests, docs, and operator guide
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T08
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3bed6582-a11e-462a-843a-271c842b0103"
|
||||
```
|
||||
|
||||
- `tests/test_shell_session.py` — REPL loop, history opt-in, hub degrade, safety
|
||||
- README section: starting a shell session, history opt-in, hub commands
|
||||
- AGENTS.md command reference update
|
||||
- Example operator session in docs
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `make test` passes.
|
||||
- README documents `cya shell` with example transcript.
|
||||
|
||||
### T09 — Register, sync, and handoff
|
||||
|
||||
```task
|
||||
id: CYA-WP-0007-T09
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "3c932e69-ee0a-4135-be11-f890da09509d"
|
||||
```
|
||||
|
||||
Run `make fix-consistency REPO=can-you-assist`, log progress, move to `active` when
|
||||
implementation begins.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
When complete:
|
||||
|
||||
- Operators can run `cya shell`, pick up recent shell context (opt-in), see hub work,
|
||||
get multi-turn assistance with full safety, and export learnings for transfer.
|
||||
- All artifacts remain user-controlled and explainable.
|
||||
- SCOPE reflects interactive session as an owned capability.
|
||||
|
||||
---
|
||||
|
||||
**Status note:** Promoted to `ready` on 2026-06-22 after operator approval of direction.
|
||||
Pair with CYA-WP-0008 for production LLM quality in the REPL.
|
||||
|
||||
## Completion Note - 2026-06-23
|
||||
|
||||
Implemented in this repo by Codex. Delivered `cya shell` with local JSONL
|
||||
session artifacts, optional capped/redacted shell history, State Hub orientation
|
||||
and explicit hub slash-command writes, `/explain`, `/export-session`, end-session
|
||||
Profile 1 learning hooks, opt-in `session_turn` memory persistence, deterministic
|
||||
weakness hints, docs, and tests.
|
||||
|
||||
Verification:
|
||||
|
||||
```bash
|
||||
make test
|
||||
python3 -m cya.cli.main --offline hello
|
||||
python3 -m cya.cli.main shell --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Operator follow-up: after this workplan file change, run from `~/state-hub`:
|
||||
|
||||
```bash
|
||||
make fix-consistency REPO=can-you-assist
|
||||
```
|
||||
179
workplans/CYA-WP-0008-llm-connect-adapter-integration.md
Normal file
179
workplans/CYA-WP-0008-llm-connect-adapter-integration.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
id: CYA-WP-0008
|
||||
type: workplan
|
||||
title: "llm-connect Adapter Integration for Production Assistance"
|
||||
domain: agents
|
||||
repo: can-you-assist
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: foerster-capabilities
|
||||
created: "2026-06-22"
|
||||
updated: "2026-06-22"
|
||||
state_hub_workstream_id: "f713db12-5b90-4453-8fbb-a0e50f61699b"
|
||||
---
|
||||
|
||||
# CYA-WP-0008: llm-connect Adapter Integration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace `FakeLLMAdapter` on production code paths with a real **llm-connect** client
|
||||
behind the existing `LLMAdapter` Protocol, so one-shot `cya "..."` and `cya shell`
|
||||
(CYA-WP-0007) can deliver genuinely useful multi-turn assistance.
|
||||
|
||||
Preserve the seam: `cya` never hard-codes a vendor; all provider config lives in
|
||||
llm-connect. Credential routing via `warden route` before requesting secrets.
|
||||
|
||||
## Background & References
|
||||
|
||||
- **Seam:** `src/cya/llm/adapter.py` — `LLMAdapter` Protocol + `FakeLLMAdapter`
|
||||
- **INTENT.md:** `cya` asks; `llm-connect` reaches infrastructure
|
||||
- **SCOPE.md:** real client listed as explicit out-of-scope / future work
|
||||
- **CYA-WP-0007:** REPL usable with fake adapter for scaffolding; quality needs this slice
|
||||
- **Credential routing:** `AGENTS.md` / `warden route find` — no secrets in repo
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Provider-specific UI inside `cya` (model picker beyond minimal config)
|
||||
- Token billing dashboards or cost analytics
|
||||
- Embedding / RAG pipelines
|
||||
- Bypassing `LLMAdapter` Protocol for any production path
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **llm-connect** package/API stability (coordinate with that repo owner)
|
||||
- Operator-provided credentials via OpenBao paths surfaced by `warden route`
|
||||
- CYA-WP-0007 T05 can proceed in parallel using `FakeLLMAdapter`
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### T01 — Adapter contract review and llm-connect API survey
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "483d13bb-aabe-48ad-96c2-8df83de5f442"
|
||||
```
|
||||
|
||||
Document mapping from `AssistanceRequest` / `AssistanceResponse` to llm-connect calls.
|
||||
Identify config surface (TOML keys, env vars). Note gaps requiring llm-connect changes.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Short integration note in `docs/` or workplan appendix.
|
||||
- Credential route catalog id(s) documented via `warden route find`.
|
||||
|
||||
**Delivered:** `docs/llm-connect-integration.md`
|
||||
|
||||
### T02 — Implement `LLMConnectAdapter`
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0fc17ad5-d90b-4ad1-b060-a1a2f9c25ea8"
|
||||
```
|
||||
|
||||
New class in `src/cya/llm/` implementing `LLMAdapter`:
|
||||
|
||||
- Delegates to llm-connect client
|
||||
- Graceful degrade message when llm-connect unavailable / misconfigured
|
||||
- `FakeLLMAdapter` remains for tests and `--offline` mode
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Protocol-compliant; swap via config or env (`CYA_LLM_ADAPTER=connect|fake`).
|
||||
|
||||
**Delivered:** `src/cya/llm/connect_adapter.py`, `src/cya/llm/factory.py`
|
||||
|
||||
### T03 — Configuration and developer ergonomics
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T03
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e8470a37-ecec-42f1-920b-ccd8b98b5512"
|
||||
```
|
||||
|
||||
- `~/.config/cya/config.toml` `[llm]` section (backend, model hints)
|
||||
- Document `warden route` steps in README
|
||||
- `make dev-install` optional extra `[llm]` dependency group if needed
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Operator can configure adapter without editing source.
|
||||
- No secrets committed; example config uses placeholders.
|
||||
|
||||
**Delivered:** `src/cya/config.py`, `docs/cya-config.example.toml`, README section
|
||||
|
||||
### T04 — Orchestrator and shell integration
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f2781963-fecf-4576-96de-bd745df271a0"
|
||||
```
|
||||
|
||||
Wire `handle_request()` and CYA-WP-0007 shell turns to adapter selection:
|
||||
|
||||
- Default fake when unconfigured (current behavior)
|
||||
- Real adapter when config present
|
||||
- Multi-turn context: pass recent session turns in `AssistanceRequest.context`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- One-shot and shell paths use same adapter factory.
|
||||
- Session context bounded (token/line budget documented).
|
||||
|
||||
**Delivered:** `get_adapter()` wired in orchestrator; `session_turns` + `bound_session_turns()` ready for CYA-WP-0007 shell
|
||||
|
||||
### T05 — Tests and offline CI strategy
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "32de980b-1a24-4159-9550-7c516570cae3"
|
||||
```
|
||||
|
||||
- Mock llm-connect for unit tests (no live API in default `make test`)
|
||||
- Optional integration test marker `@pytest.mark.llm_live` for manual runs
|
||||
- Fake adapter remains default in CI
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `make test` passes without network or API keys.
|
||||
- Live test documented for operator manual verification.
|
||||
|
||||
**Delivered:** `tests/test_llm_factory.py`, `tests/test_llm_connect_adapter.py`, `tests/test_llm_prompt.py`
|
||||
|
||||
### T06 — Documentation and SCOPE update
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "2d152d4b-e4b2-4a94-8f85-d8f033e55d5f"
|
||||
```
|
||||
|
||||
Update README, SCOPE.md (remove "only FakeLLMAdapter" where accurate), AGENTS.md.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Clear "configured vs offline" operator paths documented.
|
||||
|
||||
### T07 — Register, sync, and handoff
|
||||
|
||||
```task
|
||||
id: CYA-WP-0008-T07
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "2fb42517-b2df-43d3-8195-f02d310107dc"
|
||||
```
|
||||
|
||||
`make fix-consistency`, progress event, coordinate with llm-connect repo if API gaps found.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Configured `cya` uses llm-connect for real inference.
|
||||
- Unconfigured / test environments behave exactly as today (fake adapter).
|
||||
- CYA-WP-0007 shell session becomes practically useful once both workplans complete.
|
||||
|
||||
---
|
||||
|
||||
**Status note:** `finished` on 2026-06-22. Integration doc: `docs/llm-connect-integration.md`.
|
||||
Reference in New Issue
Block a user