muse_context.py
python
| 1 | """Muse Context service — structured musical state document for AI agent consumption. |
| 2 | |
| 3 | This is the primary read-side interface between Muse VCS and AI music generation |
| 4 | agents. ``build_muse_context()`` traverses the commit graph to produce a |
| 5 | self-contained ``MuseContextResult`` describing the current musical state of a |
| 6 | repository at a given commit (or HEAD). |
| 7 | |
| 8 | When Maestro receives a "generate a new section" request, it calls this service |
| 9 | to obtain the full musical context, passes it to the LLM, and the LLM generates |
| 10 | music that is harmonically, rhythmically, and structurally coherent with the |
| 11 | existing composition. |
| 12 | |
| 13 | Design notes |
| 14 | ------------ |
| 15 | - **Read-only**: this service never writes to the DB. |
| 16 | - **Deterministic**: for the same commit_id, the output is always identical. |
| 17 | - **Active tracks**: derived from file paths in the snapshot manifest. |
| 18 | - **Musical dimensions** (key, tempo, form, harmony): currently None — these |
| 19 | require MIDI analysis from the Storpheus service and are not yet integrated. |
| 20 | The schema is fully defined so agents can handle None gracefully today and |
| 21 | receive populated values once Storpheus integration lands. |
| 22 | """ |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | import json |
| 26 | import logging |
| 27 | import pathlib |
| 28 | from dataclasses import asdict, dataclass, field |
| 29 | from typing import Optional |
| 30 | |
| 31 | from sqlalchemy.ext.asyncio import AsyncSession |
| 32 | |
| 33 | from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot |
| 34 | |
| 35 | logger = logging.getLogger(__name__) |
| 36 | |
| 37 | # --------------------------------------------------------------------------- |
| 38 | # Result types — every public function signature is a contract |
| 39 | # --------------------------------------------------------------------------- |
| 40 | |
| 41 | |
| 42 | @dataclass(frozen=True) |
| 43 | class MuseHeadCommitInfo: |
| 44 | """Metadata for the commit that the context document was built from.""" |
| 45 | |
| 46 | commit_id: str |
| 47 | message: str |
| 48 | author: str |
| 49 | committed_at: str # ISO-8601 UTC |
| 50 | |
| 51 | |
| 52 | @dataclass(frozen=True) |
| 53 | class MuseSectionDetail: |
| 54 | """Per-section musical detail surfaced when ``--sections`` is requested. |
| 55 | |
| 56 | ``bars`` is None until MIDI region analysis is integrated. |
| 57 | """ |
| 58 | |
| 59 | tracks: list[str] |
| 60 | bars: Optional[int] = None |
| 61 | |
| 62 | |
| 63 | @dataclass(frozen=True) |
| 64 | class MuseHarmonicProfile: |
| 65 | """Harmonic summary — None fields require Storpheus MIDI analysis.""" |
| 66 | |
| 67 | chord_progression: Optional[list[str]] = None |
| 68 | tension_score: Optional[float] = None |
| 69 | harmonic_rhythm: Optional[float] = None |
| 70 | |
| 71 | |
| 72 | @dataclass(frozen=True) |
| 73 | class MuseDynamicProfile: |
| 74 | """Dynamic (volume/intensity) summary — None fields require MIDI analysis.""" |
| 75 | |
| 76 | avg_velocity: Optional[int] = None |
| 77 | dynamic_arc: Optional[str] = None |
| 78 | peak_section: Optional[str] = None |
| 79 | |
| 80 | |
| 81 | @dataclass(frozen=True) |
| 82 | class MuseMelodicProfile: |
| 83 | """Melodic contour summary — None fields require MIDI analysis.""" |
| 84 | |
| 85 | contour: Optional[str] = None |
| 86 | range_semitones: Optional[int] = None |
| 87 | motifs_detected: Optional[int] = None |
| 88 | |
| 89 | |
| 90 | @dataclass(frozen=True) |
| 91 | class MuseTrackDetail: |
| 92 | """Per-track harmonic and dynamic breakdown (``--tracks`` flag).""" |
| 93 | |
| 94 | track_name: str |
| 95 | harmonic: MuseHarmonicProfile = field(default_factory=MuseHarmonicProfile) |
| 96 | dynamic: MuseDynamicProfile = field(default_factory=MuseDynamicProfile) |
| 97 | |
| 98 | |
| 99 | @dataclass(frozen=True) |
| 100 | class MuseMusicalState: |
| 101 | """Full musical state of the project at a given commit. |
| 102 | |
| 103 | ``active_tracks`` is populated from the snapshot manifest file names. |
| 104 | All other fields are None until Storpheus MIDI analysis is integrated. |
| 105 | Agents should treat None values as unknown and generate accordingly. |
| 106 | """ |
| 107 | |
| 108 | active_tracks: list[str] |
| 109 | key: Optional[str] = None |
| 110 | mode: Optional[str] = None |
| 111 | tempo_bpm: Optional[int] = None |
| 112 | time_signature: Optional[str] = None |
| 113 | swing_factor: Optional[float] = None |
| 114 | form: Optional[str] = None |
| 115 | emotion: Optional[str] = None |
| 116 | sections: Optional[dict[str, MuseSectionDetail]] = None |
| 117 | tracks: Optional[list[MuseTrackDetail]] = None |
| 118 | harmonic_profile: Optional[MuseHarmonicProfile] = None |
| 119 | dynamic_profile: Optional[MuseDynamicProfile] = None |
| 120 | melodic_profile: Optional[MuseMelodicProfile] = None |
| 121 | |
| 122 | |
| 123 | @dataclass(frozen=True) |
| 124 | class MuseHistoryEntry: |
| 125 | """A single ancestor commit in the evolutionary history of the composition. |
| 126 | |
| 127 | Produced for each of the N most-recent ancestors when ``--depth`` > 0. |
| 128 | """ |
| 129 | |
| 130 | commit_id: str |
| 131 | message: str |
| 132 | author: str |
| 133 | committed_at: str # ISO-8601 UTC |
| 134 | active_tracks: list[str] |
| 135 | key: Optional[str] = None |
| 136 | tempo_bpm: Optional[int] = None |
| 137 | emotion: Optional[str] = None |
| 138 | |
| 139 | |
| 140 | @dataclass(frozen=True) |
| 141 | class MuseContextResult: |
| 142 | """Complete musical context document for AI agent consumption. |
| 143 | |
| 144 | Returned by ``build_muse_context()``. Self-contained: an agent receiving |
| 145 | only this document has everything it needs to generate structurally and |
| 146 | stylistically coherent music. |
| 147 | |
| 148 | Use ``to_dict()`` before serialising to JSON or YAML. |
| 149 | """ |
| 150 | |
| 151 | repo_id: str |
| 152 | current_branch: str |
| 153 | head_commit: MuseHeadCommitInfo |
| 154 | musical_state: MuseMusicalState |
| 155 | history: list[MuseHistoryEntry] |
| 156 | missing_elements: list[str] |
| 157 | suggestions: dict[str, str] |
| 158 | |
| 159 | def to_dict(self) -> dict[str, object]: |
| 160 | """Recursively convert to a plain dict suitable for json.dumps / yaml.dump.""" |
| 161 | raw: dict[str, object] = asdict(self) |
| 162 | return raw |
| 163 | |
| 164 | |
| 165 | # --------------------------------------------------------------------------- |
| 166 | # Internal helpers |
| 167 | # --------------------------------------------------------------------------- |
| 168 | |
| 169 | _MUSIC_FILE_EXTENSIONS = frozenset( |
| 170 | {".mid", ".midi", ".mp3", ".wav", ".aiff", ".aif", ".flac"} |
| 171 | ) |
| 172 | |
| 173 | |
| 174 | def _extract_track_names(manifest: dict[str, str]) -> list[str]: |
| 175 | """Derive human-readable track names from snapshot manifest file paths. |
| 176 | |
| 177 | Files with recognised music extensions whose stems do not look like raw |
| 178 | SHA-256 hashes are treated as track names. The stem is lowercased and |
| 179 | de-duplicated. |
| 180 | |
| 181 | Example: |
| 182 | ``{"drums.mid": "abc123", "bass.mid": "def456"}`` → ``["bass", "drums"]`` |
| 183 | """ |
| 184 | tracks: list[str] = [] |
| 185 | for path_str in manifest: |
| 186 | p = pathlib.PurePosixPath(path_str) |
| 187 | if p.suffix.lower() in _MUSIC_FILE_EXTENSIONS: |
| 188 | stem = p.stem.lower() |
| 189 | # Skip stems that look like raw SHA-256 hashes (64 hex chars) |
| 190 | if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem): |
| 191 | continue |
| 192 | tracks.append(stem) |
| 193 | return sorted(set(tracks)) |
| 194 | |
| 195 | |
| 196 | async def _load_commit(session: AsyncSession, commit_id: str) -> MuseCliCommit | None: |
| 197 | """Fetch a single MuseCliCommit by primary key.""" |
| 198 | return await session.get(MuseCliCommit, commit_id) |
| 199 | |
| 200 | |
| 201 | async def _load_snapshot( |
| 202 | session: AsyncSession, snapshot_id: str |
| 203 | ) -> MuseCliSnapshot | None: |
| 204 | """Fetch a single MuseCliSnapshot by primary key.""" |
| 205 | return await session.get(MuseCliSnapshot, snapshot_id) |
| 206 | |
| 207 | |
| 208 | def _read_repo_meta(root: pathlib.Path) -> tuple[str, str]: |
| 209 | """Return (repo_id, current_branch) from .muse metadata files. |
| 210 | |
| 211 | Reads ``.muse/repo.json`` and ``.muse/HEAD`` synchronously — these are |
| 212 | tiny JSON/text files that do not warrant async I/O. |
| 213 | """ |
| 214 | muse_dir = root / ".muse" |
| 215 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 216 | repo_id = repo_data["repo_id"] |
| 217 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 218 | branch = head_ref.rsplit("/", 1)[-1] if "/" in head_ref else head_ref |
| 219 | return repo_id, branch |
| 220 | |
| 221 | |
| 222 | def _read_head_commit_id(root: pathlib.Path) -> str | None: |
| 223 | """Return the HEAD commit ID from the .muse filesystem layout, or None.""" |
| 224 | muse_dir = root / ".muse" |
| 225 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 226 | ref_path = muse_dir / pathlib.Path(head_ref) |
| 227 | if not ref_path.exists(): |
| 228 | return None |
| 229 | cid = ref_path.read_text().strip() |
| 230 | return cid or None |
| 231 | |
| 232 | |
| 233 | async def _build_history( |
| 234 | session: AsyncSession, |
| 235 | start_commit: MuseCliCommit, |
| 236 | depth: int, |
| 237 | ) -> list[MuseHistoryEntry]: |
| 238 | """Walk the parent chain, returning up to *depth* ancestor entries. |
| 239 | |
| 240 | The *start_commit* (HEAD) is NOT included — it is surfaced separately as |
| 241 | ``head_commit`` in the result. Entries are returned newest-first. |
| 242 | """ |
| 243 | entries: list[MuseHistoryEntry] = [] |
| 244 | current_id: str | None = start_commit.parent_commit_id |
| 245 | |
| 246 | while current_id and len(entries) < depth: |
| 247 | commit = await _load_commit(session, current_id) |
| 248 | if commit is None: |
| 249 | logger.warning("⚠️ History chain broken at %s", current_id[:8]) |
| 250 | break |
| 251 | |
| 252 | tracks: list[str] = [] |
| 253 | snapshot = await _load_snapshot(session, commit.snapshot_id) |
| 254 | if snapshot is not None and snapshot.manifest: |
| 255 | tracks = _extract_track_names(snapshot.manifest) |
| 256 | |
| 257 | entries.append( |
| 258 | MuseHistoryEntry( |
| 259 | commit_id=commit.commit_id, |
| 260 | message=commit.message, |
| 261 | author=commit.author, |
| 262 | committed_at=commit.committed_at.isoformat(), |
| 263 | active_tracks=tracks, |
| 264 | ) |
| 265 | ) |
| 266 | current_id = commit.parent_commit_id |
| 267 | |
| 268 | return entries |
| 269 | |
| 270 | |
| 271 | # --------------------------------------------------------------------------- |
| 272 | # Public API |
| 273 | # --------------------------------------------------------------------------- |
| 274 | |
| 275 | |
| 276 | async def build_muse_context( |
| 277 | session: AsyncSession, |
| 278 | *, |
| 279 | root: pathlib.Path, |
| 280 | commit_id: str | None = None, |
| 281 | depth: int = 5, |
| 282 | include_sections: bool = False, |
| 283 | include_tracks: bool = False, |
| 284 | include_history: bool = False, |
| 285 | ) -> MuseContextResult: |
| 286 | """Build a complete musical context document for AI agent consumption. |
| 287 | |
| 288 | Traverses the commit graph starting from *commit_id* (or HEAD when None) |
| 289 | and returns a self-contained ``MuseContextResult``. |
| 290 | |
| 291 | The output is deterministic: for the same ``commit_id`` and flags, the |
| 292 | output is always identical, making it safe to cache and reproduce. |
| 293 | |
| 294 | Args: |
| 295 | session: Open async DB session. Read-only — no writes performed. |
| 296 | root: Repository root (the directory containing ``.muse/``). |
| 297 | commit_id: Target commit ID, or None to use HEAD. |
| 298 | depth: Number of ancestor commits to include in ``history``. |
| 299 | Pass 0 to omit history entirely. |
| 300 | include_sections: When True, expand section-level detail in |
| 301 | ``musical_state.sections``. Sections are currently |
| 302 | stubbed (one "main" section) until MIDI region |
| 303 | metadata is integrated. |
| 304 | include_tracks: When True, add per-track harmonic/dynamic detail in |
| 305 | ``musical_state.tracks``. |
| 306 | include_history: Reserved for future use — will annotate each history |
| 307 | entry with dimensional deltas once Storpheus MIDI |
| 308 | analysis is integrated. |
| 309 | |
| 310 | Returns: |
| 311 | MuseContextResult — serialise with ``.to_dict()`` before JSON/YAML output. |
| 312 | |
| 313 | Raises: |
| 314 | ValueError: If *commit_id* is provided but not found in the DB. |
| 315 | RuntimeError: If the repository has no commits yet and commit_id is None. |
| 316 | """ |
| 317 | repo_id, branch = _read_repo_meta(root) |
| 318 | |
| 319 | # Resolve the target commit |
| 320 | if commit_id is None: |
| 321 | resolved_id = _read_head_commit_id(root) |
| 322 | if not resolved_id: |
| 323 | raise RuntimeError( |
| 324 | "Repository has no commits yet. Run `muse commit` first." |
| 325 | ) |
| 326 | else: |
| 327 | resolved_id = commit_id |
| 328 | |
| 329 | head_commit_row = await _load_commit(session, resolved_id) |
| 330 | if head_commit_row is None: |
| 331 | raise ValueError(f"Commit {resolved_id!r} not found in DB.") |
| 332 | |
| 333 | # Derive active tracks from the snapshot manifest |
| 334 | snapshot = await _load_snapshot(session, head_commit_row.snapshot_id) |
| 335 | manifest: dict[str, str] = snapshot.manifest if snapshot is not None else {} |
| 336 | active_tracks = _extract_track_names(manifest) |
| 337 | |
| 338 | # Optional sections expansion |
| 339 | sections: dict[str, MuseSectionDetail] | None = None |
| 340 | if include_sections: |
| 341 | sections = {"main": MuseSectionDetail(tracks=active_tracks)} |
| 342 | |
| 343 | # Optional per-track detail |
| 344 | track_details: list[MuseTrackDetail] | None = None |
| 345 | if include_tracks and active_tracks: |
| 346 | track_details = [ |
| 347 | MuseTrackDetail( |
| 348 | track_name=t, |
| 349 | harmonic=MuseHarmonicProfile(), |
| 350 | dynamic=MuseDynamicProfile(), |
| 351 | ) |
| 352 | for t in active_tracks |
| 353 | ] |
| 354 | |
| 355 | musical_state = MuseMusicalState( |
| 356 | active_tracks=active_tracks, |
| 357 | sections=sections, |
| 358 | tracks=track_details, |
| 359 | ) |
| 360 | |
| 361 | head_commit_info = MuseHeadCommitInfo( |
| 362 | commit_id=head_commit_row.commit_id, |
| 363 | message=head_commit_row.message, |
| 364 | author=head_commit_row.author, |
| 365 | committed_at=head_commit_row.committed_at.isoformat(), |
| 366 | ) |
| 367 | |
| 368 | history = await _build_history(session, head_commit_row, depth=depth) |
| 369 | |
| 370 | logger.info( |
| 371 | "✅ Muse context built for commit %s (depth=%d, tracks=%d)", |
| 372 | resolved_id[:8], |
| 373 | depth, |
| 374 | len(active_tracks), |
| 375 | ) |
| 376 | |
| 377 | return MuseContextResult( |
| 378 | repo_id=repo_id, |
| 379 | current_branch=branch, |
| 380 | head_commit=head_commit_info, |
| 381 | musical_state=musical_state, |
| 382 | history=history, |
| 383 | missing_elements=[], |
| 384 | suggestions={}, |
| 385 | ) |