Simplify KB Capture to match Voice Recorder pattern

Major simplification:
- Removed clips concept (no keep/delete segments)
- Single continuous recording per session with pause/resume
- Matches Voice Recorder UX pattern Antoine knows

Flow:
  Start Session → Record → Pause → Resume → Stop → Transcribe → Done

Features:
- Record/Pause/Resume/Stop controls
- Session types: Design / Analysis
- Auto-transcribe with Whisper on stop
- Finds 'screenshot' triggers in transcript for Clawdbot
- Simple dark theme UI matching Voice Recorder

Removed:
- export.py (transcription now inline)
- hotkeys.py (not needed for MVP)
- Clip management
This commit is contained in:
Mario Lavoie
2026-02-09 22:14:34 +00:00
parent 09c32cbad2
commit 9b24478f04
6 changed files with 786 additions and 2091 deletions

View File

@@ -1,92 +1,43 @@
"""
Session Manager for KB Capture
Session Manager for KB Capture (Simplified)
Manages recording sessions with multiple clips.
Clips can be kept or deleted before finalizing.
One session = one continuous recording (with pause/resume).
No clips, no keep/delete. Just record → transcribe → done.
"""
import json
import shutil
from pathlib import Path
from dataclasses import dataclass, field, asdict
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List
from enum import Enum
import uuid
class ClipStatus(Enum):
RECORDING = "recording"
PREVIEW = "preview" # Just recorded, awaiting decision
KEPT = "kept"
DELETED = "deleted"
class SessionType(Enum):
DESIGN = "design" # CAD/Design KB
DESIGN = "design" # CAD/Design KB
ANALYSIS = "analysis" # FEA/Analysis KB
@dataclass
class Clip:
"""A single recording clip within a session."""
id: str
filename: str
start_time: datetime
end_time: Optional[datetime] = None
duration_seconds: float = 0.0
status: ClipStatus = ClipStatus.RECORDING
note: str = "" # Optional quick note
def to_dict(self) -> dict:
return {
"id": self.id,
"filename": self.filename,
"start_time": self.start_time.isoformat(),
"end_time": self.end_time.isoformat() if self.end_time else None,
"duration_seconds": self.duration_seconds,
"status": self.status.value,
"note": self.note,
}
@classmethod
def from_dict(cls, data: dict) -> "Clip":
return cls(
id=data["id"],
filename=data["filename"],
start_time=datetime.fromisoformat(data["start_time"]),
end_time=datetime.fromisoformat(data["end_time"]) if data.get("end_time") else None,
duration_seconds=data.get("duration_seconds", 0.0),
status=ClipStatus(data.get("status", "kept")),
note=data.get("note", ""),
)
class SessionStatus(Enum):
RECORDING = "recording"
PAUSED = "paused"
TRANSCRIBING = "transcribing"
READY = "ready" # Transcribed, ready for sync
PROCESSED = "processed" # Clawdbot has processed it
@dataclass
class Session:
"""A recording session containing multiple clips."""
"""A recording session."""
id: str
name: str
project: str
session_type: SessionType
created_at: datetime
clips: List[Clip] = field(default_factory=list)
is_finalized: bool = False
@property
def total_duration(self) -> float:
"""Total duration of kept clips."""
return sum(c.duration_seconds for c in self.clips if c.status == ClipStatus.KEPT)
@property
def kept_clips(self) -> List[Clip]:
"""Clips marked as kept."""
return [c for c in self.clips if c.status == ClipStatus.KEPT]
@property
def clip_count(self) -> int:
"""Number of kept clips."""
return len(self.kept_clips)
duration: float = 0.0
status: SessionStatus = SessionStatus.RECORDING
video_file: str = "recording.mp4"
transcript_file: Optional[str] = None
def to_dict(self) -> dict:
return {
@@ -95,8 +46,10 @@ class Session:
"project": self.project,
"session_type": self.session_type.value,
"created_at": self.created_at.isoformat(),
"clips": [c.to_dict() for c in self.clips],
"is_finalized": self.is_finalized,
"duration": self.duration,
"status": self.status.value,
"video_file": self.video_file,
"transcript_file": self.transcript_file,
}
@classmethod
@@ -107,52 +60,38 @@ class Session:
project=data["project"],
session_type=SessionType(data.get("session_type", "design")),
created_at=datetime.fromisoformat(data["created_at"]),
clips=[Clip.from_dict(c) for c in data.get("clips", [])],
is_finalized=data.get("is_finalized", False),
duration=data.get("duration", 0.0),
status=SessionStatus(data.get("status", "ready")),
video_file=data.get("video_file", "recording.mp4"),
transcript_file=data.get("transcript_file"),
)
class SessionManager:
"""
Manages recording sessions and clips.
Manages recording sessions.
Project-centric structure:
/2-Projects/<ProjectName>/
── KB/
│ └── dev/ # gen-XXX.md session captures (Mario creates)
├── Images/
│ └── screenshot-sessions/ # Frames organized by session
└── _capture/ # Session staging
/Projects/<ProjectName>/
── _capture/
└── <session-id>/
├── session.json
├── clips/
│ ├── clip-001.mp4
│ └── ...
└── clawdbot_export/ # Ready for Mario
├── merged.mp4
├── transcript.json
└── metadata.json
├── session.json # Metadata
├── recording.mp4 # Video
└── transcript.json # Whisper output
"""
def __init__(self, projects_root: Path):
"""
Initialize session manager.
Args:
projects_root: Path to projects folder (e.g., /2-Projects/ or D:/ATODrive/Projects/)
"""
self.projects_root = Path(projects_root)
self.current_session: Optional[Session] = None
self.current_clip: Optional[Clip] = None
self._current_project_path: Optional[Path] = None
def list_projects(self) -> List[str]:
"""List available projects (folders in projects_root)."""
"""List available projects."""
projects = []
if self.projects_root.exists():
for p in sorted(self.projects_root.iterdir()):
if p.is_dir() and not p.name.startswith((".", "_")):
# Check if it looks like a project (has KB folder or _context.md)
# Check if it looks like a project
if (p / "KB").exists() or (p / "_context.md").exists():
projects.append(p.name)
return projects
@@ -161,25 +100,13 @@ class SessionManager:
"""Get full path to a project."""
return self.projects_root / project
def get_capture_dir(self, project: str) -> Path:
"""Get the _capture directory for a project."""
return self.get_project_path(project) / "_capture"
@property
def sessions_dir(self) -> Path:
"""Current project's capture directory."""
if self._current_project_path:
return self._current_project_path / "_capture"
raise RuntimeError("No project selected")
def start_session(
self,
name: str,
project: str,
session_type: SessionType = SessionType.DESIGN,
) -> Session:
"""Start a new recording session within a project."""
# Set current project
"""Start a new recording session."""
self._current_project_path = self.get_project_path(project)
if not self._current_project_path.exists():
raise ValueError(f"Project not found: {project}")
@@ -192,221 +119,99 @@ class SessionManager:
project=project,
session_type=session_type,
created_at=datetime.now(),
status=SessionStatus.RECORDING,
)
# Create session directory in project's _capture folder
capture_dir = self._current_project_path / "_capture"
session_dir = capture_dir / session_id
# Create session directory
session_dir = self._current_project_path / "_capture" / session_id
session_dir.mkdir(parents=True, exist_ok=True)
(session_dir / "clips").mkdir(exist_ok=True)
self.current_session = session
self._save_session()
return session
def start_clip(self) -> tuple[Clip, Path]:
"""
Start a new clip in current session.
Returns clip object and path for recording.
"""
if not self.current_session:
def get_session_dir(self) -> Path:
"""Get current session directory."""
if not self.current_session or not self._current_project_path:
raise RuntimeError("No active session")
clip_num = len(self.current_session.clips) + 1
clip_id = f"clip-{clip_num:03d}"
filename = f"{clip_id}.mp4"
clip = Clip(
id=clip_id,
filename=filename,
start_time=datetime.now(),
status=ClipStatus.RECORDING,
)
self.current_session.clips.append(clip)
self.current_clip = clip
self._save_session()
clip_path = self.sessions_dir / self.current_session.id / "clips" / filename
return clip, clip_path
return self._current_project_path / "_capture" / self.current_session.id
def end_clip(self, duration: float) -> Clip:
"""End current clip, move to preview state."""
if not self.current_clip:
raise RuntimeError("No active clip")
self.current_clip.end_time = datetime.now()
self.current_clip.duration_seconds = duration
self.current_clip.status = ClipStatus.PREVIEW
clip = self.current_clip
self.current_clip = None
self._save_session()
return clip
def get_video_path(self) -> Path:
"""Get path for video file."""
return self.get_session_dir() / self.current_session.video_file
def keep_clip(self, clip_id: str, note: str = "") -> None:
"""Mark a clip as kept."""
if not self.current_session:
raise RuntimeError("No active session")
for clip in self.current_session.clips:
if clip.id == clip_id:
clip.status = ClipStatus.KEPT
clip.note = note
break
self._save_session()
def update_status(self, status: SessionStatus) -> None:
"""Update session status."""
if self.current_session:
self.current_session.status = status
self._save_session()
def delete_clip(self, clip_id: str) -> None:
"""Mark a clip as deleted and remove file."""
if not self.current_session:
raise RuntimeError("No active session")
for clip in self.current_session.clips:
if clip.id == clip_id:
clip.status = ClipStatus.DELETED
# Delete the actual file
clip_path = self.sessions_dir / self.current_session.id / "clips" / clip.filename
if clip_path.exists():
clip_path.unlink()
break
self._save_session()
def set_duration(self, duration: float) -> None:
"""Set recording duration."""
if self.current_session:
self.current_session.duration = duration
self._save_session()
def keep_last_clip(self, note: str = "") -> Optional[Clip]:
"""Keep the most recent clip in preview state."""
if not self.current_session:
return None
for clip in reversed(self.current_session.clips):
if clip.status == ClipStatus.PREVIEW:
self.keep_clip(clip.id, note)
return clip
return None
def delete_last_clip(self) -> Optional[Clip]:
"""Delete the most recent clip in preview state."""
if not self.current_session:
return None
for clip in reversed(self.current_session.clips):
if clip.status == ClipStatus.PREVIEW:
self.delete_clip(clip.id)
return clip
return None
def set_transcript(self, transcript_file: str) -> None:
"""Set transcript file name."""
if self.current_session:
self.current_session.transcript_file = transcript_file
self._save_session()
def end_session(self) -> Session:
"""
End session and prepare for export.
Clips still in preview are auto-kept.
"""
"""End current session."""
if not self.current_session:
raise RuntimeError("No active session")
# Auto-keep any clips still in preview
for clip in self.current_session.clips:
if clip.status == ClipStatus.PREVIEW:
clip.status = ClipStatus.KEPT
self.current_session.is_finalized = True
self.current_session.status = SessionStatus.READY
self._save_session()
session = self.current_session
self.current_session = None
self._current_project_path = None
return session
def cancel_session(self) -> None:
"""Cancel session and delete all files."""
if not self.current_session:
return
session_dir = self.sessions_dir / self.current_session.id
if session_dir.exists():
shutil.rmtree(session_dir)
"""Cancel session and delete files."""
if self.current_session:
import shutil
session_dir = self.get_session_dir()
if session_dir.exists():
shutil.rmtree(session_dir)
self.current_session = None
self.current_clip = None
def get_session(self, session_id: str) -> Optional[Session]:
"""Load a session by ID."""
session_file = self.sessions_dir / session_id / "session.json"
if session_file.exists():
with open(session_file) as f:
return Session.from_dict(json.load(f))
return None
self._current_project_path = None
def list_sessions(self, project: Optional[str] = None) -> List[Session]:
"""List sessions, optionally filtered by project."""
"""List sessions for a project or all projects."""
sessions = []
if project:
# List sessions for specific project
capture_dir = self.get_capture_dir(project)
capture_dir = self.get_project_path(project) / "_capture"
if capture_dir.exists():
for session_dir in sorted(capture_dir.iterdir(), reverse=True):
if session_dir.is_dir():
session_file = session_dir / "session.json"
if session_file.exists():
with open(session_file) as f:
sessions.append(Session.from_dict(json.load(f)))
try:
with open(session_file) as f:
sessions.append(Session.from_dict(json.load(f)))
except:
pass
else:
# List sessions across all projects
for proj in self.list_projects():
sessions.extend(self.list_sessions(proj))
sessions.sort(key=lambda s: s.created_at, reverse=True)
return sessions
def get_session_dir(self, session_id: str) -> Path:
"""Get session directory path."""
return self.sessions_dir / session_id
def _save_session(self) -> None:
"""Save current session to disk."""
if not self.current_session:
return
session_file = self.sessions_dir / self.current_session.id / "session.json"
session_file = self.get_session_dir() / "session.json"
with open(session_file, "w") as f:
json.dump(self.current_session.to_dict(), f, indent=2)
# Quick test
if __name__ == "__main__":
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(Path(tmpdir))
# Start session
session = manager.start_session(
name="Test Session",
project="P04-GigaBIT-M1",
session_type=SessionType.DESIGN,
)
print(f"Started session: {session.id}")
# Record some clips
clip1, path1 = manager.start_clip()
print(f"Recording clip 1 to: {path1}")
manager.end_clip(duration=45.5)
manager.keep_clip(clip1.id, note="Stage 2 joint")
clip2, path2 = manager.start_clip()
print(f"Recording clip 2 to: {path2}")
manager.end_clip(duration=30.0)
manager.delete_clip(clip2.id) # Oops, bad take
clip3, path3 = manager.start_clip()
print(f"Recording clip 3 to: {path3}")
manager.end_clip(duration=60.0)
manager.keep_last_clip()
# End session
session = manager.end_session()
print(f"Session ended: {session.clip_count} clips, {session.total_duration:.1f}s total")