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
|
## Workflow
|
||||||
|
|
||||||
### 1. Start Session
|
### 1. Start Session
|
||||||
- Open KB Capture (system tray or GUI)
|
- Open KB Capture (GUI)
|
||||||
- Enter project name (e.g., "P04-GigaBIT-M1")
|
- **Select project** from dropdown (scans your projects folder)
|
||||||
- Enter session description (e.g., "Vertical support refinement")
|
- Enter session description (e.g., "Vertical support refinement")
|
||||||
- Select type: **Design** (CAD) or **Analysis** (FEA)
|
- Select type: **Design** (CAD) or **Analysis** (FEA)
|
||||||
- Click **Start Session**
|
- Click **Start Session**
|
||||||
|
|
||||||
|
> Projects are auto-discovered from your projects folder (D:/ATODrive/Projects on Windows)
|
||||||
|
|
||||||
### 2. Record Clips
|
### 2. Record Clips
|
||||||
While working in NX/CAD:
|
While working in NX/CAD:
|
||||||
- Press **Ctrl+Shift+R** to start recording
|
- Press **Ctrl+Shift+R** to start recording
|
||||||
@@ -63,24 +65,34 @@ After each clip:
|
|||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
After ending a session:
|
Sessions are stored **inside the project folder**:
|
||||||
|
|
||||||
```
|
```
|
||||||
sessions/<session-id>/
|
/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/
|
├── clips/
|
||||||
│ ├── clip-001.mp4
|
│ ├── clip-001.mp4
|
||||||
│ ├── clip-002.mp4
|
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── session.json
|
├── session.json
|
||||||
└── clawdbot_export/
|
└── clawdbot_export/ # Ready for Mario
|
||||||
├── merged.mp4 # All clips merged
|
├── merged.mp4
|
||||||
├── transcript.json # Whisper transcription
|
├── transcript.json
|
||||||
├── frames/ # Extracted at "screenshot" triggers
|
├── frames/
|
||||||
│ ├── 01_00-30.png
|
│ ├── 01_00-30.png
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── metadata.json # Session info for Clawdbot
|
└── 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
|
## What Happens Next
|
||||||
|
|
||||||
1. **Syncthing** syncs `clawdbot_export/` to Clawdbot
|
1. **Syncthing** syncs `clawdbot_export/` to Clawdbot
|
||||||
|
|||||||
@@ -115,13 +115,15 @@ class ClipCard(CTkFrame):
|
|||||||
class KBCaptureGUI:
|
class KBCaptureGUI:
|
||||||
"""Main GUI window for KB Capture."""
|
"""Main GUI window for KB Capture."""
|
||||||
|
|
||||||
def __init__(self, base_path: Path):
|
def __init__(self, projects_root: Path):
|
||||||
if not HAS_CTK:
|
if not HAS_CTK:
|
||||||
raise RuntimeError("CustomTkinter not installed")
|
raise RuntimeError("CustomTkinter not installed")
|
||||||
|
|
||||||
|
self.projects_root = projects_root
|
||||||
|
|
||||||
# App
|
# App
|
||||||
self.app = KBCaptureApp(
|
self.app = KBCaptureApp(
|
||||||
base_path=base_path,
|
projects_root=projects_root,
|
||||||
on_status_change=self._on_status_change,
|
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(row=1, column=0, sticky="ew", padx=20, pady=10)
|
||||||
self.session_frame.grid_columnconfigure(1, weight=1)
|
self.session_frame.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
# Project
|
# Project (dropdown)
|
||||||
CTkLabel(self.session_frame, text="Project:", text_color=COLORS["text_dim"]).grid(
|
CTkLabel(self.session_frame, text="Project:", text_color=COLORS["text_dim"]).grid(
|
||||||
row=0, column=0, padx=15, pady=(15, 5), sticky="w"
|
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,
|
self.session_frame,
|
||||||
placeholder_text="P04-GigaBIT-M1",
|
values=projects,
|
||||||
width=250,
|
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
|
# Session name
|
||||||
CTkLabel(self.session_frame, text="Session:", text_color=COLORS["text_dim"]).grid(
|
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))
|
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):
|
def _start_session(self):
|
||||||
"""Start a new session."""
|
"""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"
|
name = self.name_entry.get() or "Recording Session"
|
||||||
session_type = SessionType.DESIGN if self.type_menu.get() == "Design" else SessionType.ANALYSIS
|
session_type = SessionType.DESIGN if self.type_menu.get() == "Design" else SessionType.ANALYSIS
|
||||||
|
|
||||||
|
try:
|
||||||
self.app.start_session(name, project, session_type)
|
self.app.start_session(name, project, session_type)
|
||||||
|
except ValueError as e:
|
||||||
|
self.status_label.configure(text=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
# Update UI
|
# Update UI
|
||||||
self.start_btn.configure(state="disabled", text="Session Active")
|
self.start_btn.configure(state="disabled", text="Session Active")
|
||||||
self.record_btn.configure(state="normal")
|
self.record_btn.configure(state="normal")
|
||||||
self.end_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.name_entry.configure(state="disabled")
|
||||||
self.type_menu.configure(state="disabled")
|
self.type_menu.configure(state="disabled")
|
||||||
|
|
||||||
@@ -342,7 +378,7 @@ class KBCaptureGUI:
|
|||||||
self.record_btn.configure(state="disabled")
|
self.record_btn.configure(state="disabled")
|
||||||
self.keep_btn.configure(state="disabled")
|
self.keep_btn.configure(state="disabled")
|
||||||
self.end_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.name_entry.configure(state="normal")
|
||||||
self.type_menu.configure(state="normal")
|
self.type_menu.configure(state="normal")
|
||||||
|
|
||||||
@@ -464,15 +500,38 @@ def main():
|
|||||||
print("Install with: pip install customtkinter")
|
print("Install with: pip install customtkinter")
|
||||||
sys.exit(1)
|
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":
|
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:
|
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()
|
gui.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,22 +49,23 @@ class KBCaptureApp:
|
|||||||
Main KB Capture application.
|
Main KB Capture application.
|
||||||
|
|
||||||
Controls recording flow:
|
Controls recording flow:
|
||||||
1. Start session (project, name, type)
|
1. Select project from available projects
|
||||||
2. Toggle recording to create clips
|
2. Start session (name, type)
|
||||||
3. Keep or delete clips
|
3. Toggle recording to create clips
|
||||||
4. End session to export
|
4. Keep or delete clips
|
||||||
|
5. End session to export
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_path: Path,
|
projects_root: Path,
|
||||||
on_status_change: Optional[Callable[[AppStatus], None]] = None,
|
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)
|
self.on_status_change = on_status_change or (lambda x: None)
|
||||||
|
|
||||||
# Components
|
# Components
|
||||||
self.session_manager = SessionManager(self.base_path)
|
self.session_manager = SessionManager(self.projects_root)
|
||||||
self.recorder = ScreenRecorder(on_status=self._log)
|
self.recorder = ScreenRecorder(on_status=self._log)
|
||||||
self.hotkeys = HotkeyManager()
|
self.hotkeys = HotkeyManager()
|
||||||
|
|
||||||
@@ -316,13 +317,19 @@ if __name__ == "__main__":
|
|||||||
print(f" → {status.message}")
|
print(f" → {status.message}")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a test project
|
||||||
|
test_project = Path(tmpdir) / "Test-Project"
|
||||||
|
(test_project / "KB").mkdir(parents=True)
|
||||||
|
|
||||||
app = KBCaptureApp(
|
app = KBCaptureApp(
|
||||||
base_path=Path(tmpdir),
|
projects_root=Path(tmpdir),
|
||||||
on_status_change=on_status,
|
on_status_change=on_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
print("\n=== KB Capture Test ===")
|
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(" s - Start session")
|
||||||
print(" r - Toggle recording")
|
print(" r - Toggle recording")
|
||||||
print(" k - Keep last clip")
|
print(" k - Keep last clip")
|
||||||
@@ -339,8 +346,10 @@ if __name__ == "__main__":
|
|||||||
cmd = input("> ").strip().lower()
|
cmd = input("> ").strip().lower()
|
||||||
|
|
||||||
if cmd == "s":
|
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"
|
name = input("Session name: ").strip() or "Test Session"
|
||||||
project = input("Project: ").strip() or "P04-GigaBIT-M1"
|
|
||||||
app.start_session(name, project)
|
app.start_session(name, project)
|
||||||
elif cmd == "r":
|
elif cmd == "r":
|
||||||
app.toggle_recording()
|
app.toggle_recording()
|
||||||
|
|||||||
@@ -116,27 +116,61 @@ class SessionManager:
|
|||||||
"""
|
"""
|
||||||
Manages recording sessions and clips.
|
Manages recording sessions and clips.
|
||||||
|
|
||||||
Directory structure:
|
Project-centric structure:
|
||||||
sessions/
|
/2-Projects/<ProjectName>/
|
||||||
├── <session-id>/
|
├── KB/
|
||||||
│ ├── session.json # Session metadata
|
│ └── dev/ # gen-XXX.md session captures (Mario creates)
|
||||||
│ ├── clips/
|
├── Images/
|
||||||
│ │ ├── clip-001.mp4
|
│ └── screenshot-sessions/ # Frames organized by session
|
||||||
│ │ ├── clip-002.mp4
|
└── _capture/ # Session staging
|
||||||
│ │ └── ...
|
└── <session-id>/
|
||||||
│ └── export/ # Final export for Clawdbot
|
├── session.json
|
||||||
│ ├── merged.mp4
|
├── clips/
|
||||||
│ ├── transcript.json
|
│ ├── clip-001.mp4
|
||||||
│ └── metadata.json
|
│ └── ...
|
||||||
|
└── clawdbot_export/ # Ready for Mario
|
||||||
|
├── merged.mp4
|
||||||
|
├── transcript.json
|
||||||
|
└── metadata.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, base_path: Path):
|
def __init__(self, projects_root: Path):
|
||||||
self.base_path = Path(base_path)
|
"""
|
||||||
self.sessions_dir = self.base_path / "sessions"
|
Initialize session manager.
|
||||||
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
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_session: Optional[Session] = None
|
||||||
self.current_clip: Optional[Clip] = 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(
|
def start_session(
|
||||||
self,
|
self,
|
||||||
@@ -144,7 +178,12 @@ class SessionManager:
|
|||||||
project: str,
|
project: str,
|
||||||
session_type: SessionType = SessionType.DESIGN,
|
session_type: SessionType = SessionType.DESIGN,
|
||||||
) -> Session:
|
) -> 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_id = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
session = Session(
|
session = Session(
|
||||||
@@ -155,8 +194,9 @@ class SessionManager:
|
|||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create session directory
|
# Create session directory in project's _capture folder
|
||||||
session_dir = self.sessions_dir / session_id
|
capture_dir = self._current_project_path / "_capture"
|
||||||
|
session_dir = capture_dir / session_id
|
||||||
session_dir.mkdir(parents=True, exist_ok=True)
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(session_dir / "clips").mkdir(exist_ok=True)
|
(session_dir / "clips").mkdir(exist_ok=True)
|
||||||
|
|
||||||
@@ -299,15 +339,26 @@ class SessionManager:
|
|||||||
return Session.from_dict(json.load(f))
|
return Session.from_dict(json.load(f))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_sessions(self) -> List[Session]:
|
def list_sessions(self, project: Optional[str] = None) -> List[Session]:
|
||||||
"""List all sessions."""
|
"""List sessions, optionally filtered by project."""
|
||||||
sessions = []
|
sessions = []
|
||||||
for session_dir in sorted(self.sessions_dir.iterdir(), reverse=True):
|
|
||||||
|
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():
|
if session_dir.is_dir():
|
||||||
session_file = session_dir / "session.json"
|
session_file = session_dir / "session.json"
|
||||||
if session_file.exists():
|
if session_file.exists():
|
||||||
with open(session_file) as f:
|
with open(session_file) as f:
|
||||||
sessions.append(Session.from_dict(json.load(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
|
return sessions
|
||||||
|
|
||||||
def get_session_dir(self, session_id: str) -> Path:
|
def get_session_dir(self, session_id: str) -> Path:
|
||||||
|
|||||||
Reference in New Issue
Block a user