overcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,454 @@
1
+ """
2
+ presence_logger.py
3
+
4
+ Mac-only presence logger that records user presence/absence stats.
5
+
6
+ Records once per SAMPLE_INTERVAL:
7
+ - timestamp (ISO8601 local time)
8
+ - state (1=locked/sleep, 2=screen on inactive, 3=screen on active)
9
+ - idle_seconds
10
+ - locked (0/1)
11
+ - inferred_sleep (0/1)
12
+
13
+ Data is appended to:
14
+ ~/.overcode/presence_log.csv
15
+
16
+ Usage from another Python app:
17
+
18
+ from overcode.presence_logger import start_background_logger
19
+
20
+ logger = start_background_logger()
21
+ # ... your app runs ...
22
+ logger.stop() # optional, logs until process exits anyway
23
+
24
+ CLI usage:
25
+
26
+ overcode presence
27
+
28
+ to run it in the foreground.
29
+ """
30
+
31
+ import csv
32
+ import datetime as dt
33
+ import os
34
+ import sys
35
+ import threading
36
+ import time
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Optional
40
+
41
+ from .pid_utils import is_process_running, get_process_pid, write_pid_file, remove_pid_file
42
+
43
+ # Check for macOS-specific dependencies
44
+ try:
45
+ from Quartz import (
46
+ CGEventSourceSecondsSinceLastEventType,
47
+ kCGEventSourceStateCombinedSessionState,
48
+ kCGAnyInputEventType,
49
+ )
50
+ from ApplicationServices import CGSessionCopyCurrentDictionary
51
+ MACOS_APIS_AVAILABLE = True
52
+ except ImportError:
53
+ MACOS_APIS_AVAILABLE = False
54
+
55
+
56
+ # ---- config -----------------------------------------------------------------
57
+
58
+ OVERCODE_DIR = Path.home() / ".overcode"
59
+ PRESENCE_PID_FILE = OVERCODE_DIR / "presence.pid"
60
+ DEFAULT_SAMPLE_INTERVAL = 60 # seconds
61
+ DEFAULT_IDLE_THRESHOLD = 60 # seconds
62
+
63
+
64
+ def is_presence_running() -> bool:
65
+ """Check if the presence logger process is currently running.
66
+
67
+ Returns True if PID file exists and process is alive.
68
+ """
69
+ return is_process_running(PRESENCE_PID_FILE)
70
+
71
+
72
+ def get_presence_pid() -> Optional[int]:
73
+ """Get the presence logger PID if running, None otherwise."""
74
+ return get_process_pid(PRESENCE_PID_FILE)
75
+
76
+
77
+ def _write_pid_file() -> None:
78
+ """Write current PID to file."""
79
+ write_pid_file(PRESENCE_PID_FILE)
80
+
81
+
82
+ def _remove_pid_file() -> None:
83
+ """Remove PID file."""
84
+ remove_pid_file(PRESENCE_PID_FILE)
85
+
86
+
87
+ def default_log_path() -> str:
88
+ """Return default CSV path under ~/.overcode/."""
89
+ OVERCODE_DIR.mkdir(parents=True, exist_ok=True)
90
+ return str(OVERCODE_DIR / "presence_log.csv")
91
+
92
+
93
+ @dataclass
94
+ class PresenceLoggerConfig:
95
+ sample_interval: int = DEFAULT_SAMPLE_INTERVAL
96
+ idle_threshold: int = DEFAULT_IDLE_THRESHOLD
97
+ log_path: str = ""
98
+
99
+ def __post_init__(self):
100
+ if not self.log_path:
101
+ self.log_path = default_log_path()
102
+
103
+
104
+ # ---- low-level state helpers -----------------------------------------------
105
+
106
+
107
+ def get_idle_seconds() -> float:
108
+ """Seconds since last user input (mouse/keyboard) in current session."""
109
+ if not MACOS_APIS_AVAILABLE:
110
+ return 0.0
111
+ return CGEventSourceSecondsSinceLastEventType(
112
+ kCGEventSourceStateCombinedSessionState,
113
+ kCGAnyInputEventType,
114
+ )
115
+
116
+
117
+ def is_screen_locked() -> bool:
118
+ """
119
+ Try to detect if the screen/session is locked.
120
+
121
+ This relies on keys in CGSessionCopyCurrentDictionary; may vary by macOS
122
+ version but works on most modern versions.
123
+ """
124
+ if not MACOS_APIS_AVAILABLE:
125
+ return False
126
+
127
+ session_info = CGSessionCopyCurrentDictionary()
128
+ if not session_info:
129
+ return False
130
+
131
+ # Common key; default to 0 if missing
132
+ if session_info.get("CGSSessionScreenIsLocked", 0):
133
+ return True
134
+
135
+ # Fallback heuristic: if there is an explicit "kCGSessionOnConsoleKey"
136
+ # and it's false, treat as locked. This is more conservative.
137
+ on_console = session_info.get("kCGSessionOnConsoleKey")
138
+ if isinstance(on_console, bool) and not on_console:
139
+ return True
140
+
141
+ return False
142
+
143
+
144
+ def infer_sleep(last_ts: Optional[dt.datetime],
145
+ now: dt.datetime,
146
+ sample_interval: int) -> bool:
147
+ """
148
+ Infer whether the machine likely slept between last_ts and now, based on
149
+ a gap larger than ~2x the sample interval.
150
+ """
151
+ if last_ts is None:
152
+ return False
153
+ gap = (now - last_ts).total_seconds()
154
+ return gap > 2 * sample_interval
155
+
156
+
157
+ def classify_state(locked: bool,
158
+ idle_seconds: float,
159
+ slept: bool,
160
+ idle_threshold: int) -> int:
161
+ """
162
+ Map low-level measures to 3 presence states:
163
+
164
+ 1: screen locked/hibernating
165
+ 2: screen on, inactive (idle > idle_threshold)
166
+ 3: screen on, active
167
+ """
168
+ if locked or slept:
169
+ return 1
170
+ elif idle_seconds > idle_threshold:
171
+ return 2
172
+ else:
173
+ return 3
174
+
175
+
176
+ def state_to_name(state: int) -> str:
177
+ """Convert state number to human-readable name."""
178
+ return {
179
+ 1: "locked/sleep",
180
+ 2: "inactive",
181
+ 3: "active",
182
+ }.get(state, "unknown")
183
+
184
+
185
+ # ---- main logger class ------------------------------------------------------
186
+
187
+
188
+ class PresenceLogger:
189
+ """
190
+ Background presence logger.
191
+
192
+ - Call .start() to spin up a daemon thread that logs continuously.
193
+ - Call .stop() to ask it to shut down cleanly.
194
+ """
195
+
196
+ def __init__(self, config: Optional[PresenceLoggerConfig] = None):
197
+ self.config = config or PresenceLoggerConfig()
198
+ self._thread: Optional[threading.Thread] = None
199
+ self._stop_event = threading.Event()
200
+ self._lock = threading.Lock() # protect start/stop
201
+ self._last_state: Optional[int] = None
202
+
203
+ def start(self) -> None:
204
+ """Start the background logging thread (idempotent)."""
205
+ with self._lock:
206
+ if self._thread is not None and self._thread.is_alive():
207
+ return
208
+ self._stop_event.clear()
209
+ self._thread = threading.Thread(
210
+ target=self._run, name="PresenceLoggerThread", daemon=True
211
+ )
212
+ self._thread.start()
213
+
214
+ def stop(self, timeout: Optional[float] = None) -> None:
215
+ """Request the logger to stop and optionally wait for it."""
216
+ with self._lock:
217
+ if self._thread is None:
218
+ return
219
+ self._stop_event.set()
220
+ self._thread.join(timeout=timeout)
221
+ # Don't reuse threads
222
+ self._thread = None
223
+
224
+ def get_current_state(self) -> tuple[int, float, bool]:
225
+ """Get current presence state without logging.
226
+
227
+ Returns:
228
+ Tuple of (state, idle_seconds, is_locked)
229
+ """
230
+ idle = get_idle_seconds()
231
+ locked = is_screen_locked()
232
+ state = classify_state(
233
+ locked=locked,
234
+ idle_seconds=idle,
235
+ slept=False, # Can't infer sleep without history
236
+ idle_threshold=self.config.idle_threshold,
237
+ )
238
+ return state, idle, locked
239
+
240
+ def _run(self) -> None:
241
+ """
242
+ Main loop: every sample_interval seconds, log one row to CSV.
243
+
244
+ CSV columns:
245
+ timestamp_iso, state, idle_seconds, locked, inferred_sleep
246
+ """
247
+ cfg = self.config
248
+ last_ts: Optional[dt.datetime] = None
249
+
250
+ # Ensure directory exists
251
+ os.makedirs(os.path.dirname(cfg.log_path), exist_ok=True)
252
+
253
+ # Open in append mode; keep file handle for the process lifetime
254
+ with open(cfg.log_path, "a", newline="") as f:
255
+ writer = csv.writer(f)
256
+
257
+ # Add header if file is empty
258
+ try:
259
+ if f.tell() == 0:
260
+ writer.writerow(
261
+ [
262
+ "timestamp",
263
+ "state",
264
+ "idle_seconds",
265
+ "locked",
266
+ "inferred_sleep",
267
+ ]
268
+ )
269
+ f.flush()
270
+ except (OSError, IOError):
271
+ # File write failed - header is not critical, continue
272
+ pass
273
+
274
+ while not self._stop_event.is_set():
275
+ now = dt.datetime.now()
276
+ slept = infer_sleep(last_ts, now, cfg.sample_interval)
277
+ idle = get_idle_seconds()
278
+ locked = is_screen_locked()
279
+ state = classify_state(
280
+ locked=locked,
281
+ idle_seconds=idle,
282
+ slept=slept,
283
+ idle_threshold=cfg.idle_threshold,
284
+ )
285
+
286
+ self._last_state = state
287
+
288
+ writer.writerow(
289
+ [
290
+ now.isoformat(),
291
+ state,
292
+ f"{idle:.1f}",
293
+ int(locked),
294
+ int(slept),
295
+ ]
296
+ )
297
+ f.flush()
298
+ last_ts = now
299
+
300
+ # Sleep in small chunks so stop() is responsive
301
+ remaining = cfg.sample_interval
302
+ while remaining > 0 and not self._stop_event.is_set():
303
+ step = min(1.0, remaining)
304
+ time.sleep(step)
305
+ remaining -= step
306
+
307
+
308
+ # ---- simple singleton helper for "just start it" ---------------------------
309
+
310
+ _singleton_logger: Optional[PresenceLogger] = None
311
+ _singleton_lock = threading.Lock()
312
+
313
+
314
+ def start_background_logger(
315
+ sample_interval: int = DEFAULT_SAMPLE_INTERVAL,
316
+ idle_threshold: int = DEFAULT_IDLE_THRESHOLD,
317
+ log_path: Optional[str] = None,
318
+ ) -> PresenceLogger:
319
+ """
320
+ Create (if needed) and start a global background PresenceLogger.
321
+
322
+ Returns the logger instance, so you can call .stop() later if desired.
323
+
324
+ Example:
325
+
326
+ from overcode.presence_logger import start_background_logger
327
+
328
+ logger = start_background_logger()
329
+ # ... do stuff ...
330
+ # logger.stop()
331
+ """
332
+ global _singleton_logger
333
+ with _singleton_lock:
334
+ if _singleton_logger is None:
335
+ cfg = PresenceLoggerConfig(
336
+ sample_interval=sample_interval,
337
+ idle_threshold=idle_threshold,
338
+ log_path=log_path or default_log_path(),
339
+ )
340
+ _singleton_logger = PresenceLogger(cfg)
341
+ _singleton_logger.start()
342
+ else:
343
+ # Optionally you could update config here; for simplicity, we don't.
344
+ _singleton_logger.start()
345
+ return _singleton_logger
346
+
347
+
348
+ def get_singleton_logger() -> Optional[PresenceLogger]:
349
+ """Get the singleton logger instance if it exists."""
350
+ return _singleton_logger
351
+
352
+
353
+ def get_current_presence_state() -> tuple[int, float, bool]:
354
+ """Get current presence state without needing a logger instance.
355
+
356
+ Returns:
357
+ Tuple of (state, idle_seconds, is_locked)
358
+ state: 1=locked/sleep, 2=inactive, 3=active
359
+ """
360
+ idle = get_idle_seconds()
361
+ locked = is_screen_locked()
362
+ state = classify_state(
363
+ locked=locked,
364
+ idle_seconds=idle,
365
+ slept=False,
366
+ idle_threshold=DEFAULT_IDLE_THRESHOLD,
367
+ )
368
+ return state, idle, locked
369
+
370
+
371
+ def read_presence_history(hours: float = 3.0) -> list[tuple[dt.datetime, int]]:
372
+ """Read presence history from CSV file.
373
+
374
+ Args:
375
+ hours: How many hours of history to read (default 3)
376
+
377
+ Returns:
378
+ List of (timestamp, state) tuples, oldest first
379
+ """
380
+ log_path = default_log_path()
381
+ if not Path(log_path).exists():
382
+ return []
383
+
384
+ cutoff = dt.datetime.now() - dt.timedelta(hours=hours)
385
+ history = []
386
+
387
+ try:
388
+ with open(log_path, 'r', newline='') as f:
389
+ reader = csv.DictReader(f)
390
+ for row in reader:
391
+ try:
392
+ ts = dt.datetime.fromisoformat(row['timestamp'])
393
+ if ts >= cutoff:
394
+ state = int(row['state'])
395
+ history.append((ts, state))
396
+ except (ValueError, KeyError):
397
+ continue
398
+ except (OSError, IOError):
399
+ pass
400
+
401
+ return history
402
+
403
+
404
+ # ---- CLI entrypoint ---------------------------------------------------------
405
+
406
+
407
+ def main() -> int:
408
+ """
409
+ Run the logger in the foreground; blocks until interrupted (Ctrl+C).
410
+
411
+ Useful if you just want a standalone process:
412
+
413
+ overcode presence
414
+ """
415
+ if not MACOS_APIS_AVAILABLE:
416
+ print("Error: macOS APIs not available.")
417
+ print("Install dependencies: pip install pyobjc-framework-Quartz pyobjc-framework-ApplicationServices")
418
+ return 1
419
+
420
+ # Check if already running
421
+ if is_presence_running():
422
+ pid = get_presence_pid()
423
+ print(f"Presence logger already running (PID: {pid})")
424
+ return 1
425
+
426
+ # Write PID file
427
+ _write_pid_file()
428
+
429
+ logger = PresenceLogger()
430
+ logger.start()
431
+ print(f"Presence logger running (PID: {os.getpid()})")
432
+ print(f"Writing to: {logger.config.log_path}")
433
+ print(f"Sample interval: {logger.config.sample_interval}s, idle threshold: {logger.config.idle_threshold}s")
434
+ print("Press Ctrl+C to stop.")
435
+ print()
436
+
437
+ try:
438
+ while True:
439
+ state, idle, locked = logger.get_current_state()
440
+ state_name = state_to_name(state)
441
+ status = f"State: {state} ({state_name}), Idle: {idle:.0f}s, Locked: {locked}"
442
+ print(f"\r{status:<60}", end="", flush=True)
443
+ time.sleep(1.0)
444
+ except KeyboardInterrupt:
445
+ print("\nStopping logger...")
446
+ logger.stop()
447
+ finally:
448
+ _remove_pid_file()
449
+
450
+ return 0
451
+
452
+
453
+ if __name__ == "__main__":
454
+ sys.exit(main())
overcode/protocols.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ Protocol definitions for external dependencies.
3
+
4
+ These interfaces allow dependency injection for testing, enabling us to
5
+ swap real implementations (subprocess calls to tmux, file I/O) with
6
+ mock implementations in tests.
7
+ """
8
+
9
+ from typing import Protocol, Optional, List, Dict, Any, runtime_checkable
10
+ from pathlib import Path
11
+
12
+
13
+ @runtime_checkable
14
+ class TmuxInterface(Protocol):
15
+ """Interface for tmux operations"""
16
+
17
+ def capture_pane(self, session: str, window: int, lines: int = 100) -> Optional[str]:
18
+ """Capture content from a tmux pane.
19
+
20
+ Args:
21
+ session: tmux session name
22
+ window: window number
23
+ lines: number of lines to capture from scrollback
24
+
25
+ Returns:
26
+ Pane content as string, or None on failure
27
+ """
28
+ ...
29
+
30
+ def send_keys(self, session: str, window: int, keys: str, enter: bool = True) -> bool:
31
+ """Send keys to a tmux pane.
32
+
33
+ Args:
34
+ session: tmux session name
35
+ window: window number
36
+ keys: keys/text to send
37
+ enter: whether to send Enter after keys
38
+
39
+ Returns:
40
+ True if successful, False otherwise
41
+ """
42
+ ...
43
+
44
+ def has_session(self, session: str) -> bool:
45
+ """Check if a tmux session exists."""
46
+ ...
47
+
48
+ def new_session(self, session: str) -> bool:
49
+ """Create a new tmux session."""
50
+ ...
51
+
52
+ def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
53
+ cwd: Optional[str] = None) -> Optional[int]:
54
+ """Create a new window in a session.
55
+
56
+ Returns:
57
+ Window number if successful, None otherwise
58
+ """
59
+ ...
60
+
61
+ def kill_window(self, session: str, window: int) -> bool:
62
+ """Kill a tmux window."""
63
+ ...
64
+
65
+ def kill_session(self, session: str) -> bool:
66
+ """Kill an entire tmux session."""
67
+ ...
68
+
69
+ def list_windows(self, session: str) -> List[Dict[str, Any]]:
70
+ """List windows in a session.
71
+
72
+ Returns:
73
+ List of window info dicts with 'index', 'name', etc.
74
+ """
75
+ ...
76
+
77
+ def attach(self, session: str) -> None:
78
+ """Attach to a tmux session (replaces current process)."""
79
+ ...
80
+
81
+
82
+ @runtime_checkable
83
+ class FileSystemInterface(Protocol):
84
+ """Interface for file system operations"""
85
+
86
+ def read_json(self, path: Path) -> Optional[Dict[str, Any]]:
87
+ """Read and parse a JSON file.
88
+
89
+ Returns:
90
+ Parsed JSON data, or None if file doesn't exist/is invalid
91
+ """
92
+ ...
93
+
94
+ def write_json(self, path: Path, data: Dict[str, Any]) -> bool:
95
+ """Write data to a JSON file atomically.
96
+
97
+ Returns:
98
+ True if successful, False otherwise
99
+ """
100
+ ...
101
+
102
+ def exists(self, path: Path) -> bool:
103
+ """Check if a path exists."""
104
+ ...
105
+
106
+ def mkdir(self, path: Path, parents: bool = True) -> bool:
107
+ """Create a directory."""
108
+ ...
109
+
110
+ def read_text(self, path: Path) -> Optional[str]:
111
+ """Read text from a file."""
112
+ ...
113
+
114
+ def write_text(self, path: Path, content: str) -> bool:
115
+ """Write text to a file."""
116
+ ...
117
+
118
+
119
+ @runtime_checkable
120
+ class SubprocessInterface(Protocol):
121
+ """Interface for subprocess operations (non-tmux)"""
122
+
123
+ def run(self, cmd: List[str], timeout: Optional[int] = None,
124
+ capture_output: bool = True) -> Optional[Dict[str, Any]]:
125
+ """Run a subprocess command.
126
+
127
+ Args:
128
+ cmd: command and arguments
129
+ timeout: timeout in seconds
130
+ capture_output: whether to capture stdout/stderr
131
+
132
+ Returns:
133
+ Dict with 'returncode', 'stdout', 'stderr', or None on failure
134
+ """
135
+ ...
136
+
137
+ def popen(self, cmd: List[str], cwd: Optional[str] = None) -> Any:
138
+ """Start a subprocess without waiting.
139
+
140
+ Returns:
141
+ Process handle or None on failure
142
+ """
143
+ ...