session.py
python
| 1 | """muse session — record and query recording session metadata. |
| 2 | |
| 3 | Sessions are stored as JSON files in ``.muse/sessions/`` — purely local, |
| 4 | never in Postgres. This mirrors the way git stores commit metadata as |
| 5 | plain files rather than in a database. |
| 6 | |
| 7 | Directory layout:: |
| 8 | |
| 9 | .muse/ |
| 10 | sessions/ |
| 11 | current.json ← active session (only while recording) |
| 12 | <uuid>.json ← completed sessions (one file each) |
| 13 | |
| 14 | Session JSON schema:: |
| 15 | |
| 16 | { |
| 17 | "session_id": "<uuid4>", |
| 18 | "schema_version": "1", |
| 19 | "started_at": "<ISO-8601>", |
| 20 | "ended_at": "<ISO-8601 | null>", |
| 21 | "participants": ["name", ...], |
| 22 | "location": "<string>", |
| 23 | "intent": "<string>", |
| 24 | "commits": [], |
| 25 | "notes": "<string>" |
| 26 | } |
| 27 | |
| 28 | Subcommands |
| 29 | ----------- |
| 30 | - ``start`` — open a new session (writes ``current.json``) |
| 31 | - ``end`` — finalise the active session (moves to ``<uuid>.json``) |
| 32 | - ``log`` — list all completed sessions, newest first |
| 33 | - ``show`` — print a specific session by ID (prefix match supported) |
| 34 | - ``credits`` — aggregate all participants across completed sessions |
| 35 | """ |
| 36 | from __future__ import annotations |
| 37 | |
| 38 | import datetime |
| 39 | import json |
| 40 | import logging |
| 41 | import pathlib |
| 42 | import uuid |
| 43 | from typing import Annotated, TypedDict |
| 44 | |
| 45 | import typer |
| 46 | |
| 47 | from maestro.muse_cli._repo import require_repo |
| 48 | from maestro.muse_cli.errors import ExitCode |
| 49 | |
| 50 | logger = logging.getLogger(__name__) |
| 51 | |
| 52 | app = typer.Typer(no_args_is_help=True) |
| 53 | |
| 54 | _CURRENT = "current.json" |
| 55 | _SESSION_SCHEMA_VERSION = "1" |
| 56 | |
| 57 | |
| 58 | class MuseSessionRecord(TypedDict, total=False): |
| 59 | """Wire-format for a Muse recording session stored in ``.muse/sessions/``. |
| 60 | |
| 61 | Fields |
| 62 | ------ |
| 63 | session_id |
| 64 | UUIDv4 string that uniquely identifies the session. |
| 65 | schema_version |
| 66 | Integer string (currently "1") for forward-compatibility. |
| 67 | started_at |
| 68 | ISO-8601 UTC timestamp written by ``muse session start``. |
| 69 | ended_at |
| 70 | ISO-8601 UTC timestamp written by ``muse session end``; ``None`` |
| 71 | while the session is still active. |
| 72 | participants |
| 73 | Ordered list of participant names supplied via ``--participants``. |
| 74 | location |
| 75 | Free-form recording location or studio name. |
| 76 | intent |
| 77 | Creative intent or goal declared at session start. |
| 78 | commits |
| 79 | List of Muse commit IDs associated with this session (appended |
| 80 | externally; starts empty). |
| 81 | notes |
| 82 | Closing notes added by ``muse session end --notes``. |
| 83 | """ |
| 84 | |
| 85 | session_id: str |
| 86 | schema_version: str |
| 87 | started_at: str |
| 88 | ended_at: str | None |
| 89 | participants: list[str] |
| 90 | location: str |
| 91 | intent: str |
| 92 | commits: list[str] |
| 93 | notes: str |
| 94 | |
| 95 | |
| 96 | # --------------------------------------------------------------------------- |
| 97 | # Helpers |
| 98 | # --------------------------------------------------------------------------- |
| 99 | |
| 100 | |
| 101 | def _sessions_dir(repo_root: pathlib.Path) -> pathlib.Path: |
| 102 | """Return (and create if needed) the .muse/sessions/ directory.""" |
| 103 | d = repo_root / ".muse" / "sessions" |
| 104 | d.mkdir(parents=True, exist_ok=True) |
| 105 | return d |
| 106 | |
| 107 | |
| 108 | def _read_session(path: pathlib.Path) -> MuseSessionRecord: |
| 109 | """Read and parse a session JSON file; raise typer.Exit on error.""" |
| 110 | try: |
| 111 | raw: MuseSessionRecord = json.loads(path.read_text()) |
| 112 | return raw |
| 113 | except json.JSONDecodeError as exc: |
| 114 | typer.echo(f"❌ Corrupt session file {path.name}: {exc}") |
| 115 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) from exc |
| 116 | except OSError as exc: |
| 117 | typer.echo(f"❌ Cannot read {path}: {exc}") |
| 118 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) from exc |
| 119 | |
| 120 | |
| 121 | def _write_session(path: pathlib.Path, data: MuseSessionRecord) -> None: |
| 122 | """Write *data* as indented JSON to *path*.""" |
| 123 | try: |
| 124 | path.write_text(json.dumps(data, indent=2) + "\n") |
| 125 | except OSError as exc: |
| 126 | typer.echo(f"❌ Cannot write {path}: {exc}") |
| 127 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) from exc |
| 128 | |
| 129 | |
| 130 | def _now_iso() -> str: |
| 131 | return datetime.datetime.now(datetime.timezone.utc).isoformat() |
| 132 | |
| 133 | |
| 134 | def _load_completed_sessions( |
| 135 | sessions_dir: pathlib.Path, |
| 136 | ) -> list[MuseSessionRecord]: |
| 137 | """Return all completed session records sorted by started_at descending.""" |
| 138 | sessions: list[MuseSessionRecord] = [] |
| 139 | for p in sessions_dir.glob("*.json"): |
| 140 | if p.name == _CURRENT or p.name.startswith(".tmp-"): |
| 141 | continue |
| 142 | try: |
| 143 | data = _read_session(p) |
| 144 | sessions.append(data) |
| 145 | except SystemExit: |
| 146 | # _read_session already printed an error; skip corrupt files in log/credits |
| 147 | logger.warning("⚠️ Skipping corrupt session file: %s", p.name) |
| 148 | sessions.sort(key=lambda s: str(s.get("started_at", "")), reverse=True) |
| 149 | return sessions |
| 150 | |
| 151 | |
| 152 | # --------------------------------------------------------------------------- |
| 153 | # Subcommands |
| 154 | # --------------------------------------------------------------------------- |
| 155 | |
| 156 | |
| 157 | @app.command("start") |
| 158 | def start( |
| 159 | participants: Annotated[ |
| 160 | str, |
| 161 | typer.Option( |
| 162 | "--participants", |
| 163 | help="Comma-separated list of participant names.", |
| 164 | ), |
| 165 | ] = "", |
| 166 | location: Annotated[ |
| 167 | str, |
| 168 | typer.Option("--location", help="Recording location or studio name."), |
| 169 | ] = "", |
| 170 | intent: Annotated[ |
| 171 | str, |
| 172 | typer.Option("--intent", help="Creative intent or goal for this session."), |
| 173 | ] = "", |
| 174 | ) -> None: |
| 175 | """Start a new recording session. |
| 176 | |
| 177 | Writes ``.muse/sessions/current.json``. Only one active session is |
| 178 | supported at a time; use ``muse session end`` before starting a new one. |
| 179 | """ |
| 180 | repo_root = require_repo() |
| 181 | sessions_dir = _sessions_dir(repo_root) |
| 182 | current_path = sessions_dir / _CURRENT |
| 183 | |
| 184 | if current_path.exists(): |
| 185 | typer.echo( |
| 186 | "⚠️ A session is already active. Run `muse session end` before starting a new one." |
| 187 | ) |
| 188 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 189 | |
| 190 | participant_list = [p.strip() for p in participants.split(",") if p.strip()] |
| 191 | session_id = str(uuid.uuid4()) |
| 192 | session: MuseSessionRecord = { |
| 193 | "session_id": session_id, |
| 194 | "schema_version": _SESSION_SCHEMA_VERSION, |
| 195 | "started_at": _now_iso(), |
| 196 | "ended_at": None, |
| 197 | "participants": participant_list, |
| 198 | "location": location, |
| 199 | "intent": intent, |
| 200 | "commits": [], |
| 201 | "notes": "", |
| 202 | } |
| 203 | _write_session(current_path, session) |
| 204 | |
| 205 | typer.echo(f"✅ Session started [{session_id}]") |
| 206 | if participant_list: |
| 207 | typer.echo(f" Participants : {', '.join(participant_list)}") |
| 208 | if location: |
| 209 | typer.echo(f" Location : {location}") |
| 210 | if intent: |
| 211 | typer.echo(f" Intent : {intent}") |
| 212 | logger.info("✅ Session started: %s", session_id) |
| 213 | |
| 214 | |
| 215 | @app.command("end") |
| 216 | def end( |
| 217 | notes: Annotated[ |
| 218 | str, |
| 219 | typer.Option("--notes", help="Closing notes for the session."), |
| 220 | ] = "", |
| 221 | ) -> None: |
| 222 | """End the active recording session. |
| 223 | |
| 224 | Reads ``.muse/sessions/current.json``, sets ``ended_at``, then moves |
| 225 | the file to ``.muse/sessions/<session_id>.json``. |
| 226 | """ |
| 227 | repo_root = require_repo() |
| 228 | sessions_dir = _sessions_dir(repo_root) |
| 229 | current_path = sessions_dir / _CURRENT |
| 230 | |
| 231 | if not current_path.exists(): |
| 232 | typer.echo("⚠️ No active session. Run `muse session start` first.") |
| 233 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 234 | |
| 235 | session = _read_session(current_path) |
| 236 | session["ended_at"] = _now_iso() |
| 237 | if notes: |
| 238 | session["notes"] = notes |
| 239 | |
| 240 | session_id = str(session.get("session_id", uuid.uuid4())) |
| 241 | dest = sessions_dir / f"{session_id}.json" |
| 242 | |
| 243 | # Write to a temp file in the same directory then atomically rename so that |
| 244 | # a crash between write and cleanup never leaves both current.json and |
| 245 | # <uuid>.json present simultaneously. |
| 246 | tmp = sessions_dir / f".tmp-{session_id}.json" |
| 247 | _write_session(tmp, session) |
| 248 | try: |
| 249 | tmp.rename(dest) |
| 250 | current_path.unlink() |
| 251 | except OSError as exc: |
| 252 | tmp.unlink(missing_ok=True) |
| 253 | typer.echo(f"❌ Failed to finalise session: {exc}") |
| 254 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) from exc |
| 255 | |
| 256 | typer.echo(f"✅ Session ended [{session_id}]") |
| 257 | typer.echo(f" Saved to : .muse/sessions/{session_id}.json") |
| 258 | logger.info("✅ Session ended: %s", session_id) |
| 259 | |
| 260 | |
| 261 | @app.command("log") |
| 262 | def log_sessions() -> None: |
| 263 | """List all completed sessions, newest first.""" |
| 264 | repo_root = require_repo() |
| 265 | sessions_dir = _sessions_dir(repo_root) |
| 266 | sessions = _load_completed_sessions(sessions_dir) |
| 267 | |
| 268 | if not sessions: |
| 269 | typer.echo("No completed sessions found.") |
| 270 | return |
| 271 | |
| 272 | for s in sessions: |
| 273 | sid = str(s.get("session_id", "?")) |
| 274 | started = str(s.get("started_at", "?")) |
| 275 | ended = str(s.get("ended_at", "?")) |
| 276 | parts = s.get("participants", []) |
| 277 | part_list = parts if isinstance(parts, list) else [] |
| 278 | part_str = ", ".join(str(p) for p in part_list) |
| 279 | typer.echo(f"{sid[:8]} {started[:19]} → {ended[:19]} [{part_str}]") |
| 280 | |
| 281 | |
| 282 | @app.command("show") |
| 283 | def show( |
| 284 | session_id: Annotated[ |
| 285 | str, |
| 286 | typer.Argument(help="Session ID or unique prefix to display."), |
| 287 | ], |
| 288 | ) -> None: |
| 289 | """Show the full JSON for a completed session. |
| 290 | |
| 291 | Accepts a unique prefix of the session UUID (minimum 4 characters). |
| 292 | """ |
| 293 | repo_root = require_repo() |
| 294 | sessions_dir = _sessions_dir(repo_root) |
| 295 | |
| 296 | matches: list[pathlib.Path] = [] |
| 297 | for p in sessions_dir.glob("*.json"): |
| 298 | if p.name == _CURRENT or p.name.startswith(".tmp-"): |
| 299 | continue |
| 300 | if p.stem.startswith(session_id): |
| 301 | matches.append(p) |
| 302 | |
| 303 | if not matches: |
| 304 | typer.echo(f"❌ No session found matching '{session_id}'.") |
| 305 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 306 | |
| 307 | if len(matches) > 1: |
| 308 | typer.echo( |
| 309 | f"⚠️ Ambiguous prefix '{session_id}' matches {len(matches)} sessions. " |
| 310 | "Provide more characters." |
| 311 | ) |
| 312 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 313 | |
| 314 | session = _read_session(matches[0]) |
| 315 | typer.echo(json.dumps(session, indent=2)) |
| 316 | |
| 317 | |
| 318 | @app.command("credits") |
| 319 | def credits_cmd() -> None: |
| 320 | """Aggregate all participants across completed sessions. |
| 321 | |
| 322 | Outputs each unique participant name and the number of sessions they |
| 323 | appear in, sorted by session count descending. |
| 324 | """ |
| 325 | repo_root = require_repo() |
| 326 | sessions_dir = _sessions_dir(repo_root) |
| 327 | sessions = _load_completed_sessions(sessions_dir) |
| 328 | |
| 329 | counts: dict[str, int] = {} |
| 330 | for s in sessions: |
| 331 | parts = s.get("participants", []) |
| 332 | if isinstance(parts, list): |
| 333 | for p in parts: |
| 334 | name = str(p).strip() |
| 335 | if name: |
| 336 | counts[name] = counts.get(name, 0) + 1 |
| 337 | |
| 338 | if not counts: |
| 339 | typer.echo("No participants recorded across any completed sessions.") |
| 340 | return |
| 341 | |
| 342 | typer.echo("Session credits:") |
| 343 | for name, n in sorted(counts.items(), key=lambda kv: kv[1], reverse=True): |
| 344 | typer.echo(f" {name:30s} {n} session{'s' if n != 1 else ''}") |