"""Assistance orchestrator. The piece that turns raw user intent + collected context into a well-formed request for the LLM adapter, then turns the adapter response into the final terminal output the user sees. Key responsibilities: - Coordinate context collector, memory (Profile 0 via recall with activation_context), rule-based risk classifier, and LLMAdapter. - Wire memory influence into every assistance cycle and `--explain-context`. - Own the `cya retrospect` flow that feeds higher-order memory back into the system. - Keep the CLI surface thin. **Memory note (Profile 0):** This module is the primary consumer of the stable memory ports defined in src/cya/memory/__init__.py. All calls use activation_context derived from the current working directory + git root. See MemoryVision.md "Profile 0 Baseline" and CYA-WP-0005 for the evolution path to Profiles 1–3. See workplan CYA-WP-0001 (core) + CYA-WP-0003 (memory wiring + retrospect) + CYA-WP-0005 (profile formalization). """ from __future__ import annotations from pathlib import Path import typer from rich.console import Console from rich.panel import Panel from cya.context.collector import collect, render_explanation from cya.memory import ( recall_preferences, remember_retrospection_outcome, 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.config import bound_session_turns from cya.llm.adapter import AssistanceRequest from cya.llm.factory import get_adapter console = Console() def handle_request( user_request: str, *, explain_context: bool = False, dry_run: bool = False, offline: bool = False, session_turns: list[dict[str, str]] | None = None, ) -> None: """Primary orchestrator entry point. This is what the CLI (and future tests / other front-ends) should call. It coordinates the full current flow: context → safety (with mandatory confirmation) → LLMAdapter → render """ # 1. Context (always cheap; needed for safety "affected" and for the adapter) try: envelope = collect(".") except Exception: envelope = None if explain_context and envelope: try: explanation = render_explanation(envelope) console.print( Panel( explanation, title="Context Envelope (T02)", border_style="green", padding=(1, 1), ) ) except Exception as exc: console.print(f"[red]Context explanation error: {exc}[/red]") # T03 (memory wiring): consult after context (so safety can see it in future T04 0002), # before risk/LLM. Real T02 prefs now available; graceful. # T03 (0003): pass activation_context so directory/project-bound memory is automatically # activated based on cwd + git root. memory = {} try: act_ctx = {"cwd": str(Path.cwd())} if envelope and getattr(envelope, "git", None): git_info = envelope.git or {} if git_info.get("workdir"): act_ctx["git_root"] = git_info["workdir"] memory = recall_preferences( ".", activation_context=act_ctx, kinds=[KIND_REFLECTION, KIND_RETROSPECTION, KIND_INTERACTION_GOAL, "preference"], ) except Exception: memory = {"error": "recall failed (graceful degradation)"} if explain_context and memory.get("items"): try: prov = memory.get("provenance", [{}])[0] 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( body, title="Memory Activated (T03)", border_style="blue", padding=(0, 1), ) ) except Exception: pass # 2. Risk classification + mandatory confirmation (T03 safety; T04 memory signals) assessment = classify(user_request, envelope, memory=memory) if assessment.requires_confirmation: from rich.table import Table table = Table( title=f"Risk Assessment — {assessment.level.value.upper()}", show_header=False, border_style="red", ) table.add_row("Rationale", assessment.rationale) if assessment.preview: table.add_row("Preview", assessment.preview) if assessment.affected_summary: table.add_row("Would affect", assessment.affected_summary) table.add_row("Rules", ", ".join(assessment.rules_triggered[:3])) console.print(table) if not get_user_confirmation(assessment): console.print("[yellow]Action cancelled by user. No changes made.[/yellow]") return if dry_run: console.print("[green]--dry-run acknowledged.[/green] No side-effects.") return # 3. Call through the single LLMAdapter boundary (T04 / CYA-WP-0008) adapter = get_adapter(offline=offline) ctx = (envelope.to_dict() if envelope else {}) or {} ctx["memory"] = memory # T03: memory now in context passed to LLM (for personalization + explain) if session_turns: ctx["session_turns"] = bound_session_turns(session_turns) llm_request = AssistanceRequest( user_request=user_request, context=ctx, ) llm_response = adapter.complete(llm_request) # 4. Render final user-facing artifact (T06 responsibility; T03 memory surface) mem_line = "" if memory.get("items"): mem_line = f"\n[dim]Memory activated: {len(memory.get('items', []))} items (phase {memory.get('phase')})[/dim]" refl_line = format_reflection_surfacing(memory, for_explain=False) if refl_line: mem_line += f"\n[cyan]{refl_line}[/cyan]" console.print( Panel( f"[bold]Suggestion:[/bold]\n{llm_response.suggestion}\n\n" f"[dim]{llm_response.explanation}\n" f"Rationale: {llm_response.rationale}{mem_line}[/dim]", title="LLM Response (via T04 seam)", border_style="magenta", padding=(1, 1), ) ) console.print( "[green]✓[/green] Request processed by orchestrator (T02+T03+T04 coordinated by T06)." ) def run_retrospection(scope: str = ".", limit: int = 8) -> None: """Guided retrospection session (T04 of CYA-WP-0003). Helps the user review recent memory usage in the given scope, reflect, and record new interaction goals or preferences. These are stored using the retrospection-aware memory helper. """ console.print( Panel( "[bold cyan]Retrospection Session[/bold cyan]\n\n" f"Scope: [green]{scope}[/green]\n" "We will look at recent memory items and help you reflect.\n" "Your answers will be stored as durable retrospection memory.", title="cya retrospect", border_style="magenta", padding=(1, 2), ) ) # Recall recent items, with bias toward retrospection kinds if present try: recent = recall_preferences( scope, limit=limit, kinds=[KIND_RETROSPECTION, KIND_INTERACTION_GOAL, "preference"], ) items = recent.get("items", []) except Exception as e: console.print(f"[red]Could not load memory: {e}[/red]") return if not items: console.print( "[yellow]No memory items found in this scope yet.[/yellow]\n" "You can create some with normal usage or explicit remembers." ) return console.print( Panel( "\n".join( f"• [bold]{item.get('key')}[/bold]: {item.get('value')}" for item in items[:5] ), title=f"Recent Memory in {scope} (showing up to 5)", border_style="blue", ) ) # Simple guided reflection console.print("\n[bold]Reflection time[/bold]") what_worked = typer.prompt( "What worked well in recent assistance? (short answer or 'skip')", default="", show_default=False, ) if what_worked and what_worked.lower() not in ("skip", "s", ""): remember_retrospection_outcome( "what_worked", what_worked, scope=scope ) console.print("[green]Recorded.[/green]") what_to_change = typer.prompt( "What should change in future interactions? (e.g. 'be more concise', 'always show alternatives')", default="", show_default=False, ) if what_to_change and what_to_change.lower() not in ("skip", "s", ""): remember_retrospection_outcome( "interaction_goal", what_to_change, scope=scope ) console.print("[green]Recorded as interaction goal.[/green]") safety_note = typer.prompt( "Any standing safety or preference rules for this project? (optional)", default="", show_default=False, ) if safety_note and safety_note.lower() not in ("skip", "s", ""): remember_retrospection_outcome( "safety_preference", safety_note, scope=scope ) console.print("[green]Recorded as safety preference.[/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", border_style="green", padding=(1, 2), ) ) 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__ = ["handle_request", "run_retrospection"]