htmlgraph 0.26.23__py3-none-any.whl → 0.26.25__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 (36) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/analytics/pattern_learning.py +771 -0
  3. htmlgraph/api/main.py +56 -23
  4. htmlgraph/api/templates/dashboard-redesign.html +3 -3
  5. htmlgraph/api/templates/dashboard.html +3 -3
  6. htmlgraph/api/templates/partials/work-items.html +613 -0
  7. htmlgraph/builders/track.py +26 -0
  8. htmlgraph/cli/base.py +31 -7
  9. htmlgraph/cli/work/__init__.py +74 -0
  10. htmlgraph/cli/work/browse.py +114 -0
  11. htmlgraph/cli/work/snapshot.py +558 -0
  12. htmlgraph/collections/base.py +34 -0
  13. htmlgraph/collections/todo.py +12 -0
  14. htmlgraph/converter.py +11 -0
  15. htmlgraph/db/schema.py +34 -1
  16. htmlgraph/hooks/orchestrator.py +88 -14
  17. htmlgraph/hooks/session_handler.py +3 -1
  18. htmlgraph/models.py +22 -2
  19. htmlgraph/orchestration/__init__.py +4 -0
  20. htmlgraph/orchestration/plugin_manager.py +1 -2
  21. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  22. htmlgraph/refs.py +343 -0
  23. htmlgraph/sdk.py +162 -1
  24. htmlgraph/session_manager.py +154 -2
  25. htmlgraph/sessions/__init__.py +23 -0
  26. htmlgraph/sessions/handoff.py +755 -0
  27. htmlgraph/track_builder.py +12 -0
  28. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/METADATA +1 -1
  29. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/RECORD +36 -28
  30. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/dashboard.html +0 -0
  31. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/styles.css +0 -0
  32. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  33. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  34. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  35. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/WHEEL +0 -0
  36. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,755 @@
