cgcardona / muse public
session.py python
344 lines 10.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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 ''}")