Make KB Capture project-centric

- Sessions now live inside project folders: <Project>/_capture/<session>/
- 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)
This commit is contained in:
Mario Lavoie
2026-02-09 12:53:46 +00:00
parent d5371cfe75
commit 0266fda42b
4 changed files with 199 additions and 68 deletions

View File

@@ -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/<session-id>/
├── 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/<ProjectName>/
├── KB/
── dev/ # Mario creates gen-XXX.md here
├── Images/
│ └── screenshot-sessions/ # Mario moves frames here
── _capture/ # Session staging area
└── <session-id>/
├── 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

View File

@@ -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()

View File

@@ -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()

View File

@@ -116,27 +116,61 @@ class SessionManager:
"""
Manages recording sessions and clips.
Directory structure:
sessions/
├── <session-id>/
── 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/<ProjectName>/
├── KB/
── dev/ # gen-XXX.md session captures (Mario creates)
├── Images/
└── screenshot-sessions/ # Frames organized by session
└── _capture/ # Session staging
└── <session-id>/
├── 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: