htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__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 (70) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
  2. htmlgraph/__init__.py +1 -1
  3. htmlgraph/api/main.py +50 -10
  4. htmlgraph/api/templates/dashboard-redesign.html +608 -54
  5. htmlgraph/api/templates/partials/activity-feed.html +21 -0
  6. htmlgraph/api/templates/partials/features.html +81 -12
  7. htmlgraph/api/templates/partials/orchestration.html +35 -0
  8. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  9. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  10. htmlgraph/cli/__init__.py +42 -0
  11. htmlgraph/cli/__main__.py +6 -0
  12. htmlgraph/cli/analytics.py +939 -0
  13. htmlgraph/cli/base.py +660 -0
  14. htmlgraph/cli/constants.py +206 -0
  15. htmlgraph/cli/core.py +856 -0
  16. htmlgraph/cli/main.py +143 -0
  17. htmlgraph/cli/models.py +462 -0
  18. htmlgraph/cli/templates/__init__.py +1 -0
  19. htmlgraph/cli/templates/cost_dashboard.py +398 -0
  20. htmlgraph/cli/work/__init__.py +159 -0
  21. htmlgraph/cli/work/features.py +567 -0
  22. htmlgraph/cli/work/orchestration.py +675 -0
  23. htmlgraph/cli/work/sessions.py +465 -0
  24. htmlgraph/cli/work/tracks.py +485 -0
  25. htmlgraph/dashboard.html +6414 -634
  26. htmlgraph/db/schema.py +8 -3
  27. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
  28. htmlgraph/docs/README.md +2 -3
  29. htmlgraph/hooks/event_tracker.py +355 -26
  30. htmlgraph/hooks/git_commands.py +175 -0
  31. htmlgraph/hooks/orchestrator.py +137 -71
  32. htmlgraph/hooks/orchestrator_reflector.py +23 -0
  33. htmlgraph/hooks/pretooluse.py +29 -6
  34. htmlgraph/hooks/session_handler.py +28 -0
  35. htmlgraph/hooks/session_summary.py +391 -0
  36. htmlgraph/hooks/subagent_detection.py +202 -0
  37. htmlgraph/hooks/subagent_stop.py +71 -12
  38. htmlgraph/hooks/validator.py +192 -79
  39. htmlgraph/operations/__init__.py +18 -0
  40. htmlgraph/operations/initialization.py +596 -0
  41. htmlgraph/operations/initialization.py.backup +228 -0
  42. htmlgraph/orchestration/__init__.py +16 -1
  43. htmlgraph/orchestration/claude_launcher.py +185 -0
  44. htmlgraph/orchestration/command_builder.py +71 -0
  45. htmlgraph/orchestration/headless_spawner.py +72 -1332
  46. htmlgraph/orchestration/plugin_manager.py +136 -0
  47. htmlgraph/orchestration/prompts.py +137 -0
  48. htmlgraph/orchestration/spawners/__init__.py +16 -0
  49. htmlgraph/orchestration/spawners/base.py +194 -0
  50. htmlgraph/orchestration/spawners/claude.py +170 -0
  51. htmlgraph/orchestration/spawners/codex.py +442 -0
  52. htmlgraph/orchestration/spawners/copilot.py +299 -0
  53. htmlgraph/orchestration/spawners/gemini.py +478 -0
  54. htmlgraph/orchestration/subprocess_runner.py +33 -0
  55. htmlgraph/orchestration.md +563 -0
  56. htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
  57. htmlgraph/orchestrator_config.py +357 -0
  58. htmlgraph/orchestrator_mode.py +45 -12
  59. htmlgraph/transcript.py +16 -4
  60. htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
  61. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
  62. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
  63. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
  64. htmlgraph/cli.py +0 -7256
  65. htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
  66. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
  67. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  68. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  69. {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  70. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
@@ -0,0 +1,357 @@
1
+ """
2
+ Orchestrator Configuration Management
3
+
4
+ Provides configurable thresholds for delegation enforcement instead of hardcoded values.
5
+ Supports:
6
+ - Threshold configuration (exploration, circuit breaker)
7
+ - Time-based violation decay
8
+ - Rapid sequence collapsing
9
+ - CLI commands to view/edit config
10
+ """
11
+
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml # type: ignore[import-untyped]
17
+ from pydantic import BaseModel
18
+
19
+
20
+ class ThresholdsConfig(BaseModel):
21
+ """Threshold configuration for orchestrator enforcement."""
22
+
23
+ exploration_calls: int = 5
24
+ """How many consecutive Grep/Read/Glob calls before warning."""
25
+
26
+ circuit_breaker_violations: int = 3
27
+ """How many violations before blocking all operations."""
28
+
29
+ violation_decay_seconds: int = 120
30
+ """How old violations can be before they don't count (seconds)."""
31
+
32
+ rapid_sequence_window: int = 0
33
+ """Time window for collapsing rapid violations (seconds). 0 = disabled."""
34
+
35
+
36
+ class AntiPatternsConfig(BaseModel):
37
+ """Anti-pattern detection thresholds."""
38
+
39
+ consecutive_bash: int = 5
40
+ consecutive_edit: int = 4
41
+ consecutive_grep: int = 4
42
+ consecutive_read: int = 5
43
+
44
+
45
+ class ModeConfig(BaseModel):
46
+ """Configuration for an enforcement mode."""
47
+
48
+ block_after_violations: bool = True
49
+ require_work_items: bool = True
50
+ warn_on_patterns: bool = True
51
+
52
+
53
+ class ModesConfig(BaseModel):
54
+ """All enforcement mode configurations."""
55
+
56
+ strict: ModeConfig = ModeConfig(
57
+ block_after_violations=True,
58
+ require_work_items=True,
59
+ warn_on_patterns=True,
60
+ )
61
+ moderate: ModeConfig = ModeConfig(
62
+ block_after_violations=False,
63
+ require_work_items=False,
64
+ warn_on_patterns=True,
65
+ )
66
+ guidance: ModeConfig = ModeConfig(
67
+ block_after_violations=False,
68
+ require_work_items=False,
69
+ warn_on_patterns=False,
70
+ )
71
+
72
+
73
+ class OrchestratorConfig(BaseModel):
74
+ """Complete orchestrator configuration."""
75
+
76
+ thresholds: ThresholdsConfig = ThresholdsConfig()
77
+ anti_patterns: AntiPatternsConfig = AntiPatternsConfig()
78
+ modes: ModesConfig = ModesConfig()
79
+
80
+
81
+ def get_config_paths() -> list[Path]:
82
+ """
83
+ Get list of config file paths to check (in priority order).
84
+
85
+ Returns:
86
+ List of paths to check for config file
87
+ """
88
+ return [
89
+ Path.cwd() / ".htmlgraph" / "orchestrator-config.yaml",
90
+ Path.home() / ".config" / "htmlgraph" / "orchestrator-config.yaml",
91
+ ]
92
+
93
+
94
+ def load_orchestrator_config() -> OrchestratorConfig:
95
+ """
96
+ Load orchestrator configuration from file or use defaults.
97
+
98
+ Checks multiple locations:
99
+ 1. .htmlgraph/orchestrator-config.yaml (project-specific)
100
+ 2. ~/.config/htmlgraph/orchestrator-config.yaml (user defaults)
101
+
102
+ Returns:
103
+ OrchestratorConfig with loaded or default values
104
+ """
105
+ for config_path in get_config_paths():
106
+ if config_path.exists():
107
+ try:
108
+ with open(config_path) as f:
109
+ data = yaml.safe_load(f)
110
+ if data:
111
+ return OrchestratorConfig(**data)
112
+ except Exception:
113
+ # If file is corrupted, continue to next location
114
+ pass
115
+
116
+ # No valid config found, return defaults
117
+ return OrchestratorConfig()
118
+
119
+
120
+ def save_orchestrator_config(
121
+ config: OrchestratorConfig, path: Path | None = None
122
+ ) -> None:
123
+ """
124
+ Save orchestrator configuration to file.
125
+
126
+ Args:
127
+ config: Configuration to save
128
+ path: Optional path to save to. If None, uses first config path.
129
+ """
130
+ if path is None:
131
+ path = get_config_paths()[0]
132
+
133
+ # Ensure directory exists
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+
136
+ # Convert to dict for YAML serialization
137
+ data = config.model_dump()
138
+
139
+ # Write YAML with comments
140
+ with open(path, "w") as f:
141
+ f.write("# HtmlGraph Orchestrator Configuration\n")
142
+ f.write("# Controls delegation enforcement behavior\n\n")
143
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
144
+
145
+
146
+ def filter_recent_violations(
147
+ violations: list[dict[str, Any]], decay_seconds: int
148
+ ) -> list[dict[str, Any]]:
149
+ """
150
+ Filter violations to only include recent ones within decay window.
151
+
152
+ Args:
153
+ violations: List of violation dicts with 'timestamp' field
154
+ decay_seconds: How old violations can be (in seconds)
155
+
156
+ Returns:
157
+ Filtered list of recent violations only
158
+ """
159
+ cutoff = datetime.now(timezone.utc) - timedelta(seconds=decay_seconds)
160
+
161
+ recent = []
162
+ for v in violations:
163
+ try:
164
+ # Parse timestamp (handle both ISO format and timestamp float)
165
+ ts = v.get("timestamp")
166
+ if isinstance(ts, str):
167
+ violation_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
168
+ elif isinstance(ts, (int, float)):
169
+ violation_time = datetime.fromtimestamp(ts, tz=timezone.utc)
170
+ else:
171
+ continue
172
+
173
+ if violation_time > cutoff:
174
+ recent.append(v)
175
+ except Exception:
176
+ # Skip violations with invalid timestamps
177
+ continue
178
+
179
+ return recent
180
+
181
+
182
+ def collapse_rapid_sequences(
183
+ violations: list[dict[str, Any]], window_seconds: int
184
+ ) -> list[dict[str, Any]]:
185
+ """
186
+ Collapse violations within rapid sequence window to one.
187
+
188
+ This prevents "violation spam" when user makes multiple rapid mistakes.
189
+
190
+ Args:
191
+ violations: List of violation dicts with 'timestamp' field
192
+ window_seconds: Time window for collapsing (seconds)
193
+
194
+ Returns:
195
+ Collapsed list where rapid sequences count as one
196
+ """
197
+ if not violations:
198
+ return []
199
+
200
+ collapsed = [violations[0]]
201
+
202
+ for v in violations[1:]:
203
+ try:
204
+ # Get timestamps
205
+ last_ts = collapsed[-1].get("timestamp")
206
+ curr_ts = v.get("timestamp")
207
+
208
+ # Parse timestamps
209
+ if isinstance(last_ts, str):
210
+ last_time = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
211
+ elif isinstance(last_ts, (int, float)):
212
+ last_time = datetime.fromtimestamp(last_ts, tz=timezone.utc)
213
+ else:
214
+ collapsed.append(v)
215
+ continue
216
+
217
+ if isinstance(curr_ts, str):
218
+ curr_time = datetime.fromisoformat(curr_ts.replace("Z", "+00:00"))
219
+ elif isinstance(curr_ts, (int, float)):
220
+ curr_time = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
221
+ else:
222
+ collapsed.append(v)
223
+ continue
224
+
225
+ # Only add if outside rapid sequence window
226
+ if (curr_time - last_time).total_seconds() > window_seconds:
227
+ collapsed.append(v)
228
+ except Exception:
229
+ # On error, include the violation
230
+ collapsed.append(v)
231
+
232
+ return collapsed
233
+
234
+
235
+ def get_effective_violation_count(
236
+ violations: list[dict[str, Any]], config: OrchestratorConfig
237
+ ) -> int:
238
+ """
239
+ Get effective violation count after applying decay and collapsing.
240
+
241
+ Args:
242
+ violations: Raw list of all violations
243
+ config: Configuration with thresholds
244
+
245
+ Returns:
246
+ Effective violation count (after decay and collapsing)
247
+ """
248
+ # Apply time-based decay
249
+ recent = filter_recent_violations(
250
+ violations, config.thresholds.violation_decay_seconds
251
+ )
252
+
253
+ # Collapse rapid sequences
254
+ collapsed = collapse_rapid_sequences(
255
+ recent, config.thresholds.rapid_sequence_window
256
+ )
257
+
258
+ return len(collapsed)
259
+
260
+
261
+ def get_config_value(config: OrchestratorConfig, key_path: str) -> Any:
262
+ """
263
+ Get a config value by dot-separated path.
264
+
265
+ Args:
266
+ config: Configuration object
267
+ key_path: Dot-separated path (e.g., "thresholds.exploration_calls")
268
+
269
+ Returns:
270
+ Value at that path
271
+
272
+ Raises:
273
+ KeyError: If path doesn't exist
274
+ """
275
+ parts = key_path.split(".")
276
+ value: Any = config
277
+
278
+ for part in parts:
279
+ if hasattr(value, part):
280
+ value = getattr(value, part)
281
+ else:
282
+ raise KeyError(f"Config path not found: {key_path}")
283
+
284
+ return value
285
+
286
+
287
+ def set_config_value(config: OrchestratorConfig, key_path: str, value: Any) -> None:
288
+ """
289
+ Set a config value by dot-separated path.
290
+
291
+ Args:
292
+ config: Configuration object to modify
293
+ key_path: Dot-separated path (e.g., "thresholds.exploration_calls")
294
+ value: Value to set
295
+
296
+ Raises:
297
+ KeyError: If path doesn't exist
298
+ """
299
+ parts = key_path.split(".")
300
+ obj: Any = config
301
+
302
+ # Navigate to parent object
303
+ for part in parts[:-1]:
304
+ if hasattr(obj, part):
305
+ obj = getattr(obj, part)
306
+ else:
307
+ raise KeyError(f"Config path not found: {key_path}")
308
+
309
+ # Set the final attribute
310
+ final_key = parts[-1]
311
+ if hasattr(obj, final_key):
312
+ setattr(obj, final_key, value)
313
+ else:
314
+ raise KeyError(f"Config path not found: {key_path}")
315
+
316
+
317
+ def format_config_display(config: OrchestratorConfig) -> str:
318
+ """
319
+ Format configuration for human-readable display.
320
+
321
+ Args:
322
+ config: Configuration to format
323
+
324
+ Returns:
325
+ Formatted string representation
326
+ """
327
+ lines = [
328
+ "HtmlGraph Orchestrator Configuration",
329
+ "=" * 50,
330
+ "",
331
+ "Thresholds:",
332
+ f" exploration_calls: {config.thresholds.exploration_calls}",
333
+ f" circuit_breaker_violations: {config.thresholds.circuit_breaker_violations}",
334
+ f" violation_decay_seconds: {config.thresholds.violation_decay_seconds}",
335
+ f" rapid_sequence_window: {config.thresholds.rapid_sequence_window}",
336
+ "",
337
+ "Anti-patterns:",
338
+ f" consecutive_bash: {config.anti_patterns.consecutive_bash}",
339
+ f" consecutive_edit: {config.anti_patterns.consecutive_edit}",
340
+ f" consecutive_grep: {config.anti_patterns.consecutive_grep}",
341
+ f" consecutive_read: {config.anti_patterns.consecutive_read}",
342
+ "",
343
+ "Modes:",
344
+ " strict:",
345
+ f" block_after_violations: {config.modes.strict.block_after_violations}",
346
+ f" require_work_items: {config.modes.strict.require_work_items}",
347
+ f" warn_on_patterns: {config.modes.strict.warn_on_patterns}",
348
+ " moderate:",
349
+ f" block_after_violations: {config.modes.moderate.block_after_violations}",
350
+ f" require_work_items: {config.modes.moderate.require_work_items}",
351
+ f" warn_on_patterns: {config.modes.moderate.warn_on_patterns}",
352
+ " guidance:",
353
+ f" block_after_violations: {config.modes.guidance.block_after_violations}",
354
+ f" require_work_items: {config.modes.guidance.require_work_items}",
355
+ f" warn_on_patterns: {config.modes.guidance.warn_on_patterns}",
356
+ ]
357
+ return "\n".join(lines)
@@ -8,10 +8,15 @@ State is persisted in .htmlgraph/orchestrator-mode.json
8
8
  import json
9
9
  from datetime import datetime, timezone
10
10
  from pathlib import Path
11
- from typing import Literal
11
+ from typing import Any, Literal
12
12
 
13
13
  from pydantic import BaseModel
14
14
 
15
+ from htmlgraph.orchestrator_config import (
16
+ get_effective_violation_count,
17
+ load_orchestrator_config,
18
+ )
19
+
15
20
 
16
21
  class OrchestratorMode(BaseModel):
17
22
  """Orchestrator mode state."""
@@ -41,9 +46,12 @@ class OrchestratorMode(BaseModel):
41
46
  """Timestamp of most recent violation."""
42
47
 
43
48
  circuit_breaker_triggered: bool = False
44
- """Whether circuit breaker has been triggered (3+ violations)."""
49
+ """Whether circuit breaker has been triggered (N+ violations, configurable)."""
50
+
51
+ violation_history: list[dict[str, Any]] = []
52
+ """Full history of violations with timestamps for time-based decay."""
45
53
 
46
- def to_dict(self) -> dict:
54
+ def to_dict(self) -> dict[str, Any]:
47
55
  """Convert to dict for JSON serialization."""
48
56
  return {
49
57
  "enabled": self.enabled,
@@ -59,10 +67,11 @@ class OrchestratorMode(BaseModel):
59
67
  self.last_violation_at.isoformat() if self.last_violation_at else None
60
68
  ),
61
69
  "circuit_breaker_triggered": self.circuit_breaker_triggered,
70
+ "violation_history": self.violation_history,
62
71
  }
63
72
 
64
73
  @classmethod
65
- def from_dict(cls, data: dict) -> "OrchestratorMode":
74
+ def from_dict(cls, data: dict[str, Any]) -> "OrchestratorMode":
66
75
  """Create from dict loaded from JSON."""
67
76
  activated_at = data.get("activated_at")
68
77
  if activated_at:
@@ -88,6 +97,7 @@ class OrchestratorMode(BaseModel):
88
97
  violations=data.get("violations", 0),
89
98
  last_violation_at=last_violation_at,
90
99
  circuit_breaker_triggered=data.get("circuit_breaker_triggered", False),
100
+ violation_history=data.get("violation_history", []),
91
101
  )
92
102
 
93
103
 
@@ -220,7 +230,7 @@ class OrchestratorModeManager:
220
230
  mode = self.load()
221
231
  return not mode.disabled_by_user
222
232
 
223
- def status(self) -> dict:
233
+ def status(self) -> dict[str, Any]:
224
234
  """
225
235
  Get human-readable status.
226
236
 
@@ -242,19 +252,38 @@ class OrchestratorModeManager:
242
252
  "circuit_breaker_triggered": mode.circuit_breaker_triggered,
243
253
  }
244
254
 
245
- def increment_violation(self) -> OrchestratorMode:
255
+ def increment_violation(self, tool: str | None = None) -> OrchestratorMode:
246
256
  """
247
257
  Increment violation counter and update timestamp.
248
258
 
259
+ Uses configurable thresholds and time-based decay.
260
+
261
+ Args:
262
+ tool: Optional tool name that caused violation
263
+
249
264
  Returns:
250
265
  Updated OrchestratorMode with incremented violations
251
266
  """
252
267
  mode = self.load()
253
- mode.violations += 1
268
+ config = load_orchestrator_config()
269
+
270
+ # Add to violation history with timestamp
271
+ violation = {
272
+ "timestamp": datetime.now(timezone.utc).isoformat(),
273
+ "tool": tool,
274
+ }
275
+ mode.violation_history.append(violation)
276
+
277
+ # Calculate effective violation count with decay and collapsing
278
+ effective_count = get_effective_violation_count(mode.violation_history, config)
279
+
280
+ # Update counters
281
+ mode.violations = effective_count
254
282
  mode.last_violation_at = datetime.now(timezone.utc)
255
283
 
256
- # Trigger circuit breaker if threshold reached
257
- if mode.violations >= 3:
284
+ # Trigger circuit breaker if threshold reached (configurable)
285
+ threshold = config.thresholds.circuit_breaker_violations
286
+ if effective_count >= threshold:
258
287
  mode.circuit_breaker_triggered = True
259
288
 
260
289
  self.save(mode)
@@ -271,6 +300,7 @@ class OrchestratorModeManager:
271
300
  mode.violations = 0
272
301
  mode.last_violation_at = None
273
302
  mode.circuit_breaker_triggered = False
303
+ mode.violation_history = []
274
304
  self.save(mode)
275
305
  return mode
276
306
 
@@ -286,10 +316,13 @@ class OrchestratorModeManager:
286
316
 
287
317
  def get_violation_count(self) -> int:
288
318
  """
