From 0266fda42b9fb7051ab5a6d60dc09b6e557cba8e Mon Sep 17 00:00:00 2001 From: Mario Lavoie Date: Mon, 9 Feb 2026 12:53:46 +0000 Subject: [PATCH] Make KB Capture project-centric - Sessions now live inside project folders: /_capture// - Project picker dropdown (scans projects folder) - Auto-discovers projects with KB/ folder or _context.md - Windows: D:/ATODrive/Projects - Linux: ~/obsidian-vault/2-Projects This aligns with the KB structure where Mario updates: - KB/dev/gen-XXX.md (session captures) - Images/screenshot-sessions/ (frames) --- docs/KB-CAPTURE.md | 44 ++++++++----- src/cad_documenter/gui_capture.py | 89 ++++++++++++++++++++----- src/cad_documenter/kb_capture.py | 29 ++++++--- src/cad_documenter/session.py | 105 ++++++++++++++++++++++-------- 4 files changed, 199 insertions(+), 68 deletions(-) diff --git a/docs/KB-CAPTURE.md b/docs/KB-CAPTURE.md index 0b888df..a820803 100644 --- a/docs/KB-CAPTURE.md +++ b/docs/KB-CAPTURE.md @@ -21,12 +21,14 @@ uv run kb-capture ## Workflow ### 1. Start Session -- Open KB Capture (system tray or GUI) -- Enter project name (e.g., "P04-GigaBIT-M1") +- Open KB Capture (GUI) +- **Select project** from dropdown (scans your projects folder) - Enter session description (e.g., "Vertical support refinement") - Select type: **Design** (CAD) or **Analysis** (FEA) - Click **Start Session** +> Projects are auto-discovered from your projects folder (D:/ATODrive/Projects on Windows) + ### 2. Record Clips While working in NX/CAD: - Press **Ctrl+Shift+R** to start recording @@ -63,24 +65,34 @@ After each clip: ## Output -After ending a session: +Sessions are stored **inside the project folder**: ``` -sessions// -├── clips/ -│ ├── clip-001.mp4 -│ ├── clip-002.mp4 -│ └── ... -├── session.json -└── clawdbot_export/ - ├── merged.mp4 # All clips merged - ├── transcript.json # Whisper transcription - ├── frames/ # Extracted at "screenshot" triggers - │ ├── 01_00-30.png - │ └── ... - └── metadata.json # Session info for Clawdbot +/2-Projects// +├── KB/ +│ └── dev/ # Mario creates gen-XXX.md here +├── Images/ +│ └── screenshot-sessions/ # Mario moves frames here +└── _capture/ # Session staging area + └── / + ├── clips/ + │ ├── clip-001.mp4 + │ └── ... + ├── session.json + └── clawdbot_export/ # Ready for Mario + ├── merged.mp4 + ├── transcript.json + ├── frames/ + │ ├── 01_00-30.png + │ └── ... + └── metadata.json ``` +**Key insight:** Sessions belong to PROJECTS, not to KB Capture. This means: +- All project data stays together +- Mario knows which KB to update +- Easy to archive/delete projects + ## What Happens Next 1. **Syncthing** syncs `clawdbot_export/` to Clawdbot diff --git a/src/cad_documenter/gui_capture.py b/src/cad_documenter/gui_capture.py index e4d9b02..7c7fbde 100644 --- a/src/cad_documenter/gui_capture.py +++ b/src/cad_documenter/gui_capture.py @@ -115,13 +115,15 @@ class ClipCard(CTkFrame): class KBCaptureGUI: """Main GUI window for KB Capture.""" - def __init__(self, base_path: Path): + def __init__(self, projects_root: Path): if not HAS_CTK: raise RuntimeError("CustomTkinter not installed") + self.projects_root = projects_root + # App self.app = KBCaptureApp( - base_path=base_path, + projects_root=projects_root, on_status_change=self._on_status_change, ) @@ -182,16 +184,33 @@ class KBCaptureGUI: self.session_frame.grid(row=1, column=0, sticky="ew", padx=20, pady=10) self.session_frame.grid_columnconfigure(1, weight=1) - # Project + # Project (dropdown) CTkLabel(self.session_frame, text="Project:", text_color=COLORS["text_dim"]).grid( row=0, column=0, padx=15, pady=(15, 5), sticky="w" ) - self.project_entry = CTkEntry( + + # Get available projects + projects = self.app.session_manager.list_projects() + if not projects: + projects = ["(No projects found)"] + + self.project_menu = CTkOptionMenu( self.session_frame, - placeholder_text="P04-GigaBIT-M1", + values=projects, width=250, ) - self.project_entry.grid(row=0, column=1, padx=(0, 15), pady=(15, 5), sticky="ew") + self.project_menu.grid(row=0, column=1, padx=(0, 15), pady=(15, 5), sticky="ew") + + # Refresh button + refresh_btn = CTkButton( + self.session_frame, + text="↻", + width=30, + fg_color="transparent", + hover_color=COLORS["bg_dark"], + command=self._refresh_projects, + ) + refresh_btn.grid(row=0, column=2, padx=(0, 15), pady=(15, 5)) # Session name CTkLabel(self.session_frame, text="Session:", text_color=COLORS["text_dim"]).grid( @@ -313,19 +332,36 @@ class KBCaptureGUI: ) hints.grid(row=1, column=0, columnspan=3, pady=(0, 10)) + def _refresh_projects(self): + """Refresh the project list.""" + projects = self.app.session_manager.list_projects() + if not projects: + projects = ["(No projects found)"] + self.project_menu.configure(values=projects) + if projects: + self.project_menu.set(projects[0]) + def _start_session(self): """Start a new session.""" - project = self.project_entry.get() or "P04-GigaBIT-M1" + project = self.project_menu.get() + if project == "(No projects found)": + self.status_label.configure(text="No project selected!") + return + name = self.name_entry.get() or "Recording Session" session_type = SessionType.DESIGN if self.type_menu.get() == "Design" else SessionType.ANALYSIS - self.app.start_session(name, project, session_type) + try: + self.app.start_session(name, project, session_type) + except ValueError as e: + self.status_label.configure(text=str(e)) + return # Update UI self.start_btn.configure(state="disabled", text="Session Active") self.record_btn.configure(state="normal") self.end_btn.configure(state="normal") - self.project_entry.configure(state="disabled") + self.project_menu.configure(state="disabled") self.name_entry.configure(state="disabled") self.type_menu.configure(state="disabled") @@ -342,7 +378,7 @@ class KBCaptureGUI: self.record_btn.configure(state="disabled") self.keep_btn.configure(state="disabled") self.end_btn.configure(state="disabled") - self.project_entry.configure(state="normal") + self.project_menu.configure(state="normal") self.name_entry.configure(state="normal") self.type_menu.configure(state="normal") @@ -464,15 +500,38 @@ def main(): print("Install with: pip install customtkinter") sys.exit(1) - # Default to user's documents folder + # Default projects location + # Windows: Look for ATODrive or Documents + # Linux: Look for obsidian-vault (Syncthing) or home if sys.platform == "win32": - base = Path.home() / "Documents" / "KB-Capture" + # Try common Windows locations + candidates = [ + Path("D:/ATODrive/Projects"), + Path("C:/ATODrive/Projects"), + Path.home() / "Documents" / "Projects", + ] else: - base = Path.home() / "kb-capture" + # Linux/Clawdbot - use Syncthing path + candidates = [ + Path.home() / "obsidian-vault" / "2-Projects", + Path.home() / "ATODrive" / "Projects", + ] - base.mkdir(parents=True, exist_ok=True) + projects_root = None + for path in candidates: + if path.exists(): + projects_root = path + break - gui = KBCaptureGUI(base_path=base) + if not projects_root: + # Fallback: create in Documents + projects_root = Path.home() / "Documents" / "Projects" + projects_root.mkdir(parents=True, exist_ok=True) + print(f"No projects folder found. Using: {projects_root}") + + print(f"Projects root: {projects_root}") + + gui = KBCaptureGUI(projects_root=projects_root) gui.run() diff --git a/src/cad_documenter/kb_capture.py b/src/cad_documenter/kb_capture.py index 72ebe1f..254e0ec 100644 --- a/src/cad_documenter/kb_capture.py +++ b/src/cad_documenter/kb_capture.py @@ -49,22 +49,23 @@ class KBCaptureApp: Main KB Capture application. Controls recording flow: - 1. Start session (project, name, type) - 2. Toggle recording to create clips - 3. Keep or delete clips - 4. End session to export + 1. Select project from available projects + 2. Start session (name, type) + 3. Toggle recording to create clips + 4. Keep or delete clips + 5. End session to export """ def __init__( self, - base_path: Path, + projects_root: Path, on_status_change: Optional[Callable[[AppStatus], None]] = None, ): - self.base_path = Path(base_path) + self.projects_root = Path(projects_root) self.on_status_change = on_status_change or (lambda x: None) # Components - self.session_manager = SessionManager(self.base_path) + self.session_manager = SessionManager(self.projects_root) self.recorder = ScreenRecorder(on_status=self._log) self.hotkeys = HotkeyManager() @@ -316,13 +317,19 @@ if __name__ == "__main__": print(f" → {status.message}") with tempfile.TemporaryDirectory() as tmpdir: + # Create a test project + test_project = Path(tmpdir) / "Test-Project" + (test_project / "KB").mkdir(parents=True) + app = KBCaptureApp( - base_path=Path(tmpdir), + projects_root=Path(tmpdir), on_status_change=on_status, ) print("\n=== KB Capture Test ===") - print("Commands:") + print(f"Projects root: {tmpdir}") + print(f"Available projects: {app.session_manager.list_projects()}") + print("\nCommands:") print(" s - Start session") print(" r - Toggle recording") print(" k - Keep last clip") @@ -339,8 +346,10 @@ if __name__ == "__main__": cmd = input("> ").strip().lower() if cmd == "s": + projects = app.session_manager.list_projects() + print(f"Available projects: {projects}") + project = input("Project: ").strip() or (projects[0] if projects else "Test-Project") name = input("Session name: ").strip() or "Test Session" - project = input("Project: ").strip() or "P04-GigaBIT-M1" app.start_session(name, project) elif cmd == "r": app.toggle_recording() diff --git a/src/cad_documenter/session.py b/src/cad_documenter/session.py index 92036fc..f425e62 100644 --- a/src/cad_documenter/session.py +++ b/src/cad_documenter/session.py @@ -116,27 +116,61 @@ class SessionManager: """ Manages recording sessions and clips. - Directory structure: - sessions/ - ├── / - │ ├── session.json # Session metadata - │ ├── clips/ - │ │ ├── clip-001.mp4 - │ │ ├── clip-002.mp4 - │ │ └── ... - │ └── export/ # Final export for Clawdbot - │ ├── merged.mp4 - │ ├── transcript.json - │ └── metadata.json + Project-centric structure: + /2-Projects// + ├── KB/ + │ └── dev/ # gen-XXX.md session captures (Mario creates) + ├── Images/ + │ └── screenshot-sessions/ # Frames organized by session + └── _capture/ # Session staging + └── / + ├── session.json + ├── clips/ + │ ├── clip-001.mp4 + │ └── ... + └── clawdbot_export/ # Ready for Mario + ├── merged.mp4 + ├── transcript.json + └── metadata.json """ - def __init__(self, base_path: Path): - self.base_path = Path(base_path) - self.sessions_dir = self.base_path / "sessions" - self.sessions_dir.mkdir(parents=True, exist_ok=True) + 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).""" + 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) + if (p / "KB").exists() or (p / "_context.md").exists(): + projects.append(p.name) + return projects + + def get_project_path(self, project: str) -> Path: + """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, @@ -144,7 +178,12 @@ class SessionManager: project: str, session_type: SessionType = SessionType.DESIGN, ) -> Session: - """Start a new recording session.""" + """Start a new recording session within a project.""" + # Set current project + self._current_project_path = self.get_project_path(project) + if not self._current_project_path.exists(): + raise ValueError(f"Project not found: {project}") + session_id = datetime.now().strftime("%Y%m%d-%H%M%S") session = Session( @@ -155,8 +194,9 @@ class SessionManager: created_at=datetime.now(), ) - # Create session directory - session_dir = self.sessions_dir / session_id + # Create session directory in project's _capture folder + capture_dir = self._current_project_path / "_capture" + session_dir = capture_dir / session_id session_dir.mkdir(parents=True, exist_ok=True) (session_dir / "clips").mkdir(exist_ok=True) @@ -299,15 +339,26 @@ class SessionManager: return Session.from_dict(json.load(f)) return None - def list_sessions(self) -> List[Session]: - """List all sessions.""" + def list_sessions(self, project: Optional[str] = None) -> List[Session]: + """List sessions, optionally filtered by project.""" sessions = [] - for session_dir in sorted(self.sessions_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))) + + if project: + # List sessions for specific project + capture_dir = self.get_capture_dir(project) + 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))) + 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: