cgcardona / muse public
context.py python
189 lines 5.6 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse context [<commit>] — output structured musical context for AI agent consumption.
2
3 Produces a self-contained JSON (or YAML) document describing the full musical
4 state of the project at a given commit (or HEAD). This is the primary entry
5 point for AI agents that need to generate music coherently with an existing
6 composition — agents run ``muse context --format json`` before generation to
7 understand the current key, tempo, active tracks, form, and evolutionary history.
8
9 Output modes
10 ------------
11 JSON (default)::
12
13 muse context
14 muse context abc1234
15 muse context --depth 10 --sections --tracks
16
17 YAML::
18
19 muse context --format yaml
20
21 Flags
22 -----
23 [<commit>] Optional commit ID (default: HEAD).
24 --depth N Include N ancestor commits in the ``history`` array (default: 5).
25 --sections Expand section-level detail in ``musical_state.sections``.
26 --tracks Add per-track harmonic/dynamic breakdowns.
27 --include-history Annotate history entries with dimensional deltas (reserved for
28 future Storpheus integration).
29 --format json|yaml Output format (default: json).
30 """
31 from __future__ import annotations
32
33 import asyncio
34 import json
35 import logging
36 import pathlib
37 from enum import Enum
38 from typing import Optional
39
40 import typer
41 from sqlalchemy.ext.asyncio import AsyncSession
42
43 from maestro.muse_cli._repo import require_repo
44 from maestro.muse_cli.db import open_session
45 from maestro.muse_cli.errors import ExitCode
46 from maestro.services.muse_context import build_muse_context
47
48 logger = logging.getLogger(__name__)
49
50 app = typer.Typer()
51
52
53 class OutputFormat(str, Enum):
54 """Supported output serialisation formats."""
55
56 json = "json"
57 yaml = "yaml"
58
59
60 # ---------------------------------------------------------------------------
61 # Testable async core
62 # ---------------------------------------------------------------------------
63
64
65 async def _context_async(
66 *,
67 root: "pathlib.Path",
68 session: AsyncSession,
69 commit_id: Optional[str],
70 depth: int,
71 sections: bool,
72 tracks: bool,
73 include_history: bool,
74 fmt: OutputFormat,
75 ) -> None:
76 """Core context logic — fully injectable for tests.
77
78 Delegates to ``build_muse_context()`` and serialises the result to the
79 requested format via ``typer.echo``.
80
81 Args:
82 root: Repository root path.
83 session: Open async DB session.
84 commit_id: Target commit (None = HEAD).
85 depth: Ancestor history depth.
86 sections: Whether to expand section detail.
87 tracks: Whether to include per-track breakdown.
88 include_history: Whether to annotate history with dimension deltas.
89 fmt: Output format (json or yaml).
90 """
91 result = await build_muse_context(
92 session,
93 root=root,
94 commit_id=commit_id,
95 depth=depth,
96 include_sections=sections,
97 include_tracks=tracks,
98 include_history=include_history,
99 )
100
101 data = result.to_dict()
102
103 if fmt == OutputFormat.yaml:
104 try:
105 import yaml # PyYAML ships no py.typed marker
106
107 typer.echo(yaml.dump(data, sort_keys=False, allow_unicode=True))
108 except ImportError:
109 typer.echo(
110 "❌ PyYAML is not installed. Install it with: pip install pyyaml",
111 err=True,
112 )
113 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
114 else:
115 typer.echo(json.dumps(data, indent=2, default=str))
116
117
118 # ---------------------------------------------------------------------------
119 # Typer command
120 # ---------------------------------------------------------------------------
121
122
123 @app.callback(invoke_without_command=True)
124 def context(
125 ctx: typer.Context,
126 commit: Optional[str] = typer.Argument(
127 None,
128 help="Commit ID to inspect (default: HEAD).",
129 metavar="<commit>",
130 ),
131 depth: int = typer.Option(
132 5,
133 "--depth",
134 help="Number of ancestor commits to include in history.",
135 min=0,
136 ),
137 sections: bool = typer.Option(
138 False,
139 "--sections",
140 help="Expand section-level detail in musical_state.sections.",
141 ),
142 tracks: bool = typer.Option(
143 False,
144 "--tracks",
145 help="Add per-track harmonic and dynamic breakdowns.",
146 ),
147 include_history: bool = typer.Option(
148 False,
149 "--include-history",
150 help="Annotate history entries with dimensional deltas (future Storpheus integration).",
151 ),
152 fmt: OutputFormat = typer.Option(
153 OutputFormat.json,
154 "--format",
155 help="Output format: json (default) or yaml.",
156 ),
157 ) -> None:
158 """Output structured musical context for AI agent consumption.
159
160 Produces a self-contained document describing the musical state at the
161 given commit (or HEAD). Pipe it to the LLM before generating new music
162 to ensure harmonic, rhythmic, and structural coherence.
163 """
164 root = require_repo()
165
166 async def _run() -> None:
167 async with open_session() as session:
168 await _context_async(
169 root=root,
170 session=session,
171 commit_id=commit,
172 depth=depth,
173 sections=sections,
174 tracks=tracks,
175 include_history=include_history,
176 fmt=fmt,
177 )
178
179 try:
180 asyncio.run(_run())
181 except typer.Exit:
182 raise
183 except (ValueError, RuntimeError) as exc:
184 typer.echo(f"❌ muse context: {exc}", err=True)
185 raise typer.Exit(code=ExitCode.USER_ERROR)
186 except Exception as exc:
187 typer.echo(f"❌ muse context failed: {exc}", err=True)
188 logger.error("❌ muse context error: %s", exc, exc_info=True)
189 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)