1
+ """
2
+ Session Handoff and Continuity - Phase 2 Feature 3
3
+
4
+ Provides cross-session continuity features:
5
+ - HandoffBuilder: Fluent API for creating handoffs with context
6
+ - SessionResume: Load and resume from previous session
7
+ - HandoffTracker: Track handoff effectiveness metrics
8
+ - ContextRecommender: Suggest files to keep context for next session
9
+
10
+ Usage:
11
+ # End session with handoff
12
+ sdk.sessions.end(
13
+ summary="Completed OAuth integration",
14
+ next_focus="Implement JWT token refresh",
15
+ blockers=["Waiting for security review"],
16
+ keep_context=["src/auth/", "docs/security"]
17
+ )
18
+
19
+ # Resume next session
20
+ resumed = sdk.sessions.continue_from_last()
21
+ if resumed:
22
+ print(resumed.summary)
23
+ print(resumed.recommended_files)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import subprocess
31
+ from dataclasses import dataclass
32
+ from datetime import datetime, timedelta, timezone
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ if TYPE_CHECKING:
37
+ from htmlgraph.models import Session
38
+ from htmlgraph.sdk import SDK
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ @dataclass
44
+ class SessionResumeInfo:
45
+ """Information loaded from previous session for resumption."""
46
+
47
+ session_id: str
48
+ agent: str
49
+ ended_at: datetime | None
50
+ summary: str | None # handoff_notes
51
+ next_focus: str | None # recommended_next
52
+ blockers: list[str]
53
+ recommended_files: list[str]
54
+ worked_on_features: list[str]
55
+ recent_commits: list[dict[str, str]]
56
+ time_since_last: timedelta | None
57
+
58
+
59
+ @dataclass
60
+ class HandoffMetrics:
61
+ """Metrics for a session handoff."""
62
+
63
+ handoff_id: str
64
+ from_session_id: str
65
+ to_session_id: str | None
66
+ items_in_context: int
67
+ items_accessed: int
68
+ time_to_resume_seconds: int
69
+ user_rating: int | None
70
+ created_at: datetime
71
+ resumed_at: datetime | None
72
+
73
+
74
+ class ContextRecommender:
75
+ """
76
+ Recommends files to keep context for next session.
77
+
78
+ Uses git history to identify recently edited files and
79
+ combines with feature context.
80
+ """
81
+
82
+ def __init__(self, repo_root: Path | None = None):
83
+ """
84
+ Initialize ContextRecommender.
85
+
86
+ Args:
87
+ repo_root: Root of git repository (auto-detected if None)
88
+ """
89
+ self.repo_root = repo_root or self._find_repo_root()
90
+
91
+ def _find_repo_root(self) -> Path | None:
92
+ """Find git repository root."""
93
+ try:
94
+ result = subprocess.run(
95
+ ["git", "rev-parse", "--show-toplevel"],
96
+ capture_output=True,
97
+ text=True,
98
+ check=True,
99
+ timeout=5,
100
+ )
101
+ return Path(result.stdout.strip())
102
+ except (
103
+ subprocess.CalledProcessError,
104
+ FileNotFoundError,
105
+ subprocess.TimeoutExpired,
106
+ ):
107
+ return None
108
+
109
+ def get_recent_files(
110
+ self,
111
+ since_minutes: int = 60,
112
+ max_files: int = 10,
113
+ exclude_patterns: list[str] | None = None,
114
+ ) -> list[str]:
115
+ """
116
+ Get recently edited files from git.
117
+
118
+ Args:
119
+ since_minutes: Time window to check
120
+ max_files: Maximum files to return
121
+ exclude_patterns: Patterns to exclude (e.g., ["*.md", "tests/*"])
122
+
123
+ Returns:
124
+ List of file paths (relative to repo root)
125
+ """
126
+ if not self.repo_root:
127
+ return []
128
+
129
+ exclude_patterns = exclude_patterns or []
130
+
131
+ try:
132
+ # Get files changed in last N minutes
133
+ result = subprocess.run(
134
+ [
135
+ "git",
136
+ "log",
137
+ f"--since={since_minutes} minutes ago",
138
+ "--name-only",
139
+ "--pretty=format:",
140
+ "--diff-filter=AMR", # Added, Modified, Renamed
141
+ ],
142
+ cwd=str(self.repo_root),
143
+ capture_output=True,
144
+ text=True,
145
+ check=True,
146
+ timeout=10,
147
+ )
148
+
149
+ # Parse files and deduplicate
150
+ files = []
151
+ seen = set()
152
+ for line in result.stdout.strip().split("\n"):
153
+ line = line.strip()
154
+ if not line or line in seen:
155
+ continue
156
+
157
+ # Check exclusion patterns
158
+ excluded = False
159
+ for pattern in exclude_patterns:
160
+ if self._matches_pattern(line, pattern):
161
+ excluded = True
162
+ break
163
+
164
+ if not excluded:
165
+ files.append(line)
166
+ seen.add(line)
167
+
168
+ if len(files) >= max_files:
169
+ break
170
+
171
+ return files
172
+
173
+ except (
174
+ subprocess.CalledProcessError,
175
+ subprocess.TimeoutExpired,
176
+ FileNotFoundError,
177
+ ):
178
+ logger.debug("Could not get recent files from git")
179
+ return []
180
+
181
+ def _matches_pattern(self, path: str, pattern: str) -> bool:
182
+ """Check if path matches glob pattern."""
183
+ import fnmatch
184
+
185
+ return fnmatch.fnmatch(path, pattern)
186
+
187
+ def recommend_for_session(
188
+ self,
189
+ session: Session,
190
+ max_files: int = 10,
191
+ ) -> list[str]:
192
+ """
193
+ Recommend files to keep context for next session.
194
+
195
+ Args:
196
+ session: Session ending with handoff
197
+ max_files: Maximum files to recommend
198
+
199
+ Returns:
200
+ List of recommended file paths
201
+ """
202
+ # Get recently edited files
203
+ recent_files = self.get_recent_files(
204
+ since_minutes=120, # 2 hours
205
+ max_files=max_files,
206
+ exclude_patterns=["*.md", "*.txt", "*.json", "__pycache__/*"],
207
+ )
208
+
209
+ # TODO: Could enhance this by:
210
+ # - Checking which files were Read/Edit in session activity log
211
+ # - Prioritizing files related to features worked on
212
+ # - Using file change frequency
213
+
214
+ return recent_files[:max_files]
215
+
216
+
217
+ class HandoffBuilder:
218
+ """
219
+ Fluent builder for creating session handoffs.
220
+
221
+ Example:
222
+ handoff = HandoffBuilder(session)
223
+ .add_summary("Completed OAuth integration")
224
+ .add_next_focus("Implement JWT token refresh")
225
+ .add_blockers(["Waiting for security review"])
226
+ .add_context_files(["src/auth/oauth.py", "docs/security.md"])
227
+ .build()
228
+ """
229
+
230
+ def __init__(self, session: Session):
231
+ """
232
+ Initialize HandoffBuilder.
233
+
234
+ Args:
235
+ session: Session to add handoff to
236
+ """
237
+ self.session = session
238
+ self._summary: str | None = None
239
+ self._next_focus: str | None = None
240
+ self._blockers: list[str] = []
241
+ self._context_files: list[str] = []
242
+
243
+ def add_summary(self, summary: str) -> HandoffBuilder:
244
+ """
245
+ Add handoff summary (what was accomplished).
246
+
247
+ Args:
248
+ summary: Summary of what was done
249
+
250
+ Returns:
251
+ Self for chaining
252
+ """
253
+ self._summary = summary
254
+ return self
255
+
256
+ def add_next_focus(self, next_focus: str) -> HandoffBuilder:
257
+ """
258
+ Add recommended next focus.
259
+
260
+ Args:
261
+ next_focus: What should be done next
262
+
263
+ Returns:
264
+ Self for chaining
265
+ """
266
+ self._next_focus = next_focus
267
+ return self
268
+
269
+ def add_blocker(self, blocker: str) -> HandoffBuilder:
270
+ """
271
+ Add a single blocker.
272
+
273
+ Args:
274
+ blocker: Description of blocker
275
+
276
+ Returns:
277
+ Self for chaining
278
+ """
279
+ self._blockers.append(blocker)
280
+ return self
281
+
282
+ def add_blockers(self, blockers: list[str]) -> HandoffBuilder:
283
+ """
284
+ Add multiple blockers.
285
+
286
+ Args:
287
+ blockers: List of blocker descriptions
288
+
289
+ Returns:
290
+ Self for chaining
291
+ """
292
+ self._blockers.extend(blockers)
293
+ return self
294
+
295
+ def add_context_file(self, file_path: str) -> HandoffBuilder:
296
+ """
297
+ Add a file to keep context for.
298
+
299
+ Args:
300
+ file_path: Path to file
301
+
302
+ Returns:
303
+ Self for chaining
304
+ """
305
+ self._context_files.append(file_path)
306
+ return self
307
+
308
+ def add_context_files(self, file_paths: list[str]) -> HandoffBuilder:
309
+ """
310
+ Add multiple files to keep context for.
311
+
312
+ Args:
313
+ file_paths: List of file paths
314
+
315
+ Returns:
316
+ Self for chaining
317
+ """
318
+ self._context_files.extend(file_paths)
319
+ return self
320
+
321
+ def auto_recommend_context(
322
+ self,
323
+ recommender: ContextRecommender | None = None,
324
+ max_files: int = 10,
325
+ ) -> HandoffBuilder:
326
+ """
327
+ Automatically recommend context files.
328
+
329
+ Args:
330
+ recommender: ContextRecommender instance (creates new if None)
331
+ max_files: Maximum files to recommend
332
+
333
+ Returns:
334
+ Self for chaining
335
+ """
336
+ if recommender is None:
337
+ recommender = ContextRecommender()
338
+
339
+ recommended = recommender.recommend_for_session(
340
+ self.session, max_files=max_files
341
+ )
342
+ self._context_files.extend(recommended)
343
+ return self
344
+
345
+ def build(self) -> dict[str, Any]:
346
+ """
347
+ Build handoff data dictionary.
348
+
349
+ Returns:
350
+ Dictionary with handoff data
351
+ """
352
+ return {
353
+ "handoff_notes": self._summary,
354
+ "recommended_next": self._next_focus,
355
+ "blockers": self._blockers,
356
+ "recommended_context": self._context_files,
357
+ }
358
+
359
+
360
+ class SessionResume:
361
+ """
362
+ Loads and presents context from previous session for resumption.
363
+ """
364
+
365
+ def __init__(self, sdk: SDK):
366
+ """
367
+ Initialize SessionResume.
368
+
369
+ Args:
370
+ sdk: SDK instance
371
+ """
372
+ self.sdk = sdk
373
+ self.graph_dir = sdk._directory
374
+
375
+ def get_last_session(self, agent: str | None = None) -> Session | None:
376
+ """
377
+ Get the most recent completed session.
378
+
379
+ Args:
380
+ agent: Filter by agent (None = any agent)
381
+
382
+ Returns:
383
+ Most recent session or None
384
+ """
385
+ from htmlgraph.converter import SessionConverter
386
+
387
+ converter = SessionConverter(self.graph_dir / "sessions")
388
+ sessions = converter.load_all()
389
+
390
+ # Filter by ended sessions
391
+ ended = [s for s in sessions if s.status == "ended"]
392
+
393
+ # Filter by agent if specified
394
+ if agent:
395
+ ended = [s for s in ended if s.agent == agent]
396
+
397
+ if not ended:
398
+ return None
399
+
400
+ # Sort by ended_at (most recent first)
401
+ ended.sort(key=lambda s: s.ended_at or datetime.min, reverse=True)
402
+ return ended[0]
403
+
404
+ def build_resume_info(self, session: Session) -> SessionResumeInfo:
405
+ """
406
+ Build resumption information from a session.
407
+
408
+ Args:
409
+ session: Previous session
410
+
411
+ Returns:
412
+ SessionResumeInfo with context for resumption
413
+ """
414
+ # Calculate time since last session
415
+ time_since = None
416
+ if session.ended_at:
417
+ time_since = datetime.now(timezone.utc) - session.ended_at
418
+
419
+ # Get recent commits
420
+ recent_commits = self._get_recent_commits(since_commit=session.start_commit)
421
+
422
+ return SessionResumeInfo(
423
+ session_id=session.id,
424
+ agent=session.agent,
425
+ ended_at=session.ended_at,
426
+ summary=session.handoff_notes,
427
+ next_focus=session.recommended_next,
428
+ blockers=session.blockers,
429
+ recommended_files=self._parse_json_list(session, "recommended_context"),
430
+ worked_on_features=session.worked_on,
431
+ recent_commits=recent_commits,
432
+ time_since_last=time_since,
433
+ )
434
+
435
+ def _parse_json_list(self, session: Session, field_name: str) -> list[str]:
436
+ """Parse JSON list field from session."""
437
+ # Session model stores these as Python lists already
438
+ value = getattr(session, field_name, None)
439
+ if isinstance(value, list):
440
+ return [str(item) for item in value] # Ensure list[str]
441
+ if isinstance(value, str):
442
+ try:
443
+ result = json.loads(value)
444
+ return (
445
+ [str(item) for item in result] if isinstance(result, list) else []
446
+ )
447
+ except json.JSONDecodeError:
448
+ return []
449
+ return []
450
+
451
+ def _get_recent_commits(
452
+ self, since_commit: str | None = None, limit: int = 5
453
+ ) -> list[dict[str, str]]:
454
+ """
455
+ Get recent git commits.
456
+
457
+ Args:
458
+ since_commit: Get commits since this one
459
+ limit: Maximum commits to return
460
+
461
+ Returns:
462
+ List of commit dictionaries with hash, message, author, date
463
+ """
464
+ try:
465
+ args = ["git", "log", f"-{limit}", "--oneline", "--no-merges"]
466
+ if since_commit:
467
+ args.append(f"{since_commit}..HEAD")
468
+
469
+ result = subprocess.run(
470
+ args,
471
+ capture_output=True,
472
+ text=True,
473
+ check=True,
474
+ timeout=5,
475
+ )
476
+
477
+ commits = []
478
+ for line in result.stdout.strip().split("\n"):
479
+ if not line:
480
+ continue
481
+ parts = line.split(" ", 1)
482
+ if len(parts) == 2:
483
+ commits.append({"hash": parts[0], "message": parts[1]})
484
+
485
+ return commits
486
+
487
+ except (
488
+ subprocess.CalledProcessError,
489
+ subprocess.TimeoutExpired,
490
+ FileNotFoundError,
491
+ ):
492
+ logger.debug("Could not get recent commits")
493
+ return []
494
+
495
+ def format_resume_prompt(self, info: SessionResumeInfo) -> str:
496
+ """
497
+ Format a user-friendly resumption prompt.
498
+
499
+ Args:
500
+ info: Session resumption information
501
+
502
+ Returns:
503
+ Formatted multi-line string for display
504
+ """
505
+ lines = [
506
+ "═" * 70,
507
+ "CONTINUE FROM LAST SESSION",
508
+ "═" * 70,
509
+ ]
510
+
511
+ # Session info
512
+ if info.ended_at:
513
+ lines.append(
514
+ f'Last: {info.ended_at.strftime("%A %I:%M %p")} - "{info.summary or "No summary"}"'
515
+ )
516
+ else:
517
+ lines.append(f"Last: {info.session_id}")
518
+
519
+ # Time gap
520
+ if info.time_since_last:
521
+ hours = info.time_since_last.total_seconds() / 3600
522
+ if hours < 1:
523
+ time_str = (
524
+ f"{int(info.time_since_last.total_seconds() / 60)} minutes ago"
525
+ )
526
+ elif hours < 24:
527
+ time_str = f"{int(hours)} hours ago"
528
+ else:
529
+ time_str = f"{int(hours / 24)} days ago"
530
+ lines.append(f"Gap: {time_str}")
531
+
532
+ lines.append("")
533
+
534
+ # Next focus
535
+ if info.next_focus:
536
+ lines.append("Next Focus:")
537
+ lines.append(f" {info.next_focus}")
538
+ lines.append("")
539
+
540
+ # Blockers
541
+ if info.blockers:
542
+ lines.append("Blockers:")
543
+ for blocker in info.blockers:
544
+ lines.append(f" ⚠️ {blocker}")
545
+ lines.append("")
546
+
547
+ # Context files
548
+ if info.recommended_files:
549
+ lines.append("Context to Load:")
550
+ for i, file_path in enumerate(info.recommended_files[:5], 1):
551
+ lines.append(f" {i}. {file_path}")
552
+ if len(info.recommended_files) > 5:
553
+ lines.append(f" ... and {len(info.recommended_files) - 5} more")
554
+ lines.append("")
555
+
556
+ # Features worked on
557
+ if info.worked_on_features:
558
+ lines.append("Features in Progress:")
559
+ for feature_id in info.worked_on_features[:3]:
560
+ lines.append(f" - {feature_id}")
561
+ if len(info.worked_on_features) > 3:
562
+ lines.append(f" ... and {len(info.worked_on_features) - 3} more")
563
+ lines.append("")
564
+
565
+ # Recent commits
566
+ if info.recent_commits:
567
+ lines.append("Recent Commits:")
568
+ for commit in info.recent_commits[:3]:
569
+ lines.append(f" {commit['hash']} {commit['message']}")
570
+ lines.append("")
571
+
572
+ lines.append(
573
+ "[L]oad context files [O]pen in editor [S]how summary [C]ontinue"
574
+ )
575
+
576
+ return "\n".join(lines)
577
+
578
+
579
+ class HandoffTracker:
580
+ """
581
+ Tracks handoff effectiveness metrics.
582
+
583
+ Records how helpful handoffs are and enables optimization.
584
+ """
585
+
586
+ def __init__(self, sdk: SDK):
587
+ """
588
+ Initialize HandoffTracker.
589
+
590
+ Args:
591
+ sdk: SDK instance
592
+ """
593
+ self.sdk = sdk
594
+ self.db = getattr(sdk, "_db", None)
595
+
596
+ def create_handoff(
597
+ self,
598
+ from_session_id: str,
599
+ items_in_context: int = 0,
600
+ ) -> str:
601
+ """
602
+ Create a handoff tracking record.
603
+
604
+ Args:
605
+ from_session_id: Session ending with handoff
606
+ items_in_context: Number of context items provided
607
+
608
+ Returns:
609
+ Handoff ID
610
+ """
611
+ from htmlgraph.ids import generate_id
612
+
613
+ handoff_id = generate_id("hand")
614
+
615
+ if self.db and self.db.connection:
616
+ # Ensure session exists in database (handles FK constraint)
617
+ self.db._ensure_session_exists(from_session_id)
618
+
619
+ cursor = self.db.connection.cursor()
620
+ cursor.execute(
621
+ """
622
+ INSERT INTO handoff_tracking
623
+ (handoff_id, from_session_id, items_in_context)
624
+ VALUES (?, ?, ?)
625
+ """,
626
+ (handoff_id, from_session_id, items_in_context),
627
+ )
628
+ self.db.connection.commit()
629
+
630
+ return handoff_id
631
+
632
+ def resume_handoff(
633
+ self,
634
+ handoff_id: str,
635
+ to_session_id: str,
636
+ items_accessed: int = 0,
637
+ time_to_resume_seconds: int = 0,
638
+ ) -> bool:
639
+ """
640
+ Update handoff with resumption data.
641
+
642
+ Args:
643
+ handoff_id: Handoff ID
644
+ to_session_id: New session ID
645
+ items_accessed: Number of context items accessed
646
+ time_to_resume_seconds: Time to resume work (seconds)
647
+
648
+ Returns:
649
+ True if successful
650
+ """
651
+ if not self.db or not self.db.connection:
652
+ return False
653
+
654
+ try:
655
+ # Ensure to_session exists in database (handles FK constraint)
656
+ self.db._ensure_session_exists(to_session_id)
657
+
658
+ cursor = self.db.connection.cursor()
659
+ cursor.execute(
660
+ """
661
+ UPDATE handoff_tracking
662
+ SET to_session_id = ?,
663
+ items_accessed = ?,
664
+ time_to_resume_seconds = ?,
665
+ resumed_at = CURRENT_TIMESTAMP
666
+ WHERE handoff_id = ?
667
+ """,
668
+ (to_session_id, items_accessed, time_to_resume_seconds, handoff_id),
669
+ )
670
+ self.db.connection.commit()
671
+ return True
672
+ except Exception as e:
673
+ logger.error(f"Error updating handoff: {e}")
674
+ return False
675
+
676
+ def rate_handoff(self, handoff_id: str, rating: int) -> bool:
677
+ """
678
+ Rate handoff effectiveness (1-5 scale).
679
+
680
+ Args:
681
+ handoff_id: Handoff ID
682
+ rating: Rating (1-5)
683
+
684
+ Returns:
685
+ True if successful
686
+ """
687
+ if not 1 <= rating <= 5:
688
+ raise ValueError("Rating must be between 1 and 5")
689
+
690
+ if not self.db or not self.db.connection:
691
+ return False
692
+
693
+ try:
694
+ cursor = self.db.connection.cursor()
695
+ cursor.execute(
696
+ """
697
+ UPDATE handoff_tracking
698
+ SET user_rating = ?
699
+ WHERE handoff_id = ?
700
+ """,
701
+ (rating, handoff_id),
702
+ )
703
+ self.db.connection.commit()
704
+ return True
705
+ except Exception as e:
706
+ logger.error(f"Error rating handoff: {e}")
707
+ return False
708
+
709
+ def get_handoff_metrics(self, limit: int = 10) -> list[HandoffMetrics]:
710
+ """
711
+ Get recent handoff metrics.
712
+
713
+ Args:
714
+ limit: Maximum records to return
715
+
716
+ Returns:
717
+ List of HandoffMetrics
718
+ """
719
+ if not self.db or not self.db.connection:
720
+ return []
721
+
722
+ try:
723
+ cursor = self.db.connection.cursor()
724
+ cursor.execute(
725
+ """
726
+ SELECT handoff_id, from_session_id, to_session_id,
727
+ items_in_context, items_accessed, time_to_resume_seconds,
728
+ user_rating, created_at, resumed_at
729
+ FROM handoff_tracking
730
+ ORDER BY created_at DESC
731
+ LIMIT ?
732
+ """,
733
+ (limit,),
734
+ )
735
+
736
+ metrics = []
737
+ for row in cursor.fetchall():
738
+ metrics.append(
739
+ HandoffMetrics(
740
+ handoff_id=row[0],
741
+ from_session_id=row[1],
742
+ to_session_id=row[2],
743
+ items_in_context=row[3],
744
+ items_accessed=row[4],
745
+ time_to_resume_seconds=row[5],
746
+ user_rating=row[6],
747
+ created_at=datetime.fromisoformat(row[7]),
748
+ resumed_at=(datetime.fromisoformat(row[8]) if row[8] else None),
749
+ )
750
+ )
751
+
752
+ return metrics
753
+ except Exception as e:
754
+ logger.error(f"Error getting handoff metrics: {e}")
755
+ return []