
Pair Programmer
- 93 installs
- 128 repo stars
- Updated April 16, 2026
- video-db/claude-code
Helps with ai & agent building tasks.
About
pair-programmer is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- pair-programmer
- AI & Agent Building
- AI-coding skill
Pair Programmer by the numbers
- 93 all-time installs (skills.sh)
- Ranked #4,706 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/video-db/claude-code --skill pair-programmerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 93 |
|---|---|
| repo stars | ★ 128 |
| Last updated | April 16, 2026 |
| Repository | video-db/claude-code ↗ |
What it does
Helps with ai & agent building tasks.
Files
VideoDB Pair Programmer
AI pair programming with real-time screen and audio context. Record your screen and audio, with AI-powered indexing that logs visual and audio events in real-time.
Commands
When user asks for a command, read the corresponding file for instructions:
| Command | Description | Reference |
|---|---|---|
/pair-programmer record | Start screen/audio recording | See commands/record.md |
/pair-programmer stop | Stop the running recording | See commands/stop.md |
/pair-programmer search | Search recording context (screen, mic, audio) | See commands/search.md |
/pair-programmer act | Act on a spoken instruction from the mic | See commands/act.md |
/pair-programmer what-happened | Summarize recent activity | See commands/what-happened.md |
/pair-programmer setup | Install deps and configure API key | See commands/setup.md |
/pair-programmer config | Change indexing and other settings | See commands/config.md |
How It Works
1. User runs /pair-programmer setup to install dependencies and set VIDEO_DB_API_KEY environment variable 2. User runs /pair-programmer record to start recording 3. A picker UI appears to select screen and audio sources 4. Recording starts and events are logged to /tmp/videodb_pp_events.jsonl 5. User can stop recording from the tray icon (🔴 PP → Stop Recording)
Output Files
| Path | Content |
|---|---|
/tmp/videodb_pp_pid | Process ID of the recorder |
/tmp/videodb_pp_events.jsonl | All WebSocket events (JSONL format) |
/tmp/videodb_pp_info.json | Current session info (session_id, rtstream_ids) |
Event File Format
Events are written as JSONL (one JSON object per line):
{"ts": "2026-03-05T10:15:30.123Z", "unix_ts": 1709374530.12, "channel": "visual_index", "data": {"text": "User is viewing VS Code with auth.ts open"}}
{"ts": "2026-03-05T10:15:31.456Z", "unix_ts": 1709374531.45, "channel": "transcript", "data": {"text": "Let me check the login flow", "is_final": true}}Environment Variables
The recorder reads these from environment variables:
| Variable | Required | Description |
|---|---|---|
VIDEO_DB_API_KEY | Yes | VideoDB API key |
VIDEO_DB_BASE_URL | No | API endpoint (default: https://api.videodb.io) |
Reading Context
Events are in /tmp/videodb_pp_events.jsonl. Use CLI tools to filter — never read the whole file.
| Channel | Content | Density |
|---|---|---|
visual_index | Screen descriptions | Dense (~1 every 2s) |
transcript | Mic speech | Sparse (sentences) |
audio_index | System audio summaries | Sparse (sentences) |
Channel filter — use grep to filter by channel, pipe to tail for recent events:
grep '"channel":"visual_index"' /tmp/videodb_pp_events.jsonl | tail -10Keyword search — grep across all channels:
grep -i 'keyword' /tmp/videodb_pp_events.jsonlTime-window filter — filter events from the last N minutes: 1. Get current epoch: $(date +%s) 2. Compute cutoff: epoch - N*60 3. Filter lines where the unix_ts JSON field exceeds the cutoff 4. Pipe through grep to narrow by channel
Generate the appropriate filtering command (grep, awk, python3, jq) based on complexity.
For semantic search across indexed content, use search-rtstream.js:
node search-rtstream.js --query="your query" --cwd=<PROJECT_ROOT><PROJECT_ROOT> is the absolute path to the user's project directory. This is NOT the skill directory — resolve it before running the command.See commands/search.md for the full search strategy and CLI patterns.
Find the user's most recent spoken instruction from the mic transcript and execute it.
Events File
All recording events are in /tmp/videodb_pp_events.jsonl (one JSON per line). Each line has:
{"ts": "2026-03-05T10:15:30.123Z", "unix_ts": 1709374530.12, "channel": "transcript", "data": {"text": "..."}}Flow
1. Pre-flight
Check that a recording session exists:
cat /tmp/videodb_pp_info.jsonIf file doesn't exist or is empty, tell the user no recording session is available.
2. Extract recent spoken instructions
Read the last 3 minutes of mic transcript:
grep '"channel":"transcript"' /tmp/videodb_pp_events.jsonl | tail -30For more precise time filtering, filter lines from /tmp/videodb_pp_events.jsonl where unix_ts > (current_epoch - 180) and channel is "transcript". Get current epoch with $(date +%s).
3. Identify the actionable instruction
From the transcript, identify the most recent statement that is an instruction or request — something the user wants done. Look for:
- Direct commands: "fix the bug in...", "add a test for...", "refactor this to..."
- Requests: "can you update...", "I need you to...", "let's change..."
- Implicit tasks: "this function should handle errors", "we need validation here"
Ignore conversational speech, thinking out loud, or commentary that isn't a task.
If the user passed a query with the command (e.g. /pair-programmer act "the thing I said about auth"), use that as a hint to find the specific instruction — search the transcript for that topic instead of just taking the most recent one.
4. Gather screen context
Get the visual context around the time of the spoken instruction to understand what the user was looking at:
grep '"channel":"visual_index"' /tmp/videodb_pp_events.jsonl | tail -10If the instruction referenced specific files, code, or UI elements, use the screen context to resolve what they meant.
5. Confirm and execute
1. State what you understood — tell the user: "You said: [instruction]. I'll now [action]." 2. Execute the instruction — use all available tools (Read, Write, Edit, Bash, Task) to carry out the task. This is a real action, not a search result. 3. If the instruction is ambiguous or could be interpreted multiple ways, ask the user to clarify before acting.
6. Edge cases
- If no actionable instruction is found in recent transcript, tell the user: "I didn't find a recent spoken instruction. Try saying what you'd like me to do, then run this command again."
- If multiple instructions are found, act on the most recent one unless the user specified a topic.
- If the instruction requires context you don't have (e.g. references a file you can't find), ask for clarification.
Change VideoDB Pair Programmer settings.
Steps
1. Check Current Configuration
Read the config file at pp.config.json in the user's current working directory (project root) using the file read tool.
Decision:
- File doesn't exist → Create it with defaults (see schema below) and continue to Step 3
- File exists → User may want to change something. Read up instructions at Configuration Schema
- File exists and user has nothing to change → Go to Step 3
2. Save Configuration
Create or update pp.config.json in the user's current working directory (project root). Use defaults for any fields not explicitly changed (see schema below).
3. Confirm
Say settings have been saved.
---
Configuration Schema
When the user wants to change settings, offer a single choice:
1. "Go through each setting" — For users who want to see every option and possibly change several. Ask one setting at a time: state what the option does and its current/default value, then ask for a new value or "keep current". Move to the next only after they answer. For indexing, go into VideoDB Indexing Configuration and walk through visual → system_audio → mic (and their sub-fields) in order.
2. "Change a specific setting" — Use the organised MCQ hierarchy below. After each change, return to the same level (top-level MCQ, or indexing sub-MCQ, or the chosen index's sub-field list) so the user can change another or go back.
---
MCQ hierarchy (keep it organised)
Always show all options in every MCQ — list every option below; do not omit, collapse, or limit the number of choices.
Level 1 — Top-level settings
Show one MCQ: "Which setting would you like to change?" with all of these options (each with short description and current value in parentheses):
1. `videodb_backend_url` — API endpoint. (current: e.g. https://api.videodb.io) 2. VideoDB Indexing Configuration — Screen, system audio, and mic indexing (prompts, batching). → goes to Level 2 3. Done — Finish and save.
If they pick 1: show what it does, current value, ask for new value or "keep current". Then show Level 1 MCQ again with all 3 options.
If they pick 2: go to Level 2. If they pick 3: save config and confirm.
---
Level 2 — VideoDB Indexing Configuration
Show one MCQ: "Which indexing would you like to change?" with all of:
1. Visual index (visual_index) — Screen / display indexing. → goes to Level 3 (Visual) 2. System audio index (system_audio_index) — System audio indexing. → goes to Level 3 (System audio) 3. Mic index (mic_index) — Microphone indexing. → goes to Level 3 (Mic) 4. Back to main settings → show Level 1 MCQ again.
If they pick 1: go to Level 3 — Visual index. If 2: Level 3 — System audio index. If 3: Level 3 — Mic index. If 4: Level 1 (show all Level 1 options again).
---
Level 3 — Index sub-fields (one menu per index type)
Visual index — "Which visual_index setting?" Show all options: enabled (on/off), prompt (AI prompt for screen), batch_time (seconds between captures), frame_count (frames per batch). For the chosen one: show what it does, current value, ask for new value or "keep current". Then show this same Level 3 (Visual) MCQ again, plus option "Back to Indexing" → Level 2.
System audio index — "Which system_audio_index setting?" Show all options: enabled, prompt, batch_type (sentence | time), batch_value (number). Same flow: show description + current → get new value → show this MCQ again + "Back to Indexing" → Level 2.
Mic index — "Which mic_index setting?" Show all options: enabled, prompt, batch_type, batch_value. Same flow: show description + current → get new value → show this MCQ again + "Back to Indexing" → Level 2.
---
Use the schema below for field names, descriptions, and defaults when explaining or writing the file.
Location: <project_root>/pp.config.json
{
"videodb_backend_url": "https://api.videodb.io",
"visual_index": { "enabled": true, "prompt": "Describe what is visible on the screen, focusing on the main application, content being viewed, and any notable UI elements.", "batch_time": 2, "frame_count": 3, "model_name": "mini" },
"system_audio_index": { "enabled": true, "prompt": "Summarize what is being said in the audio.", "batch_type": "sentence", "batch_value": 3, "model_name": "mini" },
"mic_index": { "enabled": true, "prompt": "Transcribe the user's speech.", "batch_type": "sentence", "batch_value": 3, "model_name": "mini" }
}| Field | Description | Default |
|---|---|---|
videodb_backend_url | API endpoint | https://api.videodb.io |
Indexing sub-fields (for Level 3 MCQ):
| Parent | Field | Description | Default |
|---|---|---|---|
| visual_index | enabled | On/off for screen indexing | true |
| visual_index | prompt | AI prompt for screen content | "Describe what is visible on the screen, focusing on the main application, content being viewed, and any notable UI elements." |
| visual_index | batch_time | Seconds between screen captures | 2 |
| visual_index | frame_count | Frames per batch | 3 |
| visual_index | model_name | AI model to use | mini |
| system_audio_index | enabled | On/off for system audio | true |
| system_audio_index | prompt | AI prompt for system audio | "Summarize what is being said in the audio." |
| system_audio_index | batch_type | "sentence" or "time" | sentence |
| system_audio_index | batch_value | Sentences or seconds per batch | 3 |
| system_audio_index | model_name | AI model to use | mini |
| mic_index | enabled | On/off for mic | true |
| mic_index | prompt | AI prompt for mic | "Transcribe the user's speech." |
| mic_index | batch_type | "sentence" or "time" | sentence |
| mic_index | batch_value | Sentences or seconds per batch | 3 |
| mic_index | model_name | AI model to use | mini |
Indexing can also be overridden at runtime via /pair-programmer:record.
Start the VideoDB Pair Programmer recorder.
Flow
1. Check if already running
1. Read the PID file at /tmp/videodb_pp_pid 2. If the file exists, check if the process with that PID is still running 3. If running → Tell the user the recorder is already active. They can stop it from the tray icon (🔴 PP in menu bar) 4. If not running → Continue to step 2
2. Verify VideoDB connection
Run the verification script from the skill directory, passing the user's project root via --cwd:
node verify-connection.js --cwd=<PROJECT_ROOT><PROJECT_ROOT> is the absolute path to the user's project directory. This is NOT the skill directory — resolve it before running the command.- If successful (exit code 0) → Continue to step 3
- If
VIDEO_DB_API_KEY not set(exit code 1) → Tell user to eitherexport VIDEO_DB_API_KEY=your_keyor add it to a.envfile in their project root - If connection error (exit code 2) → Check if the API key is valid
3. Start the recorder
Run the Electron app as a background process from the skill directory:
nohup npx electron recorder-app.js --cwd=<PROJECT_ROOT> > /tmp/videodb_pp_logs 2>&1 &<PROJECT_ROOT> is the absolute path to the user's project directory. This is NOT the skill directory — resolve it before running the command.4. Confirm
Tell the user:
- The recorder is starting
- A picker UI will appear to select screen/audio sources
- After selection, recording will start automatically
- Events are logged to
/tmp/videodb_pp_events.jsonl - Stop recording from the tray icon (🔴 PP → Stop Recording) or the overlay widget
Search the pair programmer's recording context to answer questions about what the user has been doing, saying, or seeing.
Events File
All recording events are in /tmp/videodb_pp_events.jsonl (one JSON per line). Each line has:
{"ts": "2026-03-05T10:15:30.123Z", "unix_ts": 1709374530.12, "channel": "visual_index", "data": {"text": "..."}}| Channel | Content | Density |
|---|---|---|
visual_index | Screen descriptions from VLM | Dense (~1 every 2s) |
transcript | Mic speech-to-text | Sparse (~sentences) |
audio_index | System audio summaries | Sparse (~sentences) |
capture_session | Lifecycle events (ignore for search) | Rare |
Session info with RTStream IDs is at /tmp/videodb_pp_info.json.
Flow
1. Pre-flight
Check that a recording session exists:
cat /tmp/videodb_pp_info.jsonIf file doesn't exist or is empty, tell the user no recording session is available.
2. Classify the query and pick execution mode
Based on the user's question, pick strategy AND execution mode:
| Query type | Example | Strategy | Execution |
|---|---|---|---|
| Recent context | "what's on my screen?" | Tail last N events | Direct — trivial, small output |
| Keyword / topic | "find the error" | Grep keyword | Direct — fast, small output |
| Cross-channel | "what was on screen when I said X?" | Two-step grep | Direct — sequential dependency |
| User intent | "what did I ask?" | Read last 5 min transcript | Subagent — reads events, returns intent summary |
| Time-bounded (> 5 min) | "what was I doing in the last 30 min?" | Time filter per channel | Parallel subagents — one per channel |
| Broad semantic | "when was I debugging performance?" | Local grep + remote search | Parallel subagents — local + remote simultaneously |
| Full context scan | "summarize everything about auth" | All channels + remote | Parallel subagents — one per channel + one remote |
3a. Direct execution (simple queries)
For recent context, keyword grep, and cross-channel correlation — run CLI commands directly. These are fast and produce small output.
Filter by channel:
grep '"channel":"transcript"' /tmp/videodb_pp_events.jsonl | tail -20Last N events of a specific channel:
grep '"channel":"visual_index"' /tmp/videodb_pp_events.jsonl | tail -10Keyword search across all channels (excluding lifecycle events):
grep -v '"channel":"capture_session"' /tmp/videodb_pp_events.jsonl | grep -i 'auth'Time-window filter (last N minutes):
Filter lines from /tmp/videodb_pp_events.jsonl where the unix_ts JSON field > (current_epoch - N*60). Get current epoch with $(date +%s). Pipe through grep to narrow by channel. Generate the appropriate filtering command — use grep, awk, python3, jq, or other tools based on what's available.
Cross-channel correlation:
1. Find the event in one channel:
grep '"channel":"transcript"' /tmp/videodb_pp_events.jsonl | grep -i 'broken'2. Extract the unix_ts from the matching line(s). 3. Filter the other channel for events within +/- 15 seconds of that timestamp. Use the unix_ts value and generate a command that filters for unix_ts between (ts - 15) and (ts + 15), then grep by channel.
3b. Subagent execution (complex queries)
For queries that read a lot of events or need parallel search across channels, spawn subagents using the Task tool. Each subagent runs in its own context window, reads/filters events, and returns only a concise summary. This keeps the main conversation clean.
When to use subagents:
- The query touches multiple channels and you want results in parallel
- The time window is large (> 5 minutes) and will produce many events
- You need both local and remote search simultaneously
- The query asks for user intent (mic analysis) which requires reading + interpreting
How to spawn search subagents:
Launch subagents in the same message (parallel execution). Give each a focused task with the query and the CLI patterns to use.
Example: full context scan for "auth discussion"
Spawn 3 subagents in parallel:
1. Screen search subagent:
"Search /tmp/videodb_pp_events.jsonl for visual_index events related to 'auth'. Run: grep '"channel":"visual_index"' /tmp/videodb_pp_events.jsonl | grep -i 'auth'. Read the output. Return a summary of what was on screen related to auth, with timestamps."2. Mic search subagent:
"Search /tmp/videodb_pp_events.jsonl for transcript events related to 'auth'. Usegrep '"channel":"transcript"' /tmp/videodb_pp_events.jsonl | grep -i 'auth'for keyword matches. Also read the last 5 minutes of all transcript events for broader context — filter for lines whereunix_ts> (current_epoch - 300) and channel is 'transcript'. Return a summary of what the user said about auth, with timestamps."
3. Remote semantic search subagent:
"Run semantic search for 'auth discussion' using: node search-rtstream.js --query='auth discussion' --cwd=<PROJECT_ROOT>. Read the JSON output and return a summary of the top results with timestamps and relevance scores."Example: user intent query "what did I ask?"
Spawn 1 subagent:
"Read the last 5 minutes of mic transcript from /tmp/videodb_pp_events.jsonl. Filter for lines whereunix_ts> (current_epoch - 300) and channel is 'transcript'. Current epoch:$(date +%s). Analyze the transcript and identify: what questions or requests the user made, their intent, and any specific asks. Return a structured summary."
Example: time-bounded multi-channel ("last 30 minutes")
Spawn 2-3 subagents in parallel (one per active channel):
1. Screen subagent:
"Read the last 30 minutes of visual_index events from /tmp/videodb_pp_events.jsonl. Filter for lines whereunix_ts> (current_epoch - 1800) and channel is 'visual_index'. Current epoch:$(date +%s). Screen events are dense — if output is large, sample every 5th line. Return a timeline of what was on screen."
2. Mic subagent:
"Read the last 30 minutes of transcript events from /tmp/videodb_pp_events.jsonl. Filter for lines whereunix_ts> (current_epoch - 1800) and channel is 'transcript'. Current epoch:$(date +%s). Return a summary of what the user said, with timestamps."
3. System audio subagent (if active):
"Read the last 30 minutes of audio_index events from /tmp/videodb_pp_events.jsonl. Filter for lines whereunix_ts> (current_epoch - 1800) and channel is 'audio_index'. Current epoch:$(date +%s). Return a summary of system audio, with timestamps."
Rules for subagent prompts:
- Describe what data to extract: channel, time range, keywords — let the subagent generate the appropriate command
- Use
grepexamples for channel/keyword filtering (portable and simple) - For time-based filtering, describe the logic (field, cutoff, epoch) and let the subagent pick the right tool (grep, awk, python3, jq)
- Always include the user's original search query for context
- Always ask the subagent to return a summary with timestamps, not raw output
- For screen events, tell the subagent to sample if output is large (> 50 lines)
- For remote search, include the
--cwdpath
4. Remote search details
For the remote semantic search subagent or direct remote search, use search-rtstream.js from the skill directory:
node search-rtstream.js --query="your search query" --cwd=<PROJECT_ROOT>To search a specific RTStream (get IDs from /tmp/videodb_pp_info.json):
node search-rtstream.js --query="your query" --cwd=<PROJECT_ROOT> --rtstream=rts-xxx<PROJECT_ROOT> is the absolute path to the user's project directory. This is NOT the skill directory — resolve it before running the command.Output is a JSON array of matches with text, start, end, rtstream_name, and score.
Use remote search when:
- Local grep returned no results but the query seems valid
- The query is semantically broad with no specific keyword to grep
- The user explicitly asks to search the full session history
5. Synthesize
Combine results from direct execution and/or subagent responses:
- Include timestamps for context
- Summarize findings, don't dump raw data
- If results come from multiple channels, organize by timeline
- Correlate findings across channels (e.g. "at 10:15 you were looking at auth.ts and said 'this needs fixing'")
- If no results found locally or remotely, say so clearly
Set up the VideoDB Pair Programmer.
Steps
1. Install Dependencies
In the skill directory (the directory containing SKILL.md), install these npm packages:
electronvideodbdotenv
2. Configure API Key
The user must set VIDEO_DB_API_KEY either by:
export VIDEO_DB_API_KEY=your-keyin their shell, OR- Adding
VIDEO_DB_API_KEY=your-keyto a.envfile in their project root
Get a free API key at https://console.videodb.io ($20 free credits, no credit card).
Do NOT read, write, or handle the API key yourself. Always let the user set it.
3. Confirm
Tell the user:
- Setup is complete
- Run
/pair-programmer recordto start recording
Stop the VideoDB Pair Programmer recorder.
Flow
1. Check if running
Read the PID file at /tmp/videodb_pp_pid.
- If the file doesn't exist → Tell the user no recorder is currently running.
- If the file exists, check if the process with that PID is still running.
- If the process is not running → Clean up the stale PID file and tell the user.
- If running → Continue to step 2.
2. Stop the recorder
Send SIGTERM to the process ID from the PID file. This triggers the recorder's graceful shutdown which stops the capture session, waits for the export event, cleans up WebSocket connections, and removes the PID file.
3. Verify
Wait a few seconds, then check if the process has exited by checking if the PID is still running.
- If stopped → Tell the user the recording has been stopped.
- If still running → Send
SIGKILLas a fallback and inform the user.
Summarize what the user has been doing recently based on recording context.
Flow
1. Check recording is active
cat /tmp/videodb_pp_info.jsonIf the file doesn't exist or is empty, tell the user no recording session is available.
2. Read recent context from each channel
Read the last 10 minutes of events, filtered by channel. Mic and system audio are sparse and safe to read broadly. Screen is dense — limit to the last 15 lines.
Screen (last 15 visual events):
grep '"channel":"visual_index"' /tmp/videodb_pp_events.jsonl | tail -15Mic transcript (last 10 minutes):
Filter /tmp/videodb_pp_events.jsonl for lines where unix_ts > (current_epoch - 600) and channel is "transcript". Get current epoch with $(date +%s). Use grep for channel filtering; for time filtering, generate the appropriate command (grep, awk, python3, jq).
System audio (last 10 minutes):
Same time filter as above, but with channel "audio_index".
3. Analyze and provide
From the collected events, synthesize:
- Timeline — what happened in order, using
tstimestamps - Key actions — important things the user did (files opened, code written, commands run)
- Current state — what's on screen right now (from the most recent visual events)
- Notable items — errors, decisions, important details from any channel
4. Keep it concise
Present a brief, actionable summary. Don't dump raw events. Organize by timeline and highlight what matters.
{
"name": "pair-programmer",
"version": "1.0.0",
"main": "recorder-app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"dotenv": "^16.6.1",
"electron": "^40.8.3",
"videodb": "^0.2.2"
}
}
#!/usr/bin/env electron
/**
* VideoDB Pair Programmer - Simplified Recorder
*
* Single sequential script that:
* 1. Connects to VideoDB, starts WebSocket
* 2. Shows picker UI for channel selection
* 3. Starts capture recording
* 4. Logs all events to /tmp/videodb_pp_events.jsonl
* 5. Shows tray icon with Stop/Quit buttons
*/
const path = require("path");
const fs = require("fs");
const { app, Notification, BrowserWindow, screen, ipcMain, Tray, Menu, nativeImage } = require("electron");
// Reduce Electron memory footprint
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("disable-software-rasterizer");
app.commandLine.appendSwitch("disable-dev-shm-usage");
app.commandLine.appendSwitch("js-flags", "--max-old-space-size=64");
const dotenv = require("dotenv");
const { connect } = require("videodb");
const { CaptureClient } = require("videodb/capture");
// Load .env and pp.config.json from user's project directory if --cwd flag is provided
const cwdArg = process.argv.find(a => a.startsWith("--cwd="));
const userCwd = cwdArg ? cwdArg.split("=")[1] : null;
if (userCwd) {
dotenv.config({ path: path.join(userCwd, ".env") });
}
function loadProjectConfig() {
const configPath = userCwd ? path.join(userCwd, "pp.config.json") : null;
if (!configPath) return {};
try {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
} catch (_) {
return {};
}
}
const PROJECT_CONFIG = loadProjectConfig();
// =============================================================================
// Configuration
// =============================================================================
const API_KEY = process.env.VIDEO_DB_API_KEY;
const BASE_URL = PROJECT_CONFIG.videodb_backend_url || process.env.VIDEO_DB_BASE_URL || "https://api.videodb.io";
const PID_FILE = "/tmp/videodb_pp_pid";
const EVENTS_FILE = "/tmp/videodb_pp_events.jsonl";
const INFO_FILE = "/tmp/videodb_pp_info.json";
const UI_DIR = path.join(__dirname, "ui");
const DEFAULT_INDEXING_CONFIG = {
visual: {
enabled: true,
prompt: "Describe what is visible on the screen, focusing on the main application, content being viewed, and any notable UI elements.",
batch_time: 2,
frame_count: 3,
model_name: "mini",
},
system_audio: {
enabled: true,
prompt: "Summarize what is being said in the audio.",
batch_type: "sentence",
batch_value: 3,
model_name: "mini",
},
mic: {
enabled: true,
prompt: "Transcribe the user's speech.",
batch_type: "sentence",
batch_value: 3,
model_name: "mini",
},
};
function mergeIndexingConfig(defaults, overrides) {
if (!overrides) return defaults;
const result = {};
for (const key of Object.keys(defaults)) {
const configKey = key === "visual" ? "visual_index" : key === "system_audio" ? "system_audio_index" : "mic_index";
result[key] = overrides[configKey]
? { ...defaults[key], ...overrides[configKey] }
: defaults[key];
}
return result;
}
const INDEXING_CONFIG = mergeIndexingConfig(DEFAULT_INDEXING_CONFIG, PROJECT_CONFIG);
// =============================================================================
// State
// =============================================================================
let conn = null;
let wsConnection = null;
let captureSession = null;
let captureClient = null;
let tray = null;
let pickerWindow = null;
let widgetWindow = null;
let lastPickerConfig = null;
// =============================================================================
// File Helpers
// =============================================================================
function writePidFile() {
fs.writeFileSync(PID_FILE, String(process.pid));
console.log(`✓ PID file written: ${PID_FILE}`);
}
function removePidFile() {
try {
fs.unlinkSync(PID_FILE);
} catch (_) {}
}
function appendEvent(event) {
const ts = new Date().toISOString();
const unix_ts = Date.now() / 1000;
const line = JSON.stringify({ ts, unix_ts, ...event }) + "\n";
fs.appendFileSync(EVENTS_FILE, line);
}
function writeSessionInfo(info) {
fs.writeFileSync(INFO_FILE, JSON.stringify(info, null, 2));
console.log(`✓ Session info written: ${INFO_FILE}`);
}
function clearEventsFile() {
try {
fs.writeFileSync(EVENTS_FILE, "");
} catch (_) {}
}
// =============================================================================
// Utility Functions (inlined from utils.js)
// =============================================================================
function enrichChannelsWithDisplayInfo(videoChannels) {
const electronDisplays = screen.getAllDisplays();
const normalized = (s) => String(s || "").trim().toLowerCase();
return videoChannels.map((ch, index) => {
let match = electronDisplays.find(
(d) => normalized(d.label) === normalized(ch.name)
);
if (!match && videoChannels.length === electronDisplays.length) {
match = electronDisplays[index];
}
return {
channelId: ch.id,
label: ch.name || `Display ${index + 1}`,
width: match ? match.size.width : null,
height: match ? match.size.height : null,
electronId: match ? match.id : null,
};
});
}
function buildChannelsFromPicker(pickerResult) {
const channels = [];
if (pickerResult.mic) {
channels.push({ channelId: "mic:default", type: "audio", record: true, store: true });
}
if (pickerResult.systemAudio) {
channels.push({ channelId: "system_audio:default", type: "audio", record: true, store: true });
}
channels.push({ channelId: pickerResult.displayChannelId, type: "video", record: true, store: true });
return channels;
}
// =============================================================================
// Tray (simple - just Stop/Quit while recording)
// =============================================================================
function createTray() {
const emptyIcon = nativeImage.createEmpty();
tray = new Tray(emptyIcon);
tray.setTitle(" 🔴 PP");
tray.setToolTip("VideoDB Pair Programmer - Recording");
updateTrayMenu();
tray.on("click", () => tray.popUpContextMenu());
}
function updateTrayMenu() {
if (!tray) return;
const isOverlayVisible = widgetWindow && !widgetWindow.isDestroyed() && widgetWindow.isVisible();
const menu = Menu.buildFromTemplate([
{ label: "🔴 Recording", enabled: false },
{ type: "separator" },
{
label: isOverlayVisible ? "Hide Overlay" : "Show Overlay",
click: () => toggleOverlay()
},
{ type: "separator" },
{ label: "Stop Recording", click: () => stopAndExit() },
{ type: "separator" },
{ label: "Quit", click: () => exitGracefully("Quit from tray") },
]);
tray.setContextMenu(menu);
}
function toggleOverlay() {
if (widgetWindow && !widgetWindow.isDestroyed()) {
if (widgetWindow.isVisible()) {
widgetWindow.hide();
} else {
widgetWindow.show();
}
} else if (lastPickerConfig) {
createWidget(lastPickerConfig);
}
updateTrayMenu();
}
// =============================================================================
// Picker UI
// =============================================================================
function showPicker(videoChannels = []) {
return new Promise((resolve) => {
if (pickerWindow) {
pickerWindow.focus();
return resolve(null);
}
const displays = enrichChannelsWithDisplayInfo(videoChannels);
pickerWindow = new BrowserWindow({
width: 420,
height: 520,
resizable: false,
minimizable: false,
maximizable: false,
alwaysOnTop: true,
frame: false,
transparent: false,
backgroundColor: "#1c1c1e",
show: false,
skipTaskbar: false,
focusable: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
pickerWindow.loadFile(path.join(UI_DIR, "picker.html"));
pickerWindow.webContents.on("did-finish-load", () => {
pickerWindow.webContents.send("displays", displays);
pickerWindow.show();
pickerWindow.focus();
if (process.platform === "darwin") {
app.focus({ steal: true });
}
});
ipcMain.once("picker-result", (event, result) => {
if (pickerWindow) {
pickerWindow.close();
pickerWindow = null;
}
resolve(result);
});
pickerWindow.on("closed", () => {
pickerWindow = null;
resolve(null);
});
});
}
// =============================================================================
// Widget (floating overlay showing recording status)
// =============================================================================
function createWidget(pickerConfig = null) {
if (widgetWindow) return;
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenW, height: screenH } = primaryDisplay.workAreaSize;
widgetWindow = new BrowserWindow({
width: 160,
height: 150,
x: screenW - 180,
y: screenH - 200,
frame: false,
transparent: true,
hasShadow: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
minimizable: false,
maximizable: false,
closable: true,
focusable: false,
visibleOnAllWorkspaces: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
widgetWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
widgetWindow.loadFile(path.join(UI_DIR, "widget.html"));
widgetWindow.webContents.on("did-finish-load", () => {
if (pickerConfig) {
widgetWindow.webContents.send("widget-config", pickerConfig);
}
});
ipcMain.on("widget-stop", () => {
stopAndExit();
});
ipcMain.on("widget-close", () => {
if (widgetWindow && !widgetWindow.isDestroyed()) {
widgetWindow.hide();
updateTrayMenu();
}
});
ipcMain.on("widget-resize", (_, size) => {
if (widgetWindow && size.width && size.height) {
widgetWindow.setSize(size.width, size.height);
}
});
widgetWindow.on("closed", () => {
widgetWindow = null;
});
console.log("✓ Widget created");
}
function updateWidgetState(state) {
if (widgetWindow && !widgetWindow.isDestroyed()) {
widgetWindow.webContents.send("widget-state", state);
}
}
function updateWidgetConfig(config) {
if (widgetWindow && !widgetWindow.isDestroyed()) {
widgetWindow.webContents.send("widget-config", config);
}
}
// =============================================================================
// VideoDB Integration
// =============================================================================
async function initializeVideoDB() {
if (!API_KEY) {
throw new Error("VIDEO_DB_API_KEY environment variable not set");
}
conn = connect({ apiKey: API_KEY, baseUrl: BASE_URL });
console.log("✓ Connected to VideoDB");
return true;
}
async function setupWebSocket() {
wsConnection = await conn.connectWebsocket();
await wsConnection.connect();
console.log(`✓ WebSocket connected: ${wsConnection.connectionId}`);
// Start listening for events in background
listenToWebSocketEvents();
}
async function createSession() {
const sessionConfig = {
endUserId: "pair_programmer_user",
metadata: { app: "pair-programmer" },
wsConnectionId: wsConnection.connectionId,
};
captureSession = await conn.createCaptureSession(sessionConfig);
const token = await conn.generateClientToken(3600);
captureClient = new CaptureClient({ sessionToken: token, apiUrl: BASE_URL });
console.log(`✓ Session created: ${captureSession.id}`);
return { sessionId: captureSession.id, token };
}
async function requestPermissions() {
if (!captureClient) return;
try {
const { systemPreferences } = require("electron");
const hasScreen = systemPreferences.getMediaAccessStatus("screen") === "granted";
const hasMic = systemPreferences.getMediaAccessStatus("microphone") === "granted";
if (hasScreen && hasMic) {
console.log("✓ Permissions already granted");
return;
}
if (!hasScreen) await captureClient.requestPermission("screen-capture");
if (!hasMic) await captureClient.requestPermission("microphone");
console.log("✓ Permissions requested");
} catch (e) {
console.warn("Permission request failed:", e.message);
}
}
async function startRecording(channels) {
const capturePayload = {
sessionId: captureSession.id,
channels,
};
console.log("Starting capture with channels:", channels.map(c => c.channelId).join(", "));
await captureClient.startSession(capturePayload);
}
async function startIndexingForRTStreams(rtstreams) {
if (!rtstreams || rtstreams.length === 0) {
console.error("[Indexing] No RTStreams provided!");
return;
}
const coll = await conn.getCollection();
for (const stream of rtstreams) {
const rtstream_id = stream.rtstream_id || stream.id;
const name = stream.name || stream.channel_id || "";
const mediaTypes = stream.media_types || [];
if (!rtstream_id) continue;
try {
const rtstream = await coll.getRTStream(rtstream_id);
if (mediaTypes.includes("video")) {
if (!INDEXING_CONFIG.visual.enabled) continue;
const visualOpts = {
prompt: INDEXING_CONFIG.visual.prompt,
batchConfig: {
type: "time",
value: INDEXING_CONFIG.visual.batch_time,
frameCount: INDEXING_CONFIG.visual.frame_count
},
modelName: INDEXING_CONFIG.visual.model_name,
socketId: wsConnection.connectionId,
};
const sceneIndex = await rtstream.indexVisuals(visualOpts);
if (sceneIndex) {
console.log(`✓ Visual index created for ${name}`);
}
} else if (mediaTypes.includes("audio")) {
const isMic = name.toLowerCase().includes("mic");
const config = isMic ? INDEXING_CONFIG.mic : INDEXING_CONFIG.system_audio;
if (!config.enabled) continue;
const audioOpts = {
prompt: config.prompt,
batchConfig: { type: config.batch_type, value: config.batch_value },
modelName: config.model_name,
socketId: wsConnection.connectionId,
};
const audioIndex = await rtstream.indexAudio(audioOpts);
if (audioIndex) {
console.log(`✓ Audio index created for ${name}`);
}
}
} catch (e) {
console.error(`[Indexing] Failed for ${rtstream_id}:`, e.message);
}
}
}
async function listenToWebSocketEvents() {
if (!wsConnection) return;
try {
for await (const ev of wsConnection.receive()) {
const channel = ev.channel;
if (!(channel === "transcript" && ev.data?.is_final === false)) {
appendEvent(ev);
}
if (channel === "capture_session") {
await handleCaptureSessionEvent(ev);
} else if (channel === "transcript") {
const text = ev.data?.text;
if (text) {
console.log(`[Transcript] ${text.substring(0, 80)}...`);
}
} else if (channel === "visual_index") {
const text = ev.data?.text;
if (text) {
console.log(`[Visual] ${text.substring(0, 80)}...`);
}
} else if (channel === "audio_index") {
const text = ev.data?.text;
if (text) {
console.log(`[Audio] ${text.substring(0, 80)}...`);
}
}
}
} catch (e) {
console.warn("[WS] Listener error:", e.message);
}
console.log("[WS] Connection closed");
wsConnection = null;
}
async function handleCaptureSessionEvent(ev) {
const eventType = ev.event || ev.type;
const sessionId = ev.capture_session_id || ev.session_id;
console.log(`[WS] Capture event: ${eventType}`);
if (eventType === "capture_session.starting") {
updateWidgetState({ state: "starting" });
} else if (eventType === "capture_session.created") {
updateWidgetState({ state: "started" });
} else if (eventType === "capture_session.active") {
console.log("[WS] Session is ACTIVE!");
// Update widget to active state with timer
updateWidgetState({ state: "active", startTime: Date.now() });
const data = ev.data || {};
const rtstreams = data.rtstreams || data.streams || data.channels || [];
// Write session info
writeSessionInfo({
session_id: sessionId,
rtstreams: rtstreams.map(r => ({
rtstream_id: r.rtstream_id,
name: r.name,
media_types: r.media_types,
})),
started_at: new Date().toISOString(),
});
// Start indexing
await startIndexingForRTStreams(rtstreams);
} else if (eventType === "capture_session.stopping") {
updateWidgetState({ state: "stopping" });
} else if (eventType === "capture_session.stopped") {
updateWidgetState({ state: "stopped" });
} else if (eventType === "capture_session.exported") {
const exportedId = ev.data?.exported_video_id;
const playerUrl = ev.data?.player_url;
console.log("[WS] Session exported", exportedId ? `video_id: ${exportedId}` : "");
new Notification({
title: "VideoDB Recording Complete",
body: playerUrl ? "Click to view recording" : "Recording saved",
}).show();
} else if (eventType === "capture_session.failed") {
updateWidgetState({ state: "failed" });
const err = ev.data?.error || ev.data || {};
console.error("[WS] Session failed:", err);
new Notification({
title: "VideoDB Recording Failed",
body: err.message || "Recording failed",
}).show();
}
}
// =============================================================================
// Lifecycle
// =============================================================================
async function stopAndExit() {
console.log("[Stop] Stopping recording...");
// Immediately update widget to show stopping_requested state
updateWidgetState({ state: "stopping_requested" });
if (captureClient) {
try {
await captureClient.stopSession();
console.log("[Stop] Capture session stopped");
} catch (e) {
console.error("[Stop] Error stopping session:", e.message);
}
}
// Wait a bit for export event before exiting
setTimeout(() => {
exitGracefully("Recording stopped");
}, 2000);
}
async function shutdownApp() {
console.log("[Shutdown] Starting cleanup...");
if (widgetWindow) {
try { widgetWindow.close(); } catch (_) {}
widgetWindow = null;
}
if (tray) {
try { tray.destroy(); } catch (_) {}
tray = null;
}
if (captureClient) {
try {
await captureClient.shutdown();
console.log("[Shutdown] CaptureClient shutdown");
} catch (_) {}
captureClient = null;
}
if (wsConnection) {
try { await wsConnection.close(); } catch (_) {}
wsConnection = null;
console.log("[Shutdown] WebSocket closed");
}
removePidFile();
console.log("[Shutdown] Cleanup complete");
}
let shutdownPromise = null;
function exitGracefully(source) {
console.log(`[Shutdown] ${source}`);
if (!shutdownPromise) {
const forceExit = setTimeout(() => {
console.error("[Shutdown] Force exit (timeout)");
process.exit(1);
}, 10000);
forceExit.unref();
shutdownPromise = shutdownApp();
}
shutdownPromise
.then(() => process.exit(0))
.catch(() => process.exit(1));
}
// =============================================================================
// Main Entry Point
// =============================================================================
app.whenReady().then(async () => {
try {
// Hide dock icon (menu bar app)
if (process.platform === "darwin") {
app.dock.hide();
}
// Step 1: Write PID file
writePidFile();
// Step 2: Create tray and show widget in starting state
createTray();
createWidget();
console.log("Starting VideoDB Pair Programmer...");
console.log("Config:", {
apiKey: API_KEY ? `${API_KEY.substring(0, 10)}...` : "NOT SET",
baseUrl: BASE_URL,
});
if (!API_KEY) {
new Notification({
title: "VideoDB Pair Programmer",
body: "VIDEO_DB_API_KEY environment variable not set. Run /pair-programmer setup first.",
}).show();
setTimeout(() => exitGracefully("No API key"), 3000);
return;
}
// Step 3: Connect to VideoDB
await initializeVideoDB();
// Step 4: Setup WebSocket
await setupWebSocket();
// Step 5: Create capture session
await createSession();
// Step 6: Request permissions
await requestPermissions();
// Step 7: List channels and show picker
const availableChannels = await captureClient.listChannels();
console.log("Available channels:", JSON.stringify(availableChannels, null, 2));
// Extract video channels - handle different API response structures
let videoChannels = [];
if (availableChannels.displays) {
if (typeof availableChannels.displays.all === "function") {
videoChannels = availableChannels.displays.all();
} else if (Array.isArray(availableChannels.displays)) {
videoChannels = availableChannels.displays;
}
} else if (availableChannels.video) {
videoChannels = Array.isArray(availableChannels.video) ? availableChannels.video : [];
}
// Update widget to ready state - picker is about to be shown
updateWidgetState({ state: "ready" });
console.log("Showing picker UI...");
const pickerResult = await showPicker(videoChannels);
if (!pickerResult) {
console.log("Picker cancelled");
exitGracefully("Picker cancelled");
return;
}
// Step 8: Build channels from picker result
const channels = buildChannelsFromPicker(pickerResult);
// Step 9: Clear events file and start recording
clearEventsFile();
await startRecording(channels);
// Step 10: Update widget with channel status
lastPickerConfig = pickerResult;
updateWidgetConfig(pickerResult);
updateTrayMenu();
console.log("✓ Recording started! Events logged to:", EVENTS_FILE);
} catch (error) {
console.error("Startup error:", error);
new Notification({
title: "VideoDB Recorder Error",
body: error.message,
}).show();
setTimeout(() => exitGracefully("Startup error"), 3000);
}
});
app.on("window-all-closed", () => {
// Don't quit - we're a tray app
});
app.on("before-quit", (e) => {
e.preventDefault();
exitGracefully("before-quit");
});
process.on("SIGINT", () => exitGracefully("Received SIGINT"));
process.on("SIGTERM", () => exitGracefully("Received SIGTERM"));
#!/usr/bin/env node
/**
* VideoDB RTStream Search
*
* Searches indexed RTStream content via VideoDB's semantic search.
* Reads session info from /tmp/videodb_pp_info.json for RTStream IDs.
*
* Usage:
* node search-rtstream.js --query="your search query" --cwd=/path/to/project [--rtstream=rts-xxx]
*
* Output: JSON array to stdout
* [{ "text": "...", "start": ..., "end": ..., "rtstream_id": "...", "rtstream_name": "...", "score": ... }]
*/
const path = require("path");
const fs = require("fs");
const dotenv = require("dotenv");
const { connect } = require("videodb");
const INFO_FILE = "/tmp/videodb_pp_info.json";
const args = {};
for (const arg of process.argv.slice(2)) {
const match = arg.match(/^--(\w+)=(.+)$/);
if (match) args[match[1]] = match[2];
}
if (!args.query) {
console.error("Usage: node search-rtstream.js --query=\"...\" --cwd=/path [--rtstream=rts-xxx]");
process.exit(1);
}
// Load .env and pp.config.json from user's project directory (same as recorder-app.js)
const userCwd = args.cwd || null;
if (userCwd) {
dotenv.config({ path: path.join(userCwd, ".env") });
}
function loadProjectConfig() {
const configPath = userCwd ? path.join(userCwd, "pp.config.json") : null;
if (!configPath) return {};
try {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
} catch (_) {
return {};
}
}
const PROJECT_CONFIG = loadProjectConfig();
const API_KEY = process.env.VIDEO_DB_API_KEY;
const BASE_URL = PROJECT_CONFIG.videodb_backend_url || process.env.VIDEO_DB_BASE_URL || "https://api.videodb.io";
if (!API_KEY) {
console.error("VIDEO_DB_API_KEY not set");
process.exit(1);
}
function loadSessionInfo() {
try {
return JSON.parse(fs.readFileSync(INFO_FILE, "utf-8"));
} catch (_) {
return null;
}
}
async function main() {
const sessionInfo = loadSessionInfo();
if (!sessionInfo) {
console.error("No session info found at " + INFO_FILE);
process.exit(1);
}
const conn = connect({ apiKey: API_KEY, baseUrl: BASE_URL });
const coll = await conn.getCollection();
let results;
if (args.rtstream) {
const rtstream = await coll.getRTStream(args.rtstream);
results = await rtstream.search({ query: args.query });
} else {
// coll.search positional args: query, searchType, indexType, resultThreshold, scoreThreshold, dynamicScorePercentage, filter, namespace
results = await coll.search(args.query, undefined, undefined, undefined, undefined, undefined, undefined, "rtstream");
}
const shots = results.getShots ? results.getShots() : results.shots || results;
const output = (Array.isArray(shots) ? shots : []).map(shot => ({
text: shot.text || "",
start: shot.start ?? null,
end: shot.end ?? null,
rtstream_id: shot.rtstreamId || "",
rtstream_name: shot.rtstreamName || "",
score: shot.searchScore ?? null,
}));
console.log(JSON.stringify(output, null, 2));
}
main().catch(err => {
console.error("Search failed:", err.message);
process.exit(1);
});
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Start Recording</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
::-webkit-scrollbar { display: none; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', sans-serif;
background: linear-gradient(145deg, #1c1c1e 0%, #2c2c2e 100%);
color: #fff;
padding: 24px;
user-select: none;
-webkit-app-region: drag;
min-height: 100vh;
border-radius: 18px;
}
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.header-icon {
width: 40px;
height: 40px;
background: rgba(255, 59, 48, 0.2);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 0.5px solid rgba(255, 59, 48, 0.3);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 2px 8px rgba(255, 59, 48, 0.15);
}
.header-text h1 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.3px;
color: rgba(255, 255, 255, 0.9);
}
.header-text p {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
}
.section {
margin-bottom: 24px;
}
.section-title {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.35);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 12px;
}
.displays {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
-webkit-app-region: no-drag;
}
.display-option {
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.05);
position: relative;
}
.display-option:hover {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.display-option.selected {
border-color: rgba(10, 132, 255, 0.5);
background: rgba(10, 132, 255, 0.08);
box-shadow: 0 0 0 1px rgba(10, 132, 255, 0.3), 0 8px 24px rgba(10, 132, 255, 0.1);
}
.display-option.selected::after {
content: '✓';
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: rgba(10, 132, 255, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
z-index: 10;
border: 0.5px solid rgba(10, 132, 255, 0.5);
}
.display-thumbnail {
width: 100%;
aspect-ratio: 16/10;
background: rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.display-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
opacity: 0.85;
}
.display-option:hover .display-thumbnail img {
transform: scale(1.05);
opacity: 1;
}
.display-thumbnail .placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.25);
}
.placeholder-icon {
font-size: 28px;
opacity: 0.6;
}
.placeholder-res {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.35);
}
.display-info {
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.display-name {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
}
.display-res {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
font-weight: 500;
}
.audio-section {
background: rgba(255, 255, 255, 0.05);
border: 0.5px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
overflow: hidden;
}
.audio-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
transition: background 0.15s ease;
-webkit-app-region: no-drag;
}
.audio-option:hover {
background: rgba(255, 255, 255, 0.05);
}
.audio-option:not(:last-child) {
border-bottom: 0.5px solid rgba(255, 255, 255, 0.06);
}
.audio-option input[type="checkbox"] {
display: none;
}
.checkbox-custom {
width: 22px;
height: 22px;
border: 1.5px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.04);
}
.audio-option input:checked + .checkbox-custom {
background: rgba(10, 132, 255, 0.7);
border-color: rgba(10, 132, 255, 0.8);
box-shadow: 0 2px 8px rgba(10, 132, 255, 0.2);
}
.checkbox-custom::after {
content: '✓';
color: white;
font-size: 12px;
font-weight: 700;
opacity: 0;
transform: scale(0.5);
transition: all 0.15s ease;
}
.audio-option input:checked + .checkbox-custom::after {
opacity: 1;
transform: scale(1);
}
.audio-icon {
font-size: 18px;
width: 24px;
text-align: center;
}
.audio-label {
flex: 1;
}
.audio-label-title {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
}
.audio-label-desc {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
margin-top: 1px;
}
.buttons {
display: flex;
gap: 12px;
margin-top: 24px;
-webkit-app-region: no-drag;
}
button {
flex: 1;
padding: 14px 20px;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
letter-spacing: -0.2px;
}
button:active {
transform: scale(0.98);
}
.btn-cancel {
background: rgba(255, 255, 255, 0.08);
border: 0.5px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.18);
}
.btn-start {
background: linear-gradient(135deg, rgba(255, 59, 48, 0.7) 0%, rgba(255, 98, 89, 0.7) 100%);
border: 0.5px solid rgba(255, 59, 48, 0.4);
color: #fff;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 4px 14px rgba(255, 59, 48, 0.25);
}
.btn-start:hover {
background: linear-gradient(135deg, rgba(255, 59, 48, 0.85) 0%, rgba(255, 98, 89, 0.85) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 6px 20px rgba(255, 59, 48, 0.35);
transform: translateY(-1px);
}
.btn-start:active {
transform: scale(0.98) translateY(0);
}
.loading {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.35);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 2px solid rgba(255, 255, 255, 0.08);
border-top-color: rgba(10, 132, 255, 0.6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="header">
<div class="header-icon">⏺</div>
<div class="header-text">
<h1>Start Recording</h1>
<p>Select display and audio sources</p>
</div>
</div>
<div class="section">
<div class="section-title">Select Display</div>
<div class="displays" id="displays">
<div class="loading">
<div class="loading-spinner"></div>
<div>Loading displays...</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Audio Sources</div>
<div class="audio-section">
<label class="audio-option">
<input type="checkbox" id="mic" checked>
<span class="checkbox-custom"></span>
<span class="audio-icon">🎙</span>
<div class="audio-label">
<div class="audio-label-title">Microphone</div>
<div class="audio-label-desc">Record your voice</div>
</div>
</label>
<label class="audio-option">
<input type="checkbox" id="systemAudio" checked>
<span class="checkbox-custom"></span>
<span class="audio-icon">🔊</span>
<div class="audio-label">
<div class="audio-label-title">System Audio</div>
<div class="audio-label-desc">Capture app sounds</div>
</div>
</label>
</div>
</div>
<div class="buttons">
<button class="btn-cancel" onclick="cancel()">Cancel</button>
<button class="btn-start" onclick="start()">Start Recording</button>
</div>
<script>
const { ipcRenderer, desktopCapturer } = require('electron');
let selectedChannelId = null;
let displays = [];
let thumbnails = {};
ipcRenderer.on('displays', async (event, data) => {
displays = data;
if (displays.length) selectedChannelId = displays[0].channelId;
await loadThumbnails();
renderDisplays();
});
async function loadThumbnails() {
try {
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 320, height: 200 }
});
sources.forEach((source, index) => {
if (source.thumbnail) {
thumbnails[index + 1] = source.thumbnail.toDataURL();
}
});
} catch (e) {
console.error('Failed to load thumbnails:', e);
}
}
function renderDisplays() {
const container = document.getElementById('displays');
container.innerHTML = '';
displays.forEach((display, index) => {
const displayNum = index + 1;
const isSelected = display.channelId === selectedChannelId;
const div = document.createElement('div');
div.className = `display-option ${isSelected ? 'selected' : ''}`;
div.onclick = () => selectDisplay(display.channelId);
const thumbDiv = document.createElement('div');
thumbDiv.className = 'display-thumbnail';
if (thumbnails[displayNum]) {
const img = document.createElement('img');
img.src = thumbnails[displayNum];
thumbDiv.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'placeholder';
placeholder.innerHTML = `
<span class="placeholder-icon">🖥</span>
${display.width && display.height ? `<span class="placeholder-res">${display.width} × ${display.height}</span>` : ''}
`;
thumbDiv.appendChild(placeholder);
}
const infoDiv = document.createElement('div');
infoDiv.className = 'display-info';
infoDiv.innerHTML = `
<span class="display-name">${display.label || 'Display ' + displayNum}</span>
${display.width && display.height ? `<span class="display-res">${display.width}×${display.height}</span>` : ''}
`;
div.appendChild(thumbDiv);
div.appendChild(infoDiv);
container.appendChild(div);
});
}
function selectDisplay(channelId) {
selectedChannelId = channelId;
document.querySelectorAll('.display-option').forEach((el, i) => {
el.classList.toggle('selected', displays[i] && displays[i].channelId === channelId);
});
}
function cancel() {
ipcRenderer.send('picker-result', null);
}
function start() {
ipcRenderer.send('picker-result', {
displayChannelId: selectedChannelId,
mic: document.getElementById('mic').checked,
systemAudio: document.getElementById('systemAudio').checked,
});
}
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
overflow: hidden;
background: transparent;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
user-select: none;
}
body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.widget {
position: relative;
width: 140px;
height: 130px;
-webkit-app-region: drag;
app-region: drag;
/* Apple Dark Glassmorphism */
background: rgba(30, 30, 30, 0.65);
backdrop-filter: blur(30px) saturate(190%) contrast(110%);
-webkit-backdrop-filter: blur(30px) saturate(190%) contrast(110%);
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Force cursor pointer for interactive elements inside drag region */
.widget [style*="app-region: no-drag"],
.widget .power-btn,
.widget .close-btn {
cursor: pointer !important;
}
/* Power button — center of widget */
.power-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -45%);
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
cursor: pointer !important;
-webkit-user-drag: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
-webkit-app-region: no-drag;
app-region: no-drag;
pointer-events: auto !important;
z-index: 100;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 1px 4px rgba(0, 0, 0, 0.2);
}
.power-btn:hover {
cursor: pointer !important;
transform: translate(-50%, -45%) scale(1.05);
}
.power-btn:active {
transform: translate(-50%, -45%) scale(0.93);
}
.power-btn svg {
width: 18px;
height: 18px;
fill: none;
stroke: rgba(255, 255, 255, 0.7);
stroke-width: 2;
stroke-linecap: round;
}
/* Starting state — orange pulse */
.state-starting .power-btn {
border-color: #ff9500;
animation: pulse-orange 1.4s ease-in-out infinite;
}
.state-starting .power-btn svg {
stroke: #ff9500;
}
@keyframes pulse-orange {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 149, 0, 0.4); }
50% { box-shadow: 0 0 12px 4px rgba(255, 149, 0, 0.25); }
}
/* Recording state — red filled with pulse */
.state-recording .power-btn {
border-color: #ff3b30;
background: rgba(255, 59, 48, 0.25);
animation: pulse-red 1.6s ease-in-out infinite;
}
.state-recording .power-btn svg {
stroke: #ff3b30;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.4); }
50% { box-shadow: 0 0 14px 5px rgba(255, 59, 48, 0.2); }
}
/* Channel icons — positioned around the power button */
.channel-icon {
position: absolute;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
pointer-events: none;
}
.channel-icon svg {
width: 14px;
height: 14px;
fill: none;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke 0.25s ease;
}
/* Channel states */
.channel-icon.active svg {
stroke: #30d158;
}
.channel-icon.muted {
opacity: 1;
}
.channel-icon.muted svg {
stroke: rgba(255, 255, 255, 0.9);
}
.channel-icon.muted::after {
content: "";
position: absolute;
width: 18px;
height: 2px;
background: #ff453a;
border-radius: 1px;
transform: rotate(-45deg);
}
/* Close button — top right */
.close-btn {
position: absolute;
top: 6px;
right: 6px;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer !important;
-webkit-user-drag: none;
background: rgba(255, 255, 255, 0.06);
border: 0.5px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
-webkit-app-region: no-drag;
app-region: no-drag;
pointer-events: auto !important;
z-index: 100;
opacity: 0.8;
}
.close-btn:hover {
cursor: pointer !important;
background: rgba(255, 59, 48, 0.25);
border-color: rgba(255, 59, 48, 0.4);
opacity: 1;
}
.close-btn:active {
transform: scale(0.88);
}
.close-btn svg {
width: 8px;
height: 8px;
fill: none;
stroke: rgba(255, 255, 255, 0.5);
stroke-width: 2;
stroke-linecap: round;
}
.close-btn:hover svg {
stroke: #ff453a;
}
/* Positions */
.channel-screen {
top: 10px;
left: 50%;
transform: translateX(-50%);
}
.channel-mic {
top: 50%;
left: 10px;
transform: translateY(-60%);
}
.channel-speaker {
top: 50%;
right: 10px;
transform: translateY(-60%);
}
/* Status text — bottom center */
.status {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
/* Loading state — after power on, before data arrives */
.state-loading .power-btn {
border-color: #ff9500;
animation: pulse-orange 1.4s ease-in-out infinite;
}
.state-loading .power-btn svg {
stroke: #ff9500;
}
/* Ready state — picker shown, waiting for user selection */
.state-ready .power-btn {
border-color: #007aff;
animation: pulse-blue 1.8s ease-in-out infinite;
}
.state-ready .power-btn svg {
stroke: #007aff;
}
@keyframes pulse-blue {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 122, 255, 0.4); }
50% { box-shadow: 0 0 10px 3px rgba(0, 122, 255, 0.2); }
}
/* Stopping requested state — user clicked stop, waiting for WS confirmation */
.state-stopping-requested .power-btn {
border-color: #8e8e93;
background: rgba(142, 142, 147, 0.15);
animation: pulse-gray 1s ease-in-out infinite;
pointer-events: none;
opacity: 0.7;
}
.state-stopping-requested .power-btn svg {
stroke: #8e8e93;
}
@keyframes pulse-gray {
0%, 100% { box-shadow: 0 0 0 0 rgba(142, 142, 147, 0.3); }
50% { box-shadow: 0 0 8px 3px rgba(142, 142, 147, 0.15); }
}
</style>
</head>
<body>
<div class="widget" id="widget">
<!-- Screen/monitor icon (top) -->
<div class="channel-icon channel-screen" id="ch-screen">
<svg viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<!-- Mic icon (left) -->
<div class="channel-icon channel-mic" id="ch-mic">
<svg viewBox="0 0 24 24">
<path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/>
<path d="M19 10v2a7 7 0 01-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</div>
<!-- Power button (center) — click to stop -->
<div class="power-btn" id="power-btn">
<svg viewBox="0 0 24 24">
<line x1="12" y1="2" x2="12" y2="12"/>
<path d="M16.24 7.76a6 6 0 11-8.48 0"/>
</svg>
</div>
<!-- Speaker icon (right) -->
<div class="channel-icon channel-speaker" id="ch-speaker">
<svg viewBox="0 0 24 24">
<polygon points="11,5 6,9 2,9 2,15 6,15 11,19"/>
<path d="M19.07 4.93a10 10 0 010 14.14"/>
<path d="M15.54 8.46a5 5 0 010 7.08"/>
</svg>
</div>
<!-- Minimize button (hides widget) -->
<div class="close-btn" id="close-btn">
<svg viewBox="0 0 10 10">
<line x1="2" y1="5" x2="8" y2="5"/>
</svg>
</div>
<!-- Status text -->
<div class="status" id="status">Starting...</div>
</div>
<script>
const { ipcRenderer } = require("electron");
const powerBtn = document.getElementById("power-btn");
const statusEl = document.getElementById("status");
const channelEls = {
screen: document.getElementById("ch-screen"),
mic: document.getElementById("ch-mic"),
system_audio: document.getElementById("ch-speaker"),
};
let currentState = "starting";
let startTime = null;
let timerInterval = null;
function updateTimer() {
if (!startTime) return;
const diff = Math.floor((Date.now() - startTime) / 1000);
const m = Math.floor(diff / 60).toString().padStart(2, "0");
const s = (diff % 60).toString().padStart(2, "0");
statusEl.textContent = `${m}:${s}`;
}
function setState(payload) {
const state = typeof payload === "string" ? payload : payload.state;
const newStartTime = typeof payload === "object" ? payload.startTime : null;
if (currentState === state && state !== "recording" && state !== "active") return;
currentState = state;
let cssState = state;
if (state === "active") cssState = "recording";
if (state === "stopping_requested") cssState = "stopping-requested";
document.body.className = `state-${cssState}`;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
if (state === "starting") {
statusEl.textContent = "Starting...";
} else if (state === "loading") {
statusEl.textContent = "Loading...";
} else if (state === "ready") {
statusEl.textContent = "Select Display";
} else if (state === "started") {
statusEl.textContent = "Started";
} else if (state === "recording" || state === "active") {
if (newStartTime) startTime = newStartTime;
else if (!startTime) startTime = Date.now();
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
} else if (state === "stopping_requested") {
statusEl.textContent = "Stopping...";
} else if (state === "stopping") {
statusEl.textContent = "Stopping...";
} else if (state === "stopped") {
statusEl.textContent = "Stopped";
} else if (state === "failed") {
statusEl.textContent = "Failed";
}
}
// Close button click — hides the widget
document.getElementById("close-btn").addEventListener("click", (e) => {
e.stopPropagation();
ipcRenderer.send("widget-close");
});
// Power button click — stops recording
powerBtn.addEventListener("click", (e) => {
e.stopPropagation();
ipcRenderer.send("widget-stop");
});
// Receive channel config from main process
ipcRenderer.on("widget-config", (_, config) => {
// Screen is always active
channelEls.screen.classList.add("active");
channelEls.screen.classList.remove("muted");
// Set mic state
if (config.mic) {
channelEls.mic.classList.add("active");
channelEls.mic.classList.remove("muted");
} else {
channelEls.mic.classList.add("muted");
channelEls.mic.classList.remove("active");
}
// Set system audio state
if (config.systemAudio) {
channelEls.system_audio.classList.add("active");
channelEls.system_audio.classList.remove("muted");
} else {
channelEls.system_audio.classList.add("muted");
channelEls.system_audio.classList.remove("active");
}
});
// Receive state changes
ipcRenderer.on("widget-state", (_, payload) => {
setState(payload);
});
// Send initial size
ipcRenderer.send("widget-resize", { width: 160, height: 150 });
// Force cursor pointer on interactive elements (Electron drag region workaround)
const closeBtn = document.getElementById("close-btn");
[powerBtn, closeBtn].forEach(btn => {
btn.addEventListener("mouseenter", () => {
btn.style.cursor = "pointer";
document.body.style.cursor = "pointer";
});
btn.addEventListener("mouseleave", () => {
document.body.style.cursor = "";
});
});
// Initialize
setState("starting");
</script>
</body>
</html>
const path = require("path");
const dotenv = require("dotenv");
const cwdArg = process.argv.find(a => a.startsWith("--cwd="));
const userCwd = cwdArg ? cwdArg.split("=")[1] : null;
if (userCwd) {
dotenv.config({ path: path.join(userCwd, ".env") });
}
const { connect } = require("videodb");
const apiKey = process.env.VIDEO_DB_API_KEY;
if (!apiKey) {
console.error("VIDEO_DB_API_KEY not set");
process.exit(1);
}
(async () => {
try {
const conn = connect({ apiKey });
const coll = await conn.getCollection();
console.log("Connected to VideoDB, collection:", coll.id);
} catch (err) {
console.error("Connection failed:", err.message);
process.exit(2);
}
})();