|
7 | 7 | from typing import Optional |
8 | 8 | import uuid |
9 | 9 |
|
| 10 | +import config |
10 | 11 | from modes import get_friction, get_friction_message, default_mode, validate_mode, get_mode |
11 | 12 |
|
12 | 13 | HISTORY_DIR = Path.home() / ".vibe-harness" |
13 | 14 | HISTORY_FILE = HISTORY_DIR / "mode-history.jsonl" |
14 | 15 |
|
| 16 | +# Export schema version — tracks the JSON export format independently of the |
| 17 | +# package version. Bump when adding/removing/renaming fields in to_export_dict(). |
| 18 | +EXPORT_SCHEMA_VERSION = "0.3.0" |
| 19 | + |
| 20 | + |
| 21 | +@dataclass |
| 22 | +class IdleGap: |
| 23 | + """A detected period of inactivity between tool calls.""" |
| 24 | + start: datetime |
| 25 | + end: datetime |
| 26 | + |
| 27 | + @property |
| 28 | + def duration_seconds(self) -> float: |
| 29 | + return (self.end - self.start).total_seconds() |
| 30 | + |
| 31 | + def to_dict(self) -> dict: |
| 32 | + return { |
| 33 | + "start": self.start.isoformat(), |
| 34 | + "end": self.end.isoformat(), |
| 35 | + "duration_minutes": round(self.duration_seconds / 60, 1), |
| 36 | + } |
| 37 | + |
15 | 38 |
|
16 | 39 | @dataclass |
17 | 40 | class ModeTransition: |
@@ -51,13 +74,29 @@ class VibeSession: |
51 | 74 | # Tracks interaction count at last mode switch for per-mode counting |
52 | 75 | _interactions_at_last_switch: int = field(default=0, repr=False) |
53 | 76 |
|
| 77 | + # Activity tracking for idle gap detection |
| 78 | + last_interaction_at: Optional[datetime] = field(default=None, repr=False) |
| 79 | + _idle_gaps: list[IdleGap] = field(default_factory=list, repr=False) |
| 80 | + |
54 | 81 | # High-friction confirmation state |
55 | 82 | _pending_mode: Optional[str] = field(default=None, repr=False) |
56 | 83 | _pending_friction: Optional[str] = field(default=None, repr=False) |
57 | 84 | _onboarding_shown: bool = field(default=False, repr=False) |
58 | 85 |
|
59 | 86 | def record_interaction(self) -> None: |
60 | | - """Increment interaction counter. Call on every tool invocation.""" |
| 87 | + """Increment interaction counter and detect idle gaps. |
| 88 | +
|
| 89 | + Call on every tool invocation. If the gap since the last interaction |
| 90 | + exceeds the idle threshold, records an IdleGap so active duration |
| 91 | + calculations can subtract idle time. |
| 92 | + """ |
| 93 | + now = datetime.now(timezone.utc) |
| 94 | + if self.last_interaction_at is not None: |
| 95 | + gap_seconds = (now - self.last_interaction_at).total_seconds() |
| 96 | + threshold_minutes = config.get("activity.idle_threshold_minutes") |
| 97 | + if gap_seconds >= threshold_minutes * 60: |
| 98 | + self._idle_gaps.append(IdleGap(start=self.last_interaction_at, end=now)) |
| 99 | + self.last_interaction_at = now |
61 | 100 | self.interaction_count += 1 |
62 | 101 |
|
63 | 102 | def record_governance_evaluation(self, evaluations: list[dict]) -> None: |
@@ -197,50 +236,99 @@ def session_duration_minutes(self) -> float: |
197 | 236 | delta = datetime.now(timezone.utc) - self.started_at |
198 | 237 | return delta.total_seconds() / 60 |
199 | 238 |
|
| 239 | + def _total_idle_seconds(self, since: datetime) -> float: |
| 240 | + """Sum idle gap durations that fall after `since`. |
| 241 | +
|
| 242 | + Handles gaps that straddle the `since` boundary by only counting |
| 243 | + the portion after `since`. |
| 244 | + """ |
| 245 | + total = 0.0 |
| 246 | + for gap in self._idle_gaps: |
| 247 | + if gap.end <= since: |
| 248 | + continue |
| 249 | + effective_start = max(gap.start, since) |
| 250 | + total += (gap.end - effective_start).total_seconds() |
| 251 | + return total |
| 252 | + |
| 253 | + def active_session_minutes(self) -> float: |
| 254 | + """Session duration minus idle time.""" |
| 255 | + elapsed = self.session_duration_minutes() |
| 256 | + idle = self._total_idle_seconds(self.started_at) / 60 |
| 257 | + return max(0.0, elapsed - idle) |
| 258 | + |
| 259 | + def active_mode_minutes(self) -> float: |
| 260 | + """Current mode duration minus idle time since mode started.""" |
| 261 | + elapsed = self.mode_duration_minutes() |
| 262 | + idle = self._total_idle_seconds(self.mode_since) / 60 |
| 263 | + return max(0.0, elapsed - idle) |
| 264 | + |
| 265 | + def total_idle_minutes(self) -> float: |
| 266 | + """Total idle time detected across the session.""" |
| 267 | + return self._total_idle_seconds(self.started_at) / 60 |
| 268 | + |
200 | 269 | def interactions_since_last_switch(self) -> int: |
201 | 270 | """Interactions since last mode transition.""" |
202 | 271 | return self.interaction_count - self._interactions_at_last_switch |
203 | 272 |
|
204 | 273 | def time_in_mode_summary(self) -> dict[str, float]: |
205 | | - """Calculate time spent in each mode during this session.""" |
| 274 | + """Calculate active time spent in each mode during this session. |
| 275 | +
|
| 276 | + Subtracts idle gaps from each mode's time allocation so the summary |
| 277 | + reflects active time, not wall-clock time. |
| 278 | + """ |
206 | 279 | summary: dict[str, float] = {} |
207 | 280 | now = datetime.now(timezone.utc) |
208 | 281 |
|
209 | 282 | if not self.transitions: |
210 | 283 | mode = self.mode |
211 | | - minutes = (now - self.started_at).total_seconds() / 60 |
212 | | - summary[mode] = round(minutes, 1) |
| 284 | + elapsed = (now - self.started_at).total_seconds() / 60 |
| 285 | + idle = self._total_idle_seconds(self.started_at) / 60 |
| 286 | + summary[mode] = round(max(0.0, elapsed - idle), 1) |
213 | 287 | return summary |
214 | 288 |
|
| 289 | + # Build list of (mode, start, end) spans |
| 290 | + spans: list[tuple[str, datetime, datetime]] = [] |
| 291 | + |
215 | 292 | # Time from session start to first transition |
216 | 293 | first = self.transitions[0] |
217 | | - start_minutes = (first.timestamp - self.started_at).total_seconds() / 60 |
218 | | - initial_mode = first.from_mode |
219 | | - summary[initial_mode] = summary.get(initial_mode, 0) + start_minutes |
| 294 | + spans.append((first.from_mode, self.started_at, first.timestamp)) |
220 | 295 |
|
221 | 296 | # Time between transitions |
222 | 297 | for i, t in enumerate(self.transitions): |
223 | | - if i + 1 < len(self.transitions): |
224 | | - end = self.transitions[i + 1].timestamp |
225 | | - else: |
226 | | - end = now |
227 | | - minutes = (end - t.timestamp).total_seconds() / 60 |
228 | | - summary[t.to_mode] = summary.get(t.to_mode, 0) + minutes |
| 298 | + end = self.transitions[i + 1].timestamp if i + 1 < len(self.transitions) else now |
| 299 | + spans.append((t.to_mode, t.timestamp, end)) |
| 300 | + |
| 301 | + # Accumulate active time per mode |
| 302 | + for mode, span_start, span_end in spans: |
| 303 | + elapsed = (span_end - span_start).total_seconds() / 60 |
| 304 | + idle = self._total_idle_seconds(span_start) / 60 |
| 305 | + # Only count idle time within this span |
| 306 | + idle_in_span = 0.0 |
| 307 | + for gap in self._idle_gaps: |
| 308 | + if gap.end <= span_start or gap.start >= span_end: |
| 309 | + continue |
| 310 | + effective_start = max(gap.start, span_start) |
| 311 | + effective_end = min(gap.end, span_end) |
| 312 | + idle_in_span += (effective_end - effective_start).total_seconds() / 60 |
| 313 | + active = max(0.0, elapsed - idle_in_span) |
| 314 | + summary[mode] = summary.get(mode, 0) + active |
229 | 315 |
|
230 | 316 | return {k: round(v, 1) for k, v in summary.items()} |
231 | 317 |
|
232 | 318 | def to_export_dict(self) -> dict: |
233 | 319 | """Full session data for JSON export.""" |
234 | 320 | return { |
235 | | - "schema_version": "0.2.0", |
| 321 | + "schema_version": EXPORT_SCHEMA_VERSION, |
236 | 322 | "session_id": self.session_id, |
237 | 323 | "started_at": self.started_at.isoformat(), |
238 | 324 | "exported_at": datetime.now(timezone.utc).isoformat(), |
239 | 325 | "duration_minutes": round(self.session_duration_minutes(), 1), |
| 326 | + "active_duration_minutes": round(self.active_session_minutes(), 1), |
240 | 327 | "current_mode": self.mode, |
241 | 328 | "interaction_count": self.interaction_count, |
242 | 329 | "nudges_surfaced": self.nudges_surfaced, |
243 | 330 | "transitions": [t.to_dict() for t in self.transitions], |
| 331 | + "idle_gaps": [g.to_dict() for g in self._idle_gaps], |
244 | 332 | "time_in_mode": self.time_in_mode_summary(), |
245 | 333 | "governance_trace": self.governance_trace, |
246 | 334 | } |
0 commit comments