···1010### Core Modules
11111212- **Observe** 👁️👂 - Multimodal capture and analysis
1313- - `observe-gnome` - Screencast monitoring on Linux/GNOME
1313+ - `observer` - Screen and audio capture (auto-detects platform)
1414 - `observe-describe` - Analyzes visual changes with AI
1515 - `observe-transcribe` - Transcribes audio with AI APIs
1616 - `observe-sense` - Unified observation coordination
1717- - *Note: Requires Linux with GNOME desktop*
1717+ - *Supports Linux/GNOME and macOS*
18181919- **Think** 🧠 - Data processing and insights
2020 - `think-insight` - Generates AI-powered insights and summaries
+1-1
docs/CALLOSUM.md
···5353**Purpose:** Real-time stdout/stderr streaming and process exit events
54545555### `observe` - Multimodal capture processing events
5656-**Source:** `observe/gnome/observer.py`, `observe/sense.py`, `observe/describe.py`, `observe/transcribe.py`
5656+**Source:** `observe/observer.py` (delegates to `observe/gnome/observer.py` or `observe/macos/observer.py`), `observe/sense.py`, `observe/describe.py`, `observe/transcribe.py`
5757**Events:** `status`, `observing`, `detected`, `described`, `transcribed`, `observed`
5858**Fields:**
5959- `status`: Periodic state (every 5s while running)
+4-4
docs/DOCTOR.md
···16161717```bash
1818# Check if supervisor services are running
1919-pgrep -af "observe-gnome|observe-sense|think-supervisor"
1919+pgrep -af "observer|observe-sense|think-supervisor"
20202121# Check Callosum socket exists
2222ls -la $JOURNAL_PATH/health/callosum.sock
···4040| Service | Command | Purpose | Auto-restart |
4141|---------|---------|---------|--------------|
4242| Callosum | (in-process) | Message bus for inter-service events | No |
4343-| Observer | `observe-gnome` | Screen/audio capture | Yes |
4343+| Observer | `observer` | Screen/audio capture (platform-detected) | Yes |
4444| Sense | `observe-sense` | File detection, processing dispatch | Yes |
45454646Cortex (agent execution) connects to Callosum but runs independently via `muse-cortex`.
···62626363```bash
6464# Tail current observer log
6565-tail -f $JOURNAL_PATH/health/observe-gnome.log
6565+tail -f $JOURNAL_PATH/health/observer.log
66666767# Find today's logs
6868ls -la $JOURNAL_PATH/$(date +%Y%m%d)/health/
···132132133133```bash
134134# Check observer log for errors
135135-tail -50 $JOURNAL_PATH/health/observe-gnome.log | grep -i error
135135+tail -50 $JOURNAL_PATH/health/observer.log | grep -i error
136136137137# Check if observer is emitting status (supervisor.status will show stale_heartbeats)
138138# Health is derived from observe.status Callosum events
+5-3
docs/OBSERVE.md
···6677| Command | Purpose |
88|---------|---------|
99-| `observe-gnome` | Screen and audio capture on Linux/GNOME |
1010-| `observe-macos` | Screen and audio capture on macOS |
99+| `observer` | Screen and audio capture (auto-detects platform) |
1010+| `observe-gnome` | Screen and audio capture on Linux/GNOME (direct) |
1111+| `observe-macos` | Screen and audio capture on macOS (direct) |
1112| `observe-transcribe` | Audio transcription with speaker diarization |
1213| `observe-describe` | Visual analysis of screen recordings |
1314| `observe-sense` | Unified observation coordination |
···1516## Architecture
16171718```
1818-observe-gnome/macos (capture)
1919+observer (platform-detected capture)
1920 ↓
2021 Raw media files (*.flac, *.webm)
2122 ↓
···26272728## Key Components
28293030+- **observer.py** - Unified entry point with platform detection
2931- **gnome/observer.py**, **macos/observer.py** - Platform-specific capture using native APIs
3032- **sense.py** - File watcher that dispatches transcription and description jobs
3133- **transcribe.py** - Audio processing with Whisper/Rev.ai and pyannote diarization
+4-4
muse/agents/doctor.txt
···4343## Diagnostic Procedures
44444545### Quick Health Check
4646-1. Check if supervisor services are running: `pgrep -af "observe-gnome|observe-sense|think-supervisor"`
4646+1. Check if supervisor services are running: `pgrep -af "observer|observe-sense|think-supervisor"`
47472. Check Callosum socket exists: `ls -la health/callosum.sock`
48483. Check for stuck agents: `ls agents/*_active.jsonl 2>/dev/null`
4949-4. Check observer log for recent activity: `tail -20 health/observe-gnome.log`
4949+4. Check observer log for recent activity: `tail -20 health/observer.log`
50505151**Healthy state:**
5252- All three processes running
···56565757### Service Status
5858Check specific service logs:
5959-- Observer: `tail -50 health/observe-gnome.log`
5959+- Observer: `tail -50 health/observer.log`
6060- Sense: `tail -50 health/observe-sense.log`
6161- Supervisor: Check for `think-supervisor` process
6262···6969### Common Issues
70707171**Observer not capturing:**
7272-- Check log for errors: `tail -50 health/observe-gnome.log | grep -i error`
7272+- Check log for errors: `tail -50 health/observer.log | grep -i error`
7373- Check for recent status emissions in log (health is derived from Callosum events)
7474- Causes: DBus issues, screencast permissions, audio device unavailable
7575
+130-350
observe/macos/TODO.md
···2233This document tracks the remaining work to complete the macOS observer integration using sck-cli and ScreenCaptureKit.
4455-## Phase 1: Activity Detection (activity.py)
55+## Phase 1: Activity Detection (activity.py) - DONE
6677-### 1.1 Implement `get_idle_time_ms()`
88-- [ ] Import PyObjC Quartz framework
99-- [ ] Use `CGEventSourceSecondsSinceLastEventType(1, kCGAnyInputEventType)`
1010-- [ ] Convert seconds to milliseconds
1111-- [ ] Add error handling for API failures
1212-- [ ] Test on macOS system
1313-1414-**Example:**
1515-```python
1616-from Quartz import CGEventSourceSecondsSinceLastEventType, kCGAnyInputEventType
77+### 1.1 Implement `get_idle_time_ms()` (DONE)
88+- [x] Import PyObjC Quartz framework
99+- [x] Use `CGEventSourceSecondsSinceLastEventType(1, kCGAnyInputEventType)`
1010+- [x] Convert seconds to milliseconds
1111+- [x] Add error handling for API failures
1212+- [x] Test on macOS system
17131818-def get_idle_time_ms() -> int:
1919- seconds = CGEventSourceSecondsSinceLastEventType(1, kCGAnyInputEventType)
2020- return int(seconds * 1000)
2121-```
1414+### 1.2 Implement `is_screen_locked()` (DONE)
1515+- [x] Used CGSessionCopyCurrentDictionary for kCGSSessionOnConsoleKey
1616+- [x] Add error handling
1717+- [x] Test on macOS system
22182323-### 1.2 Implement `is_screen_locked()`
2424-- [ ] Research best approach for screen lock detection:
2525- - Option A: Query CGSessionCopyCurrentDictionary for kCGSSessionOnConsoleKey
2626- - Option B: Use `ioreg -c IOHIDSystem | grep HIDIdleTime`
2727- - Option C: Check display sleep state as proxy
2828-- [ ] Implement chosen method with PyObjC
2929-- [ ] Add fallback if primary method unavailable
3030-- [ ] Test with actual screen lock/unlock cycles
3131-- [ ] Handle edge cases (fast user switching, etc.)
1919+### 1.3 Implement `is_power_save_active()` (DONE)
2020+- [x] Used CGDisplayIsAsleep(CGMainDisplayID())
2121+- [x] Add error handling
2222+- [x] Test on macOS system
32233333-### 1.3 Implement `is_power_save_active()`
3434-- [ ] Investigate IOKit display state query
3535-- [ ] Check NSScreen APIs for display power state
3636-- [ ] Alternative: subprocess call to `pmset -g` or `system_profiler`
3737-- [ ] Return True if displays are sleeping/powered off
3838-- [ ] Test with display sleep/wake
2424+### 1.4 Implement `is_output_muted()` (DONE)
2525+- [x] Used osascript to query volume settings
2626+- [x] Add error handling and timeout
2727+- [x] Test on macOS system
39284040-### 1.4 Implement `get_monitor_geometries()`
4141-- [ ] Import Cocoa NSScreen framework
4242-- [ ] Get all screens: `NSScreen.screens()`
4343-- [ ] For each screen:
4444- - Extract frame geometry: `screen.frame()`
4545- - Get device description for unique ID: `screen.deviceDescription()`
4646- - Handle NSScreenNumber, display ID, etc.
4747-- [ ] Compute union bounding box of all monitors
4848-- [ ] Calculate midlines (union_mid_x, union_mid_y)
4949-- [ ] Assign position labels based on intersection with midlines:
5050- - "center", "left", "right", "top", "bottom", "left-top", etc.
5151-- [ ] Return list of dicts: `[{"id": "...", "box": [x1,y1,x2,y2], "position": "..."}]`
5252-- [ ] Test with single monitor, dual monitor, triple monitor setups
5353-- [ ] Handle coordinate system (Cocoa uses bottom-left origin)
2929+## Phase 2: ScreenCaptureKit Manager (screencapture.py)
54305555-### 1.5 Implement `get_monitor_metadata_string()`
5656-- [ ] Call `get_monitor_geometries()`
5757-- [ ] Format as: `"0:center,0,0,1920,1080 1:right,1920,0,3840,1080"`
5858-- [ ] Test output format matches GNOME format exactly
3131+**Note:** sck-cli now provides multi-display capture with JSONL metadata output to stdout.
3232+Display geometry is parsed from sck-cli output - no PyObjC monitor detection needed.
59336060-## Phase 2: ScreenCaptureKit Manager (screencapture.py)
3434+### 2.1 JSONL Parsing (DONE)
3535+- [x] Parse sck-cli stdout for display geometry
3636+- [x] Extract displayID, x, y, width, height per display
3737+- [x] Use `assign_monitor_positions()` to compute position labels
3838+- [x] Build DisplayInfo objects with position, displayID, temp_path
61396262-### 2.1 Implement `start()`
6363-- [ ] Validate sck-cli is available in PATH or at specified path
6464-- [ ] Build command: `[sck_cli_path, str(output_base), "-r", str(frame_rate), "-l", str(duration)]`
6565-- [ ] Launch subprocess with `subprocess.Popen()`
6666-- [ ] Store process handle and output_base in instance variables
6767-- [ ] Add stderr/stdout capture for debugging
6868-- [ ] Return success/failure
6969-- [ ] Test with various parameters
4040+### 2.2 Implement `start()` (DONE)
4141+- [x] Build command with frame rate and duration
4242+- [x] Launch subprocess and capture stdout
4343+- [x] Parse JSONL for display and audio info
4444+- [x] Return list of DisplayInfo and AudioInfo
70457171-### 2.2 Implement `stop()`
7272-- [ ] Check if process exists and is running
7373-- [ ] Send SIGTERM to process
7474-- [ ] Wait with timeout (5 seconds) for graceful shutdown
7575-- [ ] If timeout, send SIGKILL as fallback
7676-- [ ] Clear process handle and output_base
7777-- [ ] Log any stderr output from process
7878-- [ ] Test graceful and forced shutdown scenarios
4646+### 2.3 Implement `stop()` (DONE)
4747+- [x] Send SIGTERM to process
4848+- [x] Wait with timeout for graceful shutdown
4949+- [x] SIGKILL as fallback
79508080-### 2.3 Implement `is_running()`
8181-- [ ] Check if `self.process` is not None
8282-- [ ] Use `self.process.poll()` to check if still running
8383-- [ ] Return True if running, False otherwise
5151+### 2.4 Implement `finalize()` (DONE)
5252+- [x] Simple file rename (no metadata embedding needed)
5353+- [x] Rename per-display: `temp_displayID.mov` -> `HHMMSS_LEN_position_displayID_screen.mov`
5454+- [x] Rename audio: `temp.m4a` -> `HHMMSS_LEN_audio.m4a`
84558585-### 2.4 Implement `finalize()`
8686-- [ ] Check if temp files exist: `temp_base.mov`, `temp_base.m4a`
8787-- [ ] If files missing, log error and return failure
8888-- [ ] Add monitor metadata to video file using one of:
8989- - Option A: ffmpeg: `ffmpeg -i input.mov -metadata title="..." -c copy output.mov`
9090- - Option B: PyObjC AVFoundation APIs to modify metadata in-place
9191-- [ ] Atomically rename temp video file to final path: `os.replace()`
9292-- [ ] Atomically rename temp audio file to final path: `os.replace()`
9393-- [ ] Return tuple of (video_success, audio_success)
9494-- [ ] Handle errors gracefully (log but don't crash)
9595-- [ ] Test with actual sck-cli output files
5656+### 2.5 Implement `get_output_size()` (DONE)
5757+- [x] Sum sizes of all display video files
5858+- [x] Used for health check file growth verification
96599797-### 2.5 Implement `get_output_size()`
9898-- [ ] Check if `self.current_output_base` is set
9999-- [ ] Build path to .mov file
100100-- [ ] Use `os.path.getsize()` to get file size
101101-- [ ] Return 0 if file doesn't exist or error
102102-- [ ] Used for health check file growth verification
6060+## Phase 3: Main Observer (observer.py) - DONE
10361104104-## Phase 3: Main Observer (observer.py)
6262+### 3.1 Implement `setup()` (DONE)
6363+- [x] Verify sck-cli is available in PATH via shutil.which()
6464+- [x] Initialize Callosum connection
6565+- [x] Start Callosum connection
6666+- [x] Log initialization success
6767+- [x] Return True on success, False on failure
10568106106-### 3.1 Implement `setup()`
107107-- [ ] Verify sck-cli is available in PATH
108108-- [ ] Create ScreenCaptureKitManager instance
109109-- [ ] Initialize Callosum connection
110110-- [ ] Start Callosum connection
111111-- [ ] Log initialization success
112112-- [ ] Return True on success, False on failure
6969+### 3.2 Implement `check_activity_status()` (DONE)
7070+- [x] Call `get_idle_time_ms()` from activity module
7171+- [x] Call `is_screen_locked()` from activity module
7272+- [x] Call `is_output_muted()` from activity module
7373+- [x] Cache values in instance variables for status events
7474+- [x] Determine if idle: `(idle_time > IDLE_THRESHOLD_MS) or screen_locked`
7575+- [x] Return activity status
11376114114-### 3.2 Implement `check_activity_status()`
115115-- [ ] Call `get_idle_time_ms()` from activity module
116116-- [ ] Call `is_screen_locked()` from activity module
117117-- [ ] Cache values in instance variables for status events
118118-- [ ] Determine if idle: `(idle_time > IDLE_THRESHOLD_MS) or screen_locked`
119119-- [ ] Set `self.cached_is_active = not is_idle`
120120-- [ ] Return activity status
7777+### 3.3 Implement `handle_boundary()` (DONE)
7878+- [x] Get timestamp parts and calculate duration
7979+- [x] Stop capture if running
8080+- [x] Check audio threshold (3-chunk RMS logic) before saving audio
8181+- [x] Build finalization list and queue
8282+- [x] Reset timing for new window
8383+- [x] Start new capture if active and screen not locked
8484+- [x] Emit Callosum observing event with saved files
12185122122-### 3.3 Implement `handle_boundary()`
123123-- [ ] Get timestamp parts and calculate duration
124124-- [ ] Get day directory path
125125-- [ ] If capture running:
126126- - Stop sck-cli via `self.screencapture.stop()`
127127- - Build temp base path (e.g., `.120000`)
128128- - Build final paths with duration (e.g., `120000_300_screen.mov`, `120000_300_audio.m4a`)
129129- - Queue for finalization: `self.pending_finalization = (temp_base, final_video, final_audio)`
130130- - Clear state variables
131131-- [ ] Reset timing: `self.start_at = time.time()`, `self.start_at_mono = time.monotonic()`
132132-- [ ] If active and screen not locked:
133133- - Call `initialize_capture()`
134134-- [ ] Build list of files that were captured
135135-- [ ] Emit Callosum event: `self.callosum.emit("observe", "observing", segment="...", files=[...])`
136136-- [ ] Log boundary handling
8686+### 3.4 Implement `initialize_capture()` (DONE)
8787+- [x] Get timestamp for filename
8888+- [x] Build temp output base (hidden file)
8989+- [x] Start sck-cli via ScreenCaptureKitManager
9090+- [x] Store displays and audio info
9191+- [x] Initialize file size tracking
9292+- [x] Log capture start with display info
13793138138-### 3.4 Implement `initialize_capture()`
139139-- [ ] Get timestamp for filename
140140-- [ ] Get day directory path
141141-- [ ] Build temp output base: `day_dir / f".{time_part}"` (hidden file)
142142-- [ ] Call `self.screencapture.start(output_base, self.interval, frame_rate=1.0)`
143143-- [ ] If success:
144144- - Set `self.capture_running = True`
145145- - Set `self.current_output_base = output_base`
146146- - Set `self.last_video_size = 0`
147147- - Log capture start
148148- - Return True
149149-- [ ] Else:
150150- - Log failure
151151- - Return False
9494+### 3.5 Implement `emit_status()` (DONE)
9595+- [x] Build capture info dict with recording status, displays, elapsed time, files_growing
9696+- [x] Build activity info dict with active, idle_time_ms, screen_locked, output_muted
9797+- [x] Emit via Callosum
15298153153-### 3.5 Implement `emit_status()`
154154-- [ ] Build capture info dict:
155155- - If capturing: `{"recording": True, "file": "...", "window_elapsed_seconds": ...}`
156156- - Else: `{"recording": False}`
157157-- [ ] Build activity info dict: `{"active": ..., "idle_time_ms": ..., "screen_locked": ...}`
158158-- [ ] Emit via Callosum: `self.callosum.emit("observe", "status", capture=..., activity=...)`
9999+### 3.6 Implement `finalize_screencast()` (DONE)
100100+- [x] Simple file rename using os.replace()
101101+- [x] Log success/failure
159102160160-### 3.6 Implement `finalize_capture()`
161161-- [ ] Check if temp files exist
162162-- [ ] If missing, log warning and return
163163-- [ ] Get monitor metadata string: `get_monitor_metadata_string()`
164164-- [ ] Call `self.screencapture.finalize(temp_base, final_video, final_audio, monitor_metadata)`
165165-- [ ] Log success/failure
166166-- [ ] Return finalization status
103103+### 3.7 Implement `main_loop()` (DONE)
104104+- [x] Check initial activity status
105105+- [x] Start initial capture if active
106106+- [x] Main loop with CHUNK_DURATION sleep intervals
107107+- [x] Process pending finalizations
108108+- [x] Check activity status and detect activation edge
109109+- [x] Detect mute state transitions (triggers boundary like GNOME)
110110+- [x] Handle window boundaries
111111+- [x] Track file growth for health reporting
112112+- [x] Emit status events
167113168168-### 3.7 Implement `main_loop()`
169169-- [ ] Check initial activity status
170170-- [ ] If active and not locked, start initial capture
171171-- [ ] Main loop while `self.running`:
172172- - Sleep for CHUNK_DURATION (5 seconds)
173173- - Process pending finalization if queued
174174- - Check activity status
175175- - Detect activation edge: `is_active and not self.capture_running`
176176- - Calculate elapsed time since window start (monotonic)
177177- - Check for boundary: `elapsed >= self.interval or activation_edge`
178178- - If boundary, call `handle_boundary(is_active)`
179179- - Track if capture files are growing (for health reporting via status event)
180180- - Emit status event with `screencast.files_growing` field (supervisor derives health from this)
181181-- [ ] Call `shutdown()` after loop exits
114114+### 3.8 Implement `shutdown()` (DONE)
115115+- [x] Stop capture if running
116116+- [x] Check audio threshold for final segment
117117+- [x] Finalize all pending captures
118118+- [x] Stop Callosum connection
182119183183-### 3.8 Implement `shutdown()`
184184-- [ ] If capture running:
185185- - Stop capture
186186- - Wait briefly (1 second) for files to be written
187187- - Build final paths
188188- - Call `finalize_capture()` for current capture
189189-- [ ] If pending finalization exists:
190190- - Wait briefly
191191- - Call `finalize_capture()` for pending
192192-- [ ] Stop Callosum connection
193193-- [ ] Log shutdown complete
120120+### 3.9 Implement `_check_audio_threshold()` (DONE)
121121+- [x] Decode m4a with PyAV
122122+- [x] Split into 5-second chunks
123123+- [x] Compute RMS per chunk
124124+- [x] Count threshold hits (same MIN_HITS_FOR_SAVE = 3 as GNOME)
125125+- [x] Return True if enough voice activity
194126195195-### 3.9 Wire up CLI arguments
196196-- [ ] Add `--sck-cli-path` argument support
197197-- [ ] Pass to ScreenCaptureKitManager constructor
198198-- [ ] Test CLI invocation: `observe-macos --interval 300`
127127+### 3.10 Wire up CLI arguments (DONE)
128128+- [x] Pass --sck-cli-path to ScreenCaptureKitManager
199129200130## Phase 4: Testing & Integration
201131···208138- [ ] Test window boundaries and file naming
209139- [ ] Test graceful shutdown (Ctrl-C)
210140- [ ] Verify Callosum events emitted
211211-- [ ] Verify health files touched
212141213142### 4.2 Multi-Monitor Testing
214214-- [ ] Test with single monitor
143143+- [ ] Test with single monitor (position should be "center")
215144- [ ] Test with dual monitors (side-by-side)
216145- [ ] Test with three monitors
217217-- [ ] Verify monitor metadata in video files
146146+- [ ] Verify per-display files with position labels
218147- [ ] Test monitor arrangement changes during capture
219148220149### 4.3 Edge Cases
···228157229158### 4.4 Integration with Downstream Tools
230159- [ ] Verify observe-describe works with .mov files
231231-- [ ] Verify observe-sense works with .m4a audio
232232-- [ ] Test or update tools expecting .flac to handle .m4a
233233-- [ ] Test monitor metadata parsing from video titles
160160+- [ ] Verify observe-sense dispatches .mov to describe and .m4a to transcribe
161161+- [ ] Test parse_screen_filename() with new displayID format
234162- [ ] Verify think-indexer handles new file formats
235163236236-## Phase 5: sck-cli Enhancements
237237-238238-### 5.1 High Priority: Monitor Metadata Capture
239239-**Investigate:**
240240-- [ ] Can `SCShareableContent` provide display arrangement/geometry?
241241-- [ ] Research `SCDisplay` properties (displayID, width, height, frame)
242242-- [ ] Can we get display position in global coordinate space?
243243-- [ ] Can we distinguish between primary and secondary displays?
244244-245245-**Implement:**
246246-- [ ] Add code to capture monitor geometry at capture start
247247-- [ ] Store in video metadata (QuickTime user data or title field)
248248-- [ ] Format: `"0:center,0,0,1920,1080 1:right,1920,0,3840,1080"`
249249-- [ ] Test with multiple monitor configurations
250250-- [ ] Document in sck-cli README
251251-252252-**Benefits:**
253253-- Enables per-monitor analysis in observe-describe
254254-- Matches GNOME screencast format for compatibility
255255-- Essential for downstream processing
256256-257257-### 5.2 High Priority: Temp File Support
258258-**Investigate:**
259259-- [ ] Add CLI flag: `--temp` or `--hidden`
260260-- [ ] When enabled, write to `.{basename}.mov` and `.{basename}.m4a`
261261-- [ ] Python wrapper then renames after completion
262262-263263-**Implement:**
264264-- [ ] Add flag to ArgumentParser
265265-- [ ] Modify output path construction in SCKShot.swift
266266-- [ ] Test that files are hidden on macOS (ls -a shows them)
267267-- [ ] Document flag in README
268268-269269-**Benefits:**
270270-- Prevents file watchers from triggering on incomplete files
271271-- Cleaner integration with Sunstone's workflow
272272-- Matches GNOME observer pattern
273273-274274-### 5.3 High Priority: Graceful Shutdown
275275-**Investigate:**
276276-- [ ] Verify current SIGTERM/SIGINT handling
277277-- [ ] Ensure VideoWriter.finish() is called on interrupt
278278-- [ ] Ensure AudioWriter finishes both tracks properly
279279-- [ ] Test file validity after various interrupt scenarios
280280-281281-**Implement:**
282282-- [ ] Add proper signal handlers if missing
283283-- [ ] Ensure clean shutdown path exercises all finish() methods
284284-- [ ] Test: Start capture, wait 5 sec, send SIGTERM, verify files valid
285285-- [ ] Test: Start capture, wait 30 sec, send SIGINT, verify files valid
286286-287287-**Benefits:**
288288-- Ensures data integrity
289289-- Critical for reliable operation
290290-- Prevents corrupt files on shutdown
291291-292292-### 5.4 Medium Priority: Multi-Display Support
293293-**Investigate:**
294294-- [ ] Currently captures "first" display - which one exactly?
295295-- [ ] Can we capture multiple displays simultaneously?
296296-- [ ] Would require multiple StreamOutput/VideoWriter instances
297297-- [ ] Or capture combined virtual display space?
298298-299299-**Implement:**
300300-- [ ] Add flag: `--display <id>` or `--display all`
301301-- [ ] Allow specifying which display(s) to capture
302302-- [ ] If "all", consider whether to:
303303- - Create separate files per display, or
304304- - Capture combined virtual space (current behavior?)
305305-- [ ] Document display selection in README
306306-307307-**Benefits:**
308308-- Flexibility for multi-monitor setups
309309-- May reduce file size if only one display active
310310-- Future-proofing
311311-312312-### 5.5 Medium Priority: Exit Code Validation
313313-**Investigate:**
314314-- [ ] Add validation before exit:
315315- - Check output files exist
316316- - Check files have non-zero size
317317- - Check video file is valid (can open with AVFoundation)
318318- - Check audio file is valid
319319-320320-**Implement:**
321321-- [ ] Return exit code 0 only if all validations pass
322322-- [ ] Return exit code 1 if capture failed
323323-- [ ] Return exit code 2 if files missing/corrupt
324324-- [ ] Log specific error messages to stderr
325325-326326-**Benefits:**
327327-- Python wrapper can detect failures reliably
328328-- Better error handling and debugging
329329-- Prevents silent failures
330330-331331-### 5.6 Medium Priority: Metadata Embedding
332332-**Investigate:**
333333-- [ ] What metadata can be embedded in .mov container?
334334-- [ ] QuickTime user data atoms for custom fields?
335335-- [ ] Standard fields: title, comment, creation date, etc.
336336-- [ ] Can we store capture settings (frame rate, duration, display ID)?
337337-338338-**Implement:**
339339-- [ ] Add custom metadata fields:
340340- - Capture frame rate
341341- - Capture duration (planned)
342342- - Display ID(s) captured
343343- - Monitor geometry string
344344- - sck-cli version
345345-- [ ] Use AVFoundation APIs to write metadata
346346-- [ ] Test metadata survives file copy/move
347347-- [ ] Document metadata fields
348348-349349-**Benefits:**
350350-- Self-documenting files
351351-- Enables smarter downstream processing
352352-- Helpful for debugging capture issues
353353-354354-### 5.7 Low Priority: Frame Timestamp Accuracy
355355-**Investigate:**
356356-- [ ] Verify CMSampleBuffer presentation timestamps are accurate
357357-- [ ] Test frame extraction at specific timestamps
358358-- [ ] Ensure timestamps align with audio timestamps
359359-- [ ] Test with different frame rates
360360-361361-**Implement:**
362362-- [ ] Add logging of frame timestamps if not already present
363363-- [ ] Validate timestamps against wall clock
364364-- [ ] Document timestamp behavior in README
365365-366366-**Benefits:**
367367-- Important for visual analysis alignment
368368-- Ensures audio/video sync
369369-- Critical for accurate playback
370370-371371-### 5.8 Low Priority: Output Path Validation
372372-**Investigate:**
373373-- [ ] Add validation before capture starts:
374374- - Parent directory exists
375375- - Parent directory is writable
376376- - Output files don't already exist
377377- - Sufficient disk space available
164164+## Phase 5: sck-cli (DONE)
378165379379-**Implement:**
380380-- [ ] Add pre-flight checks in run() method
381381-- [ ] Print clear error messages for each failure case
382382-- [ ] Exit early if validation fails
383383-- [ ] Document error messages
384384-385385-**Benefits:**
386386-- Better user experience
387387-- Prevents wasted capture attempts
388388-- Clearer error messages
166166+All sck-cli requirements are met:
167167+- [x] Multi-display capture with per-display files
168168+- [x] JSONL metadata output to stdout
169169+- [x] Temp file support (Python passes hidden path like `.HHMMSS`)
170170+- [x] Graceful SIGTERM/SIGINT handling (verified)
171171+- [x] File validation done in Python's `finalize()`
389172390173## Phase 6: Documentation & Polish
391174···413196414197## Notes
415198199199+### Architecture Changes from Original Plan
200200+- **No PyObjC monitor detection needed**: sck-cli provides display geometry via JSONL stdout
201201+- **No metadata embedding**: Position/displayID encoded in filename instead
202202+- **Multi-display from day one**: sck-cli captures all displays automatically
203203+- **DisplayInfo dataclass**: Mirrors GNOME's StreamInfo pattern
204204+205205+### File Naming Convention
206206+- **Video**: `HHMMSS_LEN_position_displayID_screen.mov` (e.g., `120000_300_center_1_screen.mov`)
207207+- **Audio**: `HHMMSS_LEN_audio.m4a` (e.g., `120000_300_audio.m4a`)
208208+- **Temp files**: `.HHMMSS_displayID.mov`, `.HHMMSS.m4a` (hidden during capture)
209209+416210### Differences from GNOME Observer
417211- **Audio**: sck-cli provides synchronized .m4a instead of separate AudioRecorder
418418-- **Format**: .mov video instead of .webm (or optionally convert with ffmpeg)
212212+- **Format**: .mov video instead of .webm
419213- **Activity APIs**: PyObjC instead of DBus
420214- **Subprocess**: Manages external sck-cli process instead of direct API calls
421421-- **No RMS threshold**: Audio always captured when recording (rely on VAD post-processing)
422422-423423-### Key Design Decisions
424424-- Use sck-cli's native audio to avoid synchronization complexity
425425-- Mirror GNOME observer architecture for consistency
426426-- Use PyObjC for native system APIs (parallels DBus approach)
427427-- Accept .m4a format (update downstream tools if needed)
428428-- Temp file pattern (`.HHMMSS`) prevents premature file watcher triggers
215215+- **Connector ID**: Uses numeric displayID instead of connector names like "DP-3"
216216+- **No RMS threshold**: Audio always captured when recording
429217430218### Dependencies
431219- sck-cli must be built and available in PATH (or specified via --sck-cli-path)
432432-- PyObjC frameworks required: core, Cocoa, Quartz
433433-- Optional: ffmpeg for video metadata manipulation (if not using PyObjC)
434434-- Optional: ffmpeg for .mov → .webm conversion (if desired)
220220+- PyObjC frameworks required: core, Cocoa, Quartz (for activity detection only)
221221+- observe.utils.assign_monitor_positions for position label computation
435222436223### Testing Strategy
4372241. Start with activity.py (testable independently)
438438-2. Then screencapture.py (can test with mock sck-cli)
225225+2. Then screencapture.py (can test with mock sck-cli or real capture)
4392263. Then observer.py (integration testing)
4402274. Finally sck-cli enhancements (separate repo)
441441-442442-### Future Enhancements
443443-- Consider adding VAD post-processing to match GNOME's threshold logic
444444-- Consider .mov → .webm conversion for format consistency
445445-- Consider .m4a → .flac conversion if downstream tools require it
446446-- Add observe-macos-test command for validation
447447-- Add metrics/telemetry for capture success rates
+69-88
observe/macos/activity.py
···55"""
6677import logging
88-from typing import Optional
88+import subprocess
99+1010+from Quartz import (
1111+ CGDisplayIsAsleep,
1212+ CGEventSourceSecondsSinceLastEventType,
1313+ CGMainDisplayID,
1414+ CGSessionCopyCurrentDictionary,
1515+ kCGAnyInputEventType,
1616+)
9171018logger = logging.getLogger(__name__)
1111-1212-# IDLE_THRESHOLD_MS is defined in observer.py, but useful to know the typical value
1313-# IDLE_THRESHOLD_MS = 5 * 60 * 1000 # 5 minutes
141915201621def get_idle_time_ms() -> int:
···2732 >>> idle_ms = get_idle_time_ms()
2833 >>> print(f"User idle for {idle_ms / 1000:.1f} seconds")
2934 """
3030- # TODO: Implement using PyObjC
3131- # from Quartz import CGEventSourceSecondsSinceLastEventType, kCGAnyInputEventType
3232- # seconds = CGEventSourceSecondsSinceLastEventType(1, kCGAnyInputEventType)
3333- # return int(seconds * 1000)
3434- logger.warning("get_idle_time_ms not yet implemented")
3535- return 0
3535+ try:
3636+ # kCGEventSourceStateHIDSystemState = 1 (hardware input events)
3737+ seconds = CGEventSourceSecondsSinceLastEventType(1, kCGAnyInputEventType)
3838+ return int(seconds * 1000)
3939+ except Exception as e:
4040+ logger.warning(f"Failed to get idle time: {e}")
4141+ return 0
364237433844def is_screen_locked() -> bool:
3945 """
4046 Check if the screen is currently locked.
41474242- Queries the macOS session state to determine if the screen lock is active.
4848+ Queries the macOS session state via CGSessionCopyCurrentDictionary.
4949+ When the screen is locked, kCGSSessionOnConsoleKey becomes False.
43504451 Returns:
4552 True if screen is locked, False otherwise
···4855 >>> if is_screen_locked():
4956 ... print("Screen is locked, skipping capture")
5057 """
5151- # TODO: Implement using PyObjC or subprocess
5252- # Options:
5353- # 1. Check CGSessionCopyCurrentDictionary for kCGSSessionOnConsoleKey
5454- # 2. Query via `ioreg -c IOHIDSystem`
5555- # 3. Use Quartz APIs to detect locked state
5656- logger.warning("is_screen_locked not yet implemented")
5757- return False
5858+ try:
5959+ session_dict = CGSessionCopyCurrentDictionary()
6060+ if session_dict is None:
6161+ logger.warning("CGSessionCopyCurrentDictionary returned None")
6262+ return False
6363+6464+ # kCGSSessionOnConsoleKey is True when user is on console (not locked)
6565+ # When screen is locked, this becomes False
6666+ on_console = session_dict.get("kCGSSessionOnConsoleKey", True)
6767+ return not on_console
6868+ except Exception as e:
6969+ logger.warning(f"Failed to check screen lock status: {e}")
7070+ return False
587159726073def is_power_save_active() -> bool:
6174 """
6275 Check if display power save mode is active (screen blanked/sleep).
63766464- Detects if displays are in sleep mode or powered off, similar to GNOME's
6565- DisplayConfig PowerSaveMode check.
7777+ Uses CGDisplayIsAsleep to detect if the main display is sleeping,
7878+ similar to GNOME's DisplayConfig PowerSaveMode check.
66796780 Returns:
6881 True if power save is active (displays off), False otherwise
···7184 >>> if is_power_save_active():
7285 ... print("Displays are sleeping")
7386 """
7474- # TODO: Implement display sleep detection
7575- # Options:
7676- # 1. IOKit display state query
7777- # 2. NSScreen APIs to check if displays are active
7878- # 3. subprocess call to system_profiler or pmset
7979- logger.warning("is_power_save_active not yet implemented")
8080- return False
8787+ try:
8888+ main_display = CGMainDisplayID()
8989+ is_asleep = CGDisplayIsAsleep(main_display)
9090+ return bool(is_asleep)
9191+ except Exception as e:
9292+ logger.warning(f"Failed to check display sleep status: {e}")
9393+ return False
819482958383-def get_monitor_geometries() -> list[dict]:
9696+def is_output_muted() -> bool:
8497 """
8585- Get structured monitor information using NSScreen.
9898+ Check if the system audio output is muted.
86998787- Returns monitor geometry in the same format as GNOME's get_monitor_geometries()
8888- to enable downstream compatibility.
100100+ Uses osascript to query macOS volume settings, similar to how GNOME
101101+ uses pactl for PulseAudio mute status.
8910290103 Returns:
9191- List of dicts with format:
9292- [{"id": "display-id", "box": [x1, y1, x2, y2], "position": "center|left|right|..."}, ...]
9393- where box contains [left, top, right, bottom] coordinates
104104+ True if muted, False otherwise (including on error).
9410595106 Example:
9696- >>> monitors = get_monitor_geometries()
9797- >>> for mon in monitors:
9898- ... print(f"{mon['id']}: {mon['position']} at {mon['box']}")
9999- display-1: center at [0, 0, 1920, 1080]
100100- display-2: right at [1920, 0, 3840, 1080]
101101-102102- Notes:
103103- - Coordinates are in screen space (origin may be top-left or bottom-left)
104104- - Position is computed relative to union bounding box midlines
105105- - Format matches GNOME output for compatibility with existing analysis tools
106106- """
107107- # TODO: Implement using PyObjC NSScreen
108108- # from Cocoa import NSScreen
109109- # from observe.utils import assign_monitor_positions
110110- #
111111- # Get all screens: NSScreen.screens()
112112- # For each screen:
113113- # - Get frame: screen.frame()
114114- # - Get device description for ID: screen.deviceDescription()
115115- # - Extract NSDeviceResolution, NSScreenNumber, etc.
116116- # - Build dict with "id" and "box" keys
117117- #
118118- # Use assign_monitor_positions() to add position labels
119119- # Return list matching GNOME format
120120- logger.warning("get_monitor_geometries not yet implemented")
121121- return []
122122-123123-124124-def get_monitor_metadata_string() -> str:
125125- """
126126- Format monitor geometries as a metadata string for video title.
127127-128128- Converts monitor geometry data into the format used in GNOME screencasts:
129129- "0:center,0,0,1920,1080 1:right,1920,0,3840,1080"
130130-131131- This string is stored in the video file's title metadata to enable per-monitor
132132- analysis in downstream tools.
133133-134134- Returns:
135135- Formatted metadata string, or empty string if no monitors
136136-137137- Example:
138138- >>> metadata = get_monitor_metadata_string()
139139- >>> print(metadata)
140140- "0:center,0,0,1920,1080 1:right,1920,0,3840,1080"
107107+ >>> if is_output_muted():
108108+ ... print("Audio is muted")
141109 """
142142- geometries = get_monitor_geometries()
143143- if not geometries:
144144- return ""
110110+ try:
111111+ result = subprocess.run(
112112+ ["osascript", "-e", "output muted of (get volume settings)"],
113113+ capture_output=True,
114114+ text=True,
115115+ timeout=5,
116116+ )
145117146146- parts = []
147147- for i, geom in enumerate(geometries):
148148- x1, y1, x2, y2 = geom["box"]
149149- position = geom["position"]
150150- parts.append(f"{i}:{position},{x1},{y1},{x2},{y2}")
118118+ if result.returncode != 0:
119119+ logger.warning(
120120+ f"osascript failed (rc={result.returncode}): {result.stderr}"
121121+ )
122122+ return False
151123152152- return " ".join(parts)
124124+ return result.stdout.strip().lower() == "true"
125125+ except subprocess.TimeoutExpired:
126126+ logger.warning("osascript timed out checking mute status")
127127+ return False
128128+ except FileNotFoundError:
129129+ logger.warning("osascript not found")
130130+ return False
131131+ except Exception as e:
132132+ logger.warning(f"Error checking output mute status: {e}")
133133+ return False
+426-81
observe/macos/observer.py
···1111import datetime
1212import logging
1313import os
1414+import shutil
1415import signal
1516import sys
1617import time
1717-from pathlib import Path
1818+1919+import av
2020+import numpy as np
18211922from observe.macos.activity import (
2023 get_idle_time_ms,
2121- get_monitor_metadata_string,
2424+ is_output_muted,
2525+ is_power_save_active,
2226 is_screen_locked,
2327)
2424-from observe.macos.screencapture import ScreenCaptureKitManager
2828+from observe.macos.screencapture import AudioInfo, DisplayInfo, ScreenCaptureKitManager
2529from think.callosum import CallosumConnection
2630from think.utils import day_path, setup_cli
2731···3034# Constants
3135IDLE_THRESHOLD_MS = 5 * 60 * 1000 # 5 minutes
3236CHUNK_DURATION = 5 # seconds
3737+RMS_THRESHOLD = 0.01
3838+MIN_HITS_FOR_SAVE = 3
3939+SAMPLE_RATE = 48000 # Standard audio sample rate
334034413542class MacOSObserver:
3643 """macOS audio and screencast observer using ScreenCaptureKit."""
37443838- def __init__(self, interval: int = 300):
4545+ def __init__(self, interval: int = 300, sck_cli_path: str = "sck-cli"):
3946 """
4047 Initialize the macOS observer.
41484249 Args:
4350 interval: Window duration in seconds (default: 300 = 5 minutes)
5151+ sck_cli_path: Path to sck-cli executable
4452 """
4553 self.interval = interval
4646- self.screencapture = ScreenCaptureKitManager()
5454+ self.screencapture = ScreenCaptureKitManager(sck_cli_path=sck_cli_path)
4755 self.running = True
4856 self.callosum: CallosumConnection | None = None
4957···5159 self.start_at = time.time() # Wall-clock for filenames
5260 self.start_at_mono = time.monotonic() # Monotonic for elapsed calculations
5361 self.capture_running = False
5454- self.current_output_base = None # Base path for current capture
5555- self.pending_finalization = (
5656- None # Tuple of (temp_base, final_video, final_audio)
5757- )
5858- self.last_video_size = 0 # Track file size for health checks
6262+6363+ # Multi-display tracking (similar to GNOME observer)
6464+ self.current_displays: list[DisplayInfo] = []
6565+ self.current_audio: AudioInfo | None = None
6666+ self.pending_finalization: list[tuple[str, str]] | None = None
6767+ self.last_video_sizes: dict[str, int] = {}
59686069 # Activity status cache (updated each loop)
6170 self.cached_is_active = False
6271 self.cached_idle_time_ms = 0
6372 self.cached_screen_locked = False
7373+ self.cached_is_muted = False
7474+ self.cached_power_save = False
7575+7676+ # Mute state at segment start
7777+ self.segment_is_muted = False
7878+7979+ # Health tracking
8080+ self.files_growing = False
64816582 async def setup(self):
6683 """Initialize ScreenCaptureKit and Callosum connection."""
6767- # TODO: Implement setup
6868- # 1. Verify sck-cli is available in PATH
6969- # 2. Start Callosum connection for status events
7070- # 3. Log initialization success
7171- logger.warning("setup() not yet implemented")
7272- return False
8484+ # Verify sck-cli is available
8585+ sck_path = shutil.which(self.screencapture.sck_cli_path)
8686+ if not sck_path:
8787+ logger.error(f"sck-cli not found: {self.screencapture.sck_cli_path}")
8888+ return False
8989+ logger.info(f"Found sck-cli at: {sck_path}")
73907474- async def check_activity_status(self) -> bool:
9191+ # Start Callosum connection for status events
9292+ self.callosum = CallosumConnection()
9393+ self.callosum.start()
9494+ logger.info("Callosum connection started")
9595+9696+ return True
9797+9898+ def check_activity_status(self) -> bool:
7599 """
76100 Check system activity status and cache values.
7710178102 Returns:
79103 True if user is active (not idle and screen unlocked)
80104 """
8181- # TODO: Implement activity checking
8282- # 1. Call get_idle_time_ms()
8383- # 2. Call is_screen_locked()
8484- # 3. Cache values for status events
8585- # 4. Determine if active (not idle and not locked)
8686- # 5. Return activity status
8787- logger.warning("check_activity_status() not yet implemented")
8888- return False
105105+ idle_time = get_idle_time_ms()
106106+ screen_locked = is_screen_locked()
107107+ power_save = is_power_save_active()
108108+ output_muted = is_output_muted()
109109+110110+ # Cache values for status events
111111+ self.cached_idle_time_ms = idle_time
112112+ self.cached_screen_locked = screen_locked
113113+ self.cached_power_save = power_save
114114+ self.cached_is_muted = output_muted
115115+116116+ is_idle = (idle_time > IDLE_THRESHOLD_MS) or screen_locked or power_save
117117+ is_active = not is_idle
118118+119119+ # Cache result
120120+ self.cached_is_active = is_active
121121+122122+ return is_active
8912390124 def get_timestamp_parts(self, timestamp: float = None) -> tuple[str, str]:
91125 """
···104138 time_part = dt.strftime("%H%M%S")
105139 return date_part, time_part
106140107107- async def handle_boundary(self, is_active: bool):
141141+ def _check_audio_threshold(self, audio_path: str) -> bool:
142142+ """
143143+ Check if audio file has enough voice activity to save.
144144+145145+ Decodes the m4a file and applies the same 3-chunk RMS threshold
146146+ logic as GNOME observer uses for real-time audio.
147147+148148+ Args:
149149+ audio_path: Path to the m4a audio file
150150+151151+ Returns:
152152+ True if audio should be saved (enough voice activity), False otherwise
153153+ """
154154+ if not os.path.exists(audio_path):
155155+ logger.warning(f"Audio file not found for threshold check: {audio_path}")
156156+ return False
157157+158158+ try:
159159+ container = av.open(audio_path)
160160+ audio_streams = list(container.streams.audio)
161161+162162+ if not audio_streams:
163163+ container.close()
164164+ logger.warning(f"No audio streams in {audio_path}")
165165+ return False
166166+167167+ stream = audio_streams[0]
168168+ sample_rate = stream.rate or SAMPLE_RATE
169169+170170+ # Decode audio and collect samples
171171+ samples = []
172172+ for frame in container.decode(stream):
173173+ arr = frame.to_ndarray()
174174+ # Convert to mono if stereo (average channels)
175175+ if arr.ndim > 1:
176176+ arr = arr.mean(axis=0)
177177+ samples.append(arr.flatten())
178178+179179+ container.close()
180180+181181+ if not samples:
182182+ logger.warning(f"No audio samples decoded from {audio_path}")
183183+ return False
184184+185185+ # Concatenate all samples
186186+ all_samples = np.concatenate(samples)
187187+188188+ # Split into CHUNK_DURATION (5 second) chunks and count threshold hits
189189+ chunk_samples = int(sample_rate * CHUNK_DURATION)
190190+ threshold_hits = 0
191191+192192+ for i in range(0, len(all_samples), chunk_samples):
193193+ chunk = all_samples[i : i + chunk_samples]
194194+ if len(chunk) == 0:
195195+ continue
196196+197197+ # Compute RMS for this chunk
198198+ rms = float(np.sqrt(np.mean(chunk**2)))
199199+ if rms > RMS_THRESHOLD:
200200+ threshold_hits += 1
201201+202202+ logger.debug(
203203+ f"Audio threshold check: {threshold_hits}/{MIN_HITS_FOR_SAVE} hits"
204204+ )
205205+ return threshold_hits >= MIN_HITS_FOR_SAVE
206206+207207+ except Exception as e:
208208+ logger.warning(f"Error checking audio threshold for {audio_path}: {e}")
209209+ # On error, keep the file (safer default)
210210+ return True
211211+212212+ def handle_boundary(self, is_active: bool):
108213 """
109214 Handle window boundary rollover.
110215111216 Args:
112217 is_active: Whether system is currently active
113218 """
114114- # TODO: Implement boundary handling
115115- # 1. Get timestamp parts and calculate duration
116116- # 2. Stop current capture if running
117117- # 3. Queue files for finalization (temp paths -> final paths with duration)
118118- # 4. Reset timing for new window
119119- # 5. Start new capture if active and screen not locked
120120- # 6. Emit Callosum observing event with saved files
121121- logger.warning("handle_boundary() not yet implemented")
219219+ # Get timestamp parts for this window and calculate duration
220220+ date_part, time_part = self.get_timestamp_parts(self.start_at)
221221+ duration = int(time.time() - self.start_at)
222222+ day_dir = day_path(date_part)
223223+224224+ saved_files: list[str] = []
225225+ finalizations: list[tuple[str, str]] = []
226226+227227+ if self.capture_running:
228228+ logger.info("Stopping previous capture")
229229+ self.screencapture.stop()
230230+ self.capture_running = False
231231+232232+ # Build finalization list for video files
233233+ for display in self.current_displays:
234234+ if os.path.exists(display.temp_path):
235235+ final_name = display.final_name(time_part, duration)
236236+ final_path = str(day_dir / final_name)
237237+ finalizations.append((display.temp_path, final_path))
238238+ saved_files.append(final_name)
239239+240240+ # Check audio threshold before including in finalization
241241+ if self.current_audio and os.path.exists(self.current_audio.temp_path):
242242+ if self._check_audio_threshold(self.current_audio.temp_path):
243243+ final_name = self.current_audio.final_name(time_part, duration)
244244+ final_path = str(day_dir / final_name)
245245+ finalizations.append((self.current_audio.temp_path, final_path))
246246+ saved_files.append(final_name)
247247+ logger.info(f"Audio passed threshold check, saving: {final_name}")
248248+ else:
249249+ # Delete the temp audio file
250250+ try:
251251+ os.remove(self.current_audio.temp_path)
252252+ logger.info("Audio below threshold, discarded")
253253+ except OSError as e:
254254+ logger.warning(f"Failed to remove audio file: {e}")
255255+256256+ # Clear state
257257+ self.current_displays = []
258258+ self.current_audio = None
259259+ self.last_video_sizes = {}
260260+261261+ if finalizations:
262262+ self.pending_finalization = finalizations
263263+264264+ # Reset timing for new window
265265+ self.start_at = time.time()
266266+ self.start_at_mono = time.monotonic()
267267+268268+ # Update segment mute state
269269+ self.segment_is_muted = self.cached_is_muted
270270+271271+ # Start new capture if active and screen not locked
272272+ if is_active and not self.cached_screen_locked:
273273+ self.initialize_capture()
274274+275275+ # Emit observing event with saved files
276276+ if saved_files and self.callosum:
277277+ segment = f"{time_part}_{duration}"
278278+ self.callosum.emit(
279279+ "observe",
280280+ "observing",
281281+ segment=segment,
282282+ files=saved_files,
283283+ )
284284+ logger.info(f"Segment observing: {segment} ({len(saved_files)} files)")
122285123123- async def initialize_capture(self) -> bool:
286286+ def initialize_capture(self) -> bool:
124287 """
125288 Start a new screencast and audio recording.
126289127290 Returns:
128291 True if capture started successfully, False otherwise
129292 """
130130- # TODO: Implement capture initialization
131131- # 1. Get timestamp for filename
132132- # 2. Build temp output base path (e.g., .HHMMSS)
133133- # 3. Start sck-cli via ScreenCaptureKitManager
134134- # 4. Update state tracking variables
135135- # 5. Log capture start
136136- logger.warning("initialize_capture() not yet implemented")
137137- return False
293293+ date_part, time_part = self.get_timestamp_parts(self.start_at)
294294+ day_dir = day_path(date_part)
295295+296296+ # Ensure day directory exists
297297+ day_dir.mkdir(parents=True, exist_ok=True)
298298+299299+ # Build temp output base (hidden file)
300300+ output_base = day_dir / f".{time_part}"
301301+302302+ try:
303303+ displays, audio = self.screencapture.start(
304304+ output_base, self.interval, frame_rate=1.0
305305+ )
306306+ except RuntimeError as e:
307307+ logger.error(f"Failed to start capture: {e}")
308308+ return False
309309+310310+ self.current_displays = displays
311311+ self.current_audio = audio
312312+ self.capture_running = True
313313+ self.last_video_sizes = {d.temp_path: 0 for d in displays}
314314+315315+ logger.info(f"Started capture with {len(displays)} display(s)")
316316+ for display in displays:
317317+ logger.info(
318318+ f" Display {display.display_id}: {display.position} -> {display.temp_path}"
319319+ )
320320+ if audio:
321321+ logger.info(f" Audio: {audio.temp_path}")
322322+323323+ return True
138324139325 def emit_status(self):
140326 """Emit observe.status event with current state."""
141141- # TODO: Implement status emission
142142- # 1. Build capture info dict (recording status, file path, elapsed time)
143143- # 2. Build activity info dict (active, idle_time_ms, screen_locked)
144144- # 3. Emit via Callosum
145145- logger.warning("emit_status() not yet implemented")
327327+ if not self.callosum:
328328+ return
329329+330330+ journal_path = os.getenv("JOURNAL_PATH", "")
331331+332332+ # Build capture info
333333+ if self.capture_running and self.current_displays:
334334+ elapsed = int(time.monotonic() - self.start_at_mono)
335335+ displays_info = []
336336+ for display in self.current_displays:
337337+ try:
338338+ rel_file = (
339339+ os.path.relpath(display.temp_path, journal_path)
340340+ if journal_path
341341+ else display.temp_path
342342+ )
343343+ except ValueError:
344344+ rel_file = display.temp_path
345345+346346+ displays_info.append(
347347+ {
348348+ "position": display.position,
349349+ "display_id": display.display_id,
350350+ "file": rel_file,
351351+ }
352352+ )
353353+354354+ capture_info = {
355355+ "recording": True,
356356+ "displays": displays_info,
357357+ "window_elapsed_seconds": elapsed,
358358+ "files_growing": self.files_growing,
359359+ }
360360+ else:
361361+ capture_info = {"recording": False, "files_growing": False}
362362+363363+ # Activity info
364364+ activity_info = {
365365+ "active": self.cached_is_active,
366366+ "idle_time_ms": self.cached_idle_time_ms,
367367+ "screen_locked": self.cached_screen_locked,
368368+ "power_save": self.cached_power_save,
369369+ "sink_muted": self.cached_is_muted,
370370+ }
371371+372372+ self.callosum.emit(
373373+ "observe",
374374+ "status",
375375+ capture=capture_info,
376376+ activity=activity_info,
377377+ )
146378147147- async def finalize_capture(
148148- self, temp_base: Path, final_video: Path, final_audio: Path
149149- ):
379379+ def finalize_screencast(self, temp_path: str, final_path: str):
150380 """
151151- Add monitor metadata to video and rename files from temp to final paths.
381381+ Rename capture file from temp to final path.
152382153383 Args:
154154- temp_base: Temporary base path (e.g., /path/.HHMMSS)
155155- final_video: Final video path (e.g., /path/HHMMSS_300_screen.mov)
156156- final_audio: Final audio path (e.g., /path/HHMMSS_300_audio.m4a)
384384+ temp_path: Temporary file path
385385+ final_path: Final destination path
157386 """
158158- # TODO: Implement finalization
159159- # 1. Check if temp files exist
160160- # 2. Get monitor metadata string
161161- # 3. Call screencapture.finalize() to add metadata and rename
162162- # 4. Log success/failure
163163- logger.warning("finalize_capture() not yet implemented")
387387+ if not os.path.exists(temp_path):
388388+ logger.warning(f"Capture file not found: {temp_path}")
389389+ return
390390+391391+ try:
392392+ os.replace(temp_path, final_path)
393393+ logger.info(f"Finalized: {final_path}")
394394+ except OSError as e:
395395+ logger.error(f"Failed to rename {temp_path} to {final_path}: {e}")
164396165397 async def main_loop(self):
166398 """Run the main observer loop."""
167167- # TODO: Implement main loop
168168- # 1. Check activity and start capture if active
169169- # 2. Loop with CHUNK_DURATION sleep intervals:
170170- # a. Process pending finalization if queued
171171- # b. Check activity status
172172- # c. Detect activation edge (idle -> active transition)
173173- # d. Check for window boundary or activation edge
174174- # e. Handle boundary if needed
175175- # f. Touch health files (see, hear)
176176- # g. Emit status event
177177- # 3. Call shutdown on exit
178178- logger.warning("main_loop() not yet implemented")
399399+ logger.info(f"Starting observer loop (interval={self.interval}s)")
400400+401401+ # Check initial activity and start capture if active
402402+ is_active = self.check_activity_status()
403403+ self.segment_is_muted = self.cached_is_muted
404404+405405+ if is_active and not self.cached_screen_locked:
406406+ if not self.initialize_capture():
407407+ logger.error("Failed to start initial capture")
408408+ self.running = False
409409+ return
410410+411411+ while self.running:
412412+ # Sleep for chunk duration
413413+ await asyncio.sleep(CHUNK_DURATION)
414414+415415+ # Process pending finalizations
416416+ if self.pending_finalization:
417417+ for temp_path, final_path in self.pending_finalization:
418418+ if os.path.exists(temp_path):
419419+ self.finalize_screencast(temp_path, final_path)
420420+ else:
421421+ logger.warning(f"Pending file not found: {temp_path}")
422422+ self.pending_finalization = None
423423+424424+ # Check activity status
425425+ is_active = self.check_activity_status()
426426+427427+ # Check if sck-cli process died unexpectedly
428428+ if self.capture_running and not self.screencapture.is_running():
429429+ logger.warning("Capture process died, handling boundary")
430430+ self.handle_boundary(is_active)
431431+ continue
432432+433433+ # Detect activation edge (idle -> active transition)
434434+ activation_edge = is_active and not self.capture_running
435435+436436+ # Detect mute state transition
437437+ mute_transition = self.cached_is_muted != self.segment_is_muted
438438+ if mute_transition:
439439+ logger.info(
440440+ f"Mute state changed: "
441441+ f"{'muted' if self.segment_is_muted else 'unmuted'} -> "
442442+ f"{'muted' if self.cached_is_muted else 'unmuted'}"
443443+ )
444444+445445+ # Check for window boundary (use monotonic to avoid DST/clock jumps)
446446+ now_mono = time.monotonic()
447447+ elapsed = now_mono - self.start_at_mono
448448+ is_boundary = (
449449+ (elapsed >= self.interval) or activation_edge or mute_transition
450450+ )
451451+452452+ if is_boundary:
453453+ logger.info(
454454+ f"Boundary: elapsed={elapsed:.1f}s edge={activation_edge} "
455455+ f"mute_change={mute_transition}"
456456+ )
457457+ self.handle_boundary(is_active)
458458+459459+ # Check if capture files are actively growing (health indicator)
460460+ if self.capture_running and self.current_displays:
461461+ any_growing = False
462462+ for display in self.current_displays:
463463+ if os.path.exists(display.temp_path):
464464+ current_size = os.path.getsize(display.temp_path)
465465+ last_size = self.last_video_sizes.get(display.temp_path, 0)
466466+ if current_size > last_size:
467467+ any_growing = True
468468+ self.last_video_sizes[display.temp_path] = current_size
469469+ self.files_growing = any_growing
470470+ else:
471471+ self.files_growing = False
472472+473473+ # Emit status event
474474+ self.emit_status()
475475+476476+ # Cleanup on exit
477477+ logger.info("Observer loop stopped, cleaning up...")
478478+ await self.shutdown()
179479180480 async def shutdown(self):
181481 """Clean shutdown of observer."""
182182- # TODO: Implement shutdown
183183- # 1. Stop current capture if running
184184- # 2. Wait for files to be written
185185- # 3. Finalize pending captures
186186- # 4. Stop Callosum connection
187187- # 5. Log shutdown complete
188188- logger.warning("shutdown() not yet implemented")
482482+ # Stop capture if running
483483+ if self.capture_running:
484484+ logger.info("Stopping capture for shutdown")
485485+ self.screencapture.stop()
486486+487487+ # Brief delay for files to be written
488488+ await asyncio.sleep(0.5)
489489+490490+ # Get timestamp parts for finalization
491491+ date_part, time_part = self.get_timestamp_parts(self.start_at)
492492+ duration = int(time.time() - self.start_at)
493493+ day_dir = day_path(date_part)
494494+495495+ # Finalize video files
496496+ for display in self.current_displays:
497497+ if os.path.exists(display.temp_path):
498498+ final_name = display.final_name(time_part, duration)
499499+ final_path = str(day_dir / final_name)
500500+ self.finalize_screencast(display.temp_path, final_path)
501501+502502+ # Check and finalize audio if threshold met
503503+ if self.current_audio and os.path.exists(self.current_audio.temp_path):
504504+ if self._check_audio_threshold(self.current_audio.temp_path):
505505+ final_name = self.current_audio.final_name(time_part, duration)
506506+ final_path = str(day_dir / final_name)
507507+ self.finalize_screencast(self.current_audio.temp_path, final_path)
508508+ else:
509509+ try:
510510+ os.remove(self.current_audio.temp_path)
511511+ logger.info("Final audio below threshold, discarded")
512512+ except OSError:
513513+ pass
514514+515515+ self.capture_running = False
516516+517517+ # Process any remaining pending finalizations
518518+ if self.pending_finalization:
519519+ await asyncio.sleep(0.5)
520520+ for temp_path, final_path in self.pending_finalization:
521521+ if os.path.exists(temp_path):
522522+ self.finalize_screencast(temp_path, final_path)
523523+ self.pending_finalization = None
524524+525525+ # Stop Callosum connection
526526+ if self.callosum:
527527+ self.callosum.stop()
528528+ logger.info("Callosum connection stopped")
529529+530530+ logger.info("Shutdown complete")
189531190532191533async def async_main(args):
192534 """Async entry point."""
193193- observer = MacOSObserver(interval=args.interval)
535535+ observer = MacOSObserver(
536536+ interval=args.interval,
537537+ sck_cli_path=args.sck_cli_path,
538538+ )
194539195540 # Setup signal handlers
196541 loop = asyncio.get_running_loop()
+184-102
observe/macos/screencapture.py
···11"""ScreenCaptureKit integration via sck-cli subprocess.
2233This module manages the sck-cli subprocess lifecycle for video and audio capture
44-on macOS using ScreenCaptureKit.
44+on macOS using ScreenCaptureKit. sck-cli captures all displays simultaneously
55+and outputs JSONL metadata to stdout with display geometry information.
56"""
6788+import json
79import logging
88-import os
1010+import signal
911import subprocess
1212+from dataclasses import dataclass
1013from pathlib import Path
1114from typing import Optional
1515+1616+from observe.utils import assign_monitor_positions
12171318logger = logging.getLogger(__name__)
141915202121+@dataclass
2222+class DisplayInfo:
2323+ """Information about a single display's recording."""
2424+2525+ display_id: int
2626+ position: str
2727+ x: int
2828+ y: int
2929+ width: int
3030+ height: int
3131+ temp_path: str
3232+3333+ def final_name(self, time_part: str, duration: int) -> str:
3434+ """Generate the final filename for this display's video."""
3535+ return f"{time_part}_{duration}_{self.position}_{self.display_id}_screen.mov"
3636+3737+3838+@dataclass
3939+class AudioInfo:
4040+ """Information about the audio recording."""
4141+4242+ temp_path: str
4343+ tracks: list[str]
4444+4545+ def final_name(self, time_part: str, duration: int) -> str:
4646+ """Generate the final filename for audio."""
4747+ return f"{time_part}_{duration}_audio.m4a"
4848+4949+1650class ScreenCaptureKitManager:
1751 """
1852 Manages sck-cli subprocess for synchronized video and audio capture.
19532054 Wraps the sck-cli tool to provide lifecycle management, handles process
2121- monitoring, and manages output file finalization with metadata.
5555+ monitoring, parses JSONL output for display geometry, and manages output
5656+ file finalization.
2257 """
23582459 def __init__(self, sck_cli_path: str = "sck-cli"):
···3065 """
3166 self.sck_cli_path = sck_cli_path
3267 self.process: Optional[subprocess.Popen] = None
3333- self.current_output_base: Optional[str] = None
6868+ self.displays: list[DisplayInfo] = []
6969+ self.audio: Optional[AudioInfo] = None
7070+ self.output_base: Optional[Path] = None
34713572 def start(
3673 self,
3774 output_base: Path,
3875 duration: int,
3976 frame_rate: float = 1.0,
4040- ) -> bool:
7777+ ) -> tuple[list[DisplayInfo], Optional[AudioInfo]]:
4178 """
4242- Start video and audio capture to temporary files.
7979+ Start video and audio capture.
43804481 Launches sck-cli as a subprocess with the specified parameters.
4545- Files are written to output_base.mov and output_base.m4a.
8282+ Parses JSONL output from stdout to get display geometry information.
8383+ Files are written to output_base_<displayID>.mov and output_base.m4a.
46844785 Args:
4886 output_base: Base path for output files (without extension)
···5088 frame_rate: Frame rate in Hz (default: 1.0)
51895290 Returns:
5353- True if subprocess started successfully, False otherwise
9191+ Tuple of (list of DisplayInfo, AudioInfo or None)
9292+9393+ Raises:
9494+ RuntimeError: If sck-cli fails to start or returns no displays
54955596 Example:
5697 >>> manager = ScreenCaptureKitManager()
5798 >>> day_dir = Path("journal/20250101")
5899 >>> output_base = day_dir / ".120000" # Hidden temp file
5959- >>> manager.start(output_base, duration=300, frame_rate=1.0)
6060- True
100100+ >>> displays, audio = manager.start(output_base, duration=300)
61101 """
6262- # TODO: Implement subprocess launch
6363- # Command: sck-cli <output_base> -r <frame_rate> -l <duration>
6464- # Store process handle in self.process
6565- # Store output_base in self.current_output_base
6666- # Check if sck-cli is available in PATH
6767- # Handle launch errors and log appropriately
6868- logger.warning("start() not yet implemented")
6969- return False
102102+ self.output_base = output_base
701037171- def stop(self) -> None:
7272- """
7373- Stop the running capture gracefully.
104104+ # Build command
105105+ cmd = [
106106+ self.sck_cli_path,
107107+ str(output_base),
108108+ "-r",
109109+ str(frame_rate),
110110+ "-l",
111111+ str(duration),
112112+ ]
741137575- Sends SIGTERM to the sck-cli process and waits for it to finish writing
7676- files properly. This ensures video and audio files are finalized correctly.
114114+ logger.info(f"Starting sck-cli: {' '.join(cmd)}")
771157878- Example:
7979- >>> manager.stop()
8080- # sck-cli receives SIGTERM and finishes writing files
8181- """
8282- # TODO: Implement graceful shutdown
8383- # 1. Check if self.process exists and is running
8484- # 2. Send SIGTERM signal
8585- # 3. Wait with timeout (e.g., 5 seconds) for process to complete
8686- # 4. If timeout, send SIGKILL as fallback
8787- # 5. Clean up process handle
8888- logger.warning("stop() not yet implemented")
116116+ try:
117117+ self.process = subprocess.Popen(
118118+ cmd,
119119+ stdout=subprocess.PIPE,
120120+ stderr=subprocess.PIPE,
121121+ text=True,
122122+ )
123123+ except FileNotFoundError:
124124+ raise RuntimeError(f"sck-cli not found at: {self.sck_cli_path}")
125125+ except Exception as e:
126126+ raise RuntimeError(f"Failed to start sck-cli: {e}")
891279090- def is_running(self) -> bool:
9191- """
9292- Check if the capture subprocess is currently running.
128128+ # Read JSONL metadata from stdout (sck-cli outputs this immediately)
129129+ # Each line is either a display info or audio info
130130+ displays_raw = []
131131+ audio_info = None
931329494- Returns:
9595- True if subprocess is active, False otherwise
133133+ # Read lines until we get all metadata (sck-cli outputs then starts capture)
134134+ # We need to read non-blocking since the process keeps running
135135+ try:
136136+ for line in self.process.stdout:
137137+ line = line.strip()
138138+ if not line:
139139+ continue
140140+ try:
141141+ data = json.loads(line)
142142+ if data.get("type") == "display":
143143+ displays_raw.append(data)
144144+ elif data.get("type") == "audio":
145145+ audio_info = data
146146+ except json.JSONDecodeError:
147147+ # Not JSON, might be a log message - ignore
148148+ pass
961499797- Example:
9898- >>> if manager.is_running():
9999- ... print("Capture in progress")
100100- """
101101- # TODO: Implement process status check
102102- # Check if self.process is not None and self.process.poll() is None
103103- return False
150150+ # sck-cli outputs all metadata before starting capture
151151+ # Once we have both displays and audio (or displays only if no audio)
152152+ # we can stop reading. But we also need to not block forever.
153153+ # Actually, sck-cli flushes stdout after metadata, so readline
154154+ # will return empty when no more data. But process is still running.
155155+ # We break after getting audio info or when stdout blocks.
156156+ if audio_info is not None:
157157+ break
158158+ except Exception as e:
159159+ logger.warning(f"Error reading sck-cli stdout: {e}")
104160105105- def finalize(
106106- self,
107107- temp_base: Path,
108108- final_video_path: Path,
109109- final_audio_path: Path,
110110- monitor_metadata: str,
111111- ) -> tuple[bool, bool]:
112112- """
113113- Finalize capture files: add metadata and rename to final paths.
161161+ if not displays_raw:
162162+ self.stop()
163163+ raise RuntimeError("sck-cli returned no display information")
114164115115- Takes temporary output files from sck-cli, adds monitor geometry metadata
116116- to the video file, and renames both files to their final destinations with
117117- duration in the filename.
165165+ # Convert raw display data to monitor format for position assignment
166166+ monitors = []
167167+ for d in displays_raw:
168168+ x = int(d.get("x", 0))
169169+ y = int(d.get("y", 0))
170170+ width = int(d.get("width", 0))
171171+ height = int(d.get("height", 0))
172172+ monitors.append(
173173+ {
174174+ "id": str(d["displayID"]),
175175+ "box": [x, y, x + width, y + height],
176176+ "_raw": d,
177177+ }
178178+ )
118179119119- Args:
120120- temp_base: Base path of temporary files (without extension)
121121- final_video_path: Final path for video file (HHMMSS_DURATION_screen.mov)
122122- final_audio_path: Final path for audio file (HHMMSS_DURATION_audio.m4a)
123123- monitor_metadata: Monitor geometry string to embed in video metadata
180180+ # Assign position labels based on geometry
181181+ monitors = assign_monitor_positions(monitors)
124182125125- Returns:
126126- Tuple of (video_success, audio_success) booleans
183183+ # Build DisplayInfo objects
184184+ self.displays = []
185185+ for mon in monitors:
186186+ raw = mon["_raw"]
187187+ self.displays.append(
188188+ DisplayInfo(
189189+ display_id=raw["displayID"],
190190+ position=mon["position"],
191191+ x=mon["box"][0],
192192+ y=mon["box"][1],
193193+ width=mon["box"][2] - mon["box"][0],
194194+ height=mon["box"][3] - mon["box"][1],
195195+ temp_path=raw["filename"],
196196+ )
197197+ )
198198+199199+ # Build AudioInfo if present
200200+ if audio_info:
201201+ tracks = [t["name"] for t in audio_info.get("tracks", [])]
202202+ self.audio = AudioInfo(
203203+ temp_path=audio_info["filename"],
204204+ tracks=tracks,
205205+ )
206206+ else:
207207+ self.audio = None
208208+209209+ logger.info(f"sck-cli started with {len(self.displays)} display(s)")
210210+ for display in self.displays:
211211+ logger.info(
212212+ f" Display {display.display_id}: {display.position} "
213213+ f"({display.width}x{display.height}) -> {display.temp_path}"
214214+ )
215215+ if self.audio:
216216+ logger.info(f" Audio: {self.audio.temp_path} ({self.audio.tracks})")
127217128128- Example:
129129- >>> metadata = "0:center,0,0,1920,1080"
130130- >>> manager.finalize(
131131- ... Path("journal/20250101/.120000"),
132132- ... Path("journal/20250101/120000_300_screen.mov"),
133133- ... Path("journal/20250101/120000_300_audio.m4a"),
134134- ... metadata
135135- ... )
136136- (True, True)
218218+ return self.displays, self.audio
137219138138- Notes:
139139- - Uses ffmpeg or similar to update video metadata
140140- - Atomically renames files to avoid partial writes
141141- - Logs errors if files are missing or operations fail
220220+ def stop(self) -> None:
142221 """
143143- # TODO: Implement file finalization
144144- # 1. Check if temp files exist (temp_base.mov, temp_base.m4a)
145145- # 2. Add monitor metadata to video title:
146146- # - Use ffmpeg: `ffmpeg -i input.mov -metadata title="..." -c copy output.mov`
147147- # - Or use PyObjC AVFoundation APIs to modify metadata
148148- # 3. Atomically rename temp_base.mov -> final_video_path
149149- # 4. Atomically rename temp_base.m4a -> final_audio_path
150150- # 5. Return success status for each file
151151- # 6. Handle errors gracefully (missing files, permission issues, etc.)
152152- logger.warning("finalize() not yet implemented")
153153- return False, False
222222+ Stop the running capture gracefully.
154223155155- def get_output_size(self) -> int:
224224+ Sends SIGTERM to the sck-cli process and waits for it to finish writing
225225+ files properly. This ensures video and audio files are finalized correctly.
156226 """
157157- Get the current size of the video output file.
227227+ if self.process is None:
228228+ return
158229159159- Used for health checks to verify the file is growing during capture.
230230+ if self.process.poll() is None:
231231+ logger.info("Stopping sck-cli...")
232232+ try:
233233+ self.process.send_signal(signal.SIGTERM)
234234+ try:
235235+ self.process.wait(timeout=5)
236236+ except subprocess.TimeoutExpired:
237237+ logger.warning("sck-cli did not exit cleanly, killing")
238238+ self.process.kill()
239239+ self.process.wait()
240240+ except Exception as e:
241241+ logger.warning(f"Error stopping sck-cli: {e}")
242242+243243+ self.process = None
244244+245245+ def is_running(self) -> bool:
246246+ """
247247+ Check if the capture subprocess is currently running.
160248161249 Returns:
162162- File size in bytes, or 0 if file doesn't exist or not capturing
163163-164164- Example:
165165- >>> size = manager.get_output_size()
166166- >>> print(f"Captured {size / 1024 / 1024:.1f} MB so far")
250250+ True if subprocess is active, False otherwise
167251 """
168168- # TODO: Implement file size check
169169- # Check if self.current_output_base exists
170170- # Check if .mov file exists and return its size
171171- # Return 0 if not found or not capturing
172172- return 0
252252+ if self.process is None:
253253+ return False
254254+ return self.process.poll() is None
+37
observe/observer.py
···11+"""Unified observer entry point with platform detection.
22+33+Detects the current platform and delegates to the appropriate
44+platform-specific observer implementation.
55+"""
66+77+import sys
88+99+1010+def main() -> None:
1111+ """Platform-aware observer entry point.
1212+1313+ Detects the current platform and calls the appropriate observer:
1414+ - macOS (darwin): observe.macos.observer
1515+ - Linux: observe.gnome.observer
1616+1717+ All command-line arguments are passed through to the platform-specific
1818+ implementation via its main() function.
1919+ """
2020+ platform = sys.platform
2121+2222+ if platform == "darwin":
2323+ from observe.macos.observer import main as platform_main
2424+ elif platform == "linux":
2525+ from observe.gnome.observer import main as platform_main
2626+ else:
2727+ print(
2828+ f"Error: Observer not available for platform '{platform}'", file=sys.stderr
2929+ )
3030+ print("Supported platforms: macOS (darwin), Linux", file=sys.stderr)
3131+ sys.exit(1)
3232+3333+ platform_main()
3434+3535+3636+if __name__ == "__main__":
3737+ main()
+13-7
observe/utils.py
···147147148148def parse_screen_filename(filename: str) -> tuple[str, str]:
149149 """
150150- Parse position and connector from a per-monitor screen filename.
150150+ Parse position and connector/displayID from a per-monitor screen filename.
151151152152 Handles both pre-move filenames (with segment prefix) and post-move filenames
153153- (in segment directory without prefix).
153153+ (in segment directory without prefix). Works with both GNOME connector IDs
154154+ (e.g., "DP-3") and macOS displayIDs (e.g., "1").
154155155156 Parameters
156157 ----------
157158 filename : str
158159 Filename stem (without extension), e.g.:
159159- - "143022_300_center_DP-3_screen" (pre-move, in day root)
160160- - "center_DP-3_screen" (post-move, in segment directory)
160160+ - "143022_300_center_DP-3_screen" (GNOME pre-move)
161161+ - "143022_300_center_1_screen" (macOS pre-move)
162162+ - "center_DP-3_screen" (GNOME post-move)
163163+ - "center_1_screen" (macOS post-move)
161164162165 Returns
163166 -------
164167 tuple[str, str]
165165- (position, connector) tuple, e.g., ("center", "DP-3")
168168+ (position, connector) tuple, e.g., ("center", "DP-3") or ("center", "1")
166169 Returns ("unknown", "unknown") if pattern doesn't match
167170168171 Examples
169172 --------
170173 >>> parse_screen_filename("143022_300_center_DP-3_screen")
171174 ("center", "DP-3")
175175+ >>> parse_screen_filename("143022_300_center_1_screen")
176176+ ("center", "1")
172177 >>> parse_screen_filename("center_DP-3_screen")
173178 ("center", "DP-3")
179179+ >>> parse_screen_filename("center_1_screen")
180180+ ("center", "1")
174181 >>> parse_screen_filename("143022_300_screen")
175182 ("unknown", "unknown")
176176- >>> parse_screen_filename("screen")
177177- ("unknown", "unknown")
178183 """
179184 # Pattern 1: HHMMSS_LEN_position_connector_screen (pre-move)
185185+ # Connector can be alphanumeric with hyphens (GNOME: DP-3) or just numeric (macOS: 1)
180186 match = re.match(r"^\d{6}_\d+_([a-z-]+)_([A-Za-z0-9-]+)_screen$", filename)
181187 if match:
182188 return match.group(1), match.group(2)