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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user