289
- Get current violation count.
319
+ Get current violation count (with time-based decay applied).
290
320
 
291
321
  Returns:
292
- Number of violations in current session
322
+ Effective number of violations in current session
293
323
  """
294
324
  mode = self.load()
295
- return mode.violations
325
+ config = load_orchestrator_config()
326
+
327
+ # Return effective count with decay and collapsing
328
+ return get_effective_violation_count(mode.violation_history, config)
htmlgraph/transcript.py CHANGED
@@ -588,14 +588,26 @@ class TranscriptReader:
588
588
  Returns:
589
589
  List of TranscriptSession objects, newest first
590
590
  """
591
+ from datetime import timezone
592
+
593
+ def normalize_dt(dt: datetime | None) -> datetime:
594
+ """Normalize datetime to UTC for comparison."""
595
+ if dt is None:
596
+ return datetime.min.replace(tzinfo=timezone.utc)
597
+ if dt.tzinfo is None:
598
+ # Assume naive datetimes are UTC
599
+ return dt.replace(tzinfo=timezone.utc)
600
+ return dt.astimezone(timezone.utc)
601
+
591
602
  sessions: list[TranscriptSession] = []
592
603
 
593
604
  for path in self.list_transcript_files(project_path):
594
605
  session = self.read_transcript(path)
595
606
 
596
607
  # Filter by time
597
- if since and session.started_at and session.started_at < since:
598
- continue
608
+ if since and session.started_at:
609
+ if normalize_dt(session.started_at) < normalize_dt(since):
610
+ continue
599
611
 
600
612
  sessions.append(session)
601
613
 
@@ -604,8 +616,8 @@ class TranscriptReader:
604
616
  if deduplicate and sessions:
605
617
  sessions = self._deduplicate_context_snapshots(sessions)
606
618
 
607
- # Sort by start time, newest first
608
- sessions.sort(key=lambda s: s.started_at or datetime.min, reverse=True)
619
+ # Sort by start time, newest first (normalize for comparison)
620
+ sessions.sort(key=lambda s: normalize_dt(s.started_at), reverse=True)
609
621
 
610
622
  if limit:
611
623
  sessions = sessions[:limit]