cct-cli 0.7.9.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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT v0.8.a — Cursor / Pointer Tracking & Context Inference.
|
|
3
|
+
|
|
4
|
+
Records Pointer Events (mouse/pen/touch) with throttling, dwell detection,
|
|
5
|
+
and probabilistic attention inference — never "mind reading".
|
|
6
|
+
|
|
7
|
+
Spec 10/11: pointer metadata + Cursor Context Inference.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field, asdict
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PointerType(str, Enum):
|
|
20
|
+
MOUSE = "mouse"
|
|
21
|
+
PEN = "pen"
|
|
22
|
+
TOUCH = "touch"
|
|
23
|
+
UNKNOWN = "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PointerEvent:
|
|
28
|
+
timestamp: float
|
|
29
|
+
x: float # normalized 0.0-1.0
|
|
30
|
+
y: float # normalized 0.0-1.0
|
|
31
|
+
pointer_id: int = 0
|
|
32
|
+
pointer_type: str = "mouse"
|
|
33
|
+
button: int = 0
|
|
34
|
+
pressure: float = 0.5
|
|
35
|
+
movement_x: float = 0.0
|
|
36
|
+
movement_y: float = 0.0
|
|
37
|
+
is_click: bool = False
|
|
38
|
+
is_double_click: bool = False
|
|
39
|
+
is_drag: bool = False
|
|
40
|
+
is_scroll: bool = False
|
|
41
|
+
scroll_delta: float = 0.0
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"timestamp": self.timestamp,
|
|
46
|
+
"x": round(self.x, 4),
|
|
47
|
+
"y": round(self.y, 4),
|
|
48
|
+
"pointerId": self.pointer_id,
|
|
49
|
+
"pointerType": self.pointer_type,
|
|
50
|
+
"button": self.button,
|
|
51
|
+
"pressure": round(self.pressure, 3),
|
|
52
|
+
"movement": [round(self.movement_x, 4), round(self.movement_y, 4)],
|
|
53
|
+
"click": self.is_click,
|
|
54
|
+
"doubleClick": self.is_double_click,
|
|
55
|
+
"drag": self.is_drag,
|
|
56
|
+
"scroll": self.is_scroll,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DwellRegion:
|
|
62
|
+
x: float
|
|
63
|
+
y: float
|
|
64
|
+
radius: float
|
|
65
|
+
dwell_ms: float
|
|
66
|
+
event_count: int
|
|
67
|
+
started_at: float
|
|
68
|
+
ended_at: float
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"x": round(self.x, 4),
|
|
73
|
+
"y": round(self.y, 4),
|
|
74
|
+
"radius": round(self.radius, 4),
|
|
75
|
+
"dwell_ms": round(self.dwell_ms, 1),
|
|
76
|
+
"event_count": self.event_count,
|
|
77
|
+
"started_at": self.started_at,
|
|
78
|
+
"ended_at": self.ended_at,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CursorTracker:
|
|
83
|
+
"""
|
|
84
|
+
Throttled pointer tracking + dwell detection + attention inference.
|
|
85
|
+
|
|
86
|
+
- Throttle: coalesce high-rate pointermove (16ms min interval)
|
|
87
|
+
- Dwell: cursor stays within radius for >= dwell_threshold_ms
|
|
88
|
+
- Inference: probabilistic observations, never mind-reading
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self,
|
|
92
|
+
throttle_ms: float = 16.0,
|
|
93
|
+
dwell_threshold_ms: float = 800.0,
|
|
94
|
+
dwell_radius: float = 0.03,
|
|
95
|
+
max_events: int = 2000):
|
|
96
|
+
self.throttle_ms = throttle_ms
|
|
97
|
+
self.dwell_threshold_ms = dwell_threshold_ms
|
|
98
|
+
self.dwell_radius = dwell_radius
|
|
99
|
+
self.max_events = max_events
|
|
100
|
+
|
|
101
|
+
self._events: List[PointerEvent] = []
|
|
102
|
+
self._last_emit_time: float = 0.0
|
|
103
|
+
self._last_pos: Tuple[float, float] = (0.0, 0.0)
|
|
104
|
+
self._dwell_start: Optional[float] = None
|
|
105
|
+
self._dwell_center: Tuple[float, float] = (0.0, 0.0)
|
|
106
|
+
self._dwells: List[DwellRegion] = []
|
|
107
|
+
self._click_count: int = 0
|
|
108
|
+
self._scroll_total: float = 0.0
|
|
109
|
+
|
|
110
|
+
# -- ingestion --
|
|
111
|
+
|
|
112
|
+
def add_event(self, evt: PointerEvent) -> bool:
|
|
113
|
+
"""Add event with throttling. Returns True if stored."""
|
|
114
|
+
now = evt.timestamp or time.time()
|
|
115
|
+
# throttle pointermove without click/drag
|
|
116
|
+
is_move_only = not (evt.is_click or evt.is_drag or evt.is_scroll or evt.is_double_click)
|
|
117
|
+
if is_move_only and self._events:
|
|
118
|
+
elapsed_ms = (now - self._last_emit_time) * 1000
|
|
119
|
+
if elapsed_ms < self.throttle_ms:
|
|
120
|
+
# coalesce: update last move position instead of appending
|
|
121
|
+
if self._events and not self._events[-1].is_click:
|
|
122
|
+
self._events[-1].x = evt.x
|
|
123
|
+
self._events[-1].y = evt.y
|
|
124
|
+
self._events[-1].timestamp = now
|
|
125
|
+
self._events[-1].movement_x = evt.movement_x
|
|
126
|
+
self._events[-1].movement_y = evt.movement_y
|
|
127
|
+
self._update_dwell(evt.x, evt.y, now)
|
|
128
|
+
return False
|
|
129
|
+
self._events.append(evt)
|
|
130
|
+
self._last_emit_time = now
|
|
131
|
+
self._last_pos = (evt.x, evt.y)
|
|
132
|
+
if len(self._events) > self.max_events:
|
|
133
|
+
self._events = self._events[-self.max_events:]
|
|
134
|
+
if evt.is_click:
|
|
135
|
+
self._click_count += 1
|
|
136
|
+
if evt.is_scroll:
|
|
137
|
+
self._scroll_total += abs(evt.scroll_delta)
|
|
138
|
+
self._update_dwell(evt.x, evt.y, now)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
def add_raw(self, x: float, y: float,
|
|
142
|
+
pointer_type: str = "mouse",
|
|
143
|
+
pointer_id: int = 0,
|
|
144
|
+
button: int = 0,
|
|
145
|
+
pressure: float = 0.5,
|
|
146
|
+
movement_x: float = 0.0,
|
|
147
|
+
movement_y: float = 0.0,
|
|
148
|
+
is_click: bool = False,
|
|
149
|
+
is_double_click: bool = False,
|
|
150
|
+
is_drag: bool = False,
|
|
151
|
+
is_scroll: bool = False,
|
|
152
|
+
scroll_delta: float = 0.0,
|
|
153
|
+
timestamp: Optional[float] = None) -> bool:
|
|
154
|
+
return self.add_event(PointerEvent(
|
|
155
|
+
timestamp=timestamp or time.time(),
|
|
156
|
+
x=max(0.0, min(1.0, x)),
|
|
157
|
+
y=max(0.0, min(1.0, y)),
|
|
158
|
+
pointer_id=pointer_id,
|
|
159
|
+
pointer_type=pointer_type,
|
|
160
|
+
button=button,
|
|
161
|
+
pressure=pressure,
|
|
162
|
+
movement_x=movement_x,
|
|
163
|
+
movement_y=movement_y,
|
|
164
|
+
is_click=is_click,
|
|
165
|
+
is_double_click=is_double_click,
|
|
166
|
+
is_drag=is_drag,
|
|
167
|
+
is_scroll=is_scroll,
|
|
168
|
+
scroll_delta=scroll_delta,
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
# -- dwell --
|
|
172
|
+
|
|
173
|
+
def _update_dwell(self, x: float, y: float, now: float):
|
|
174
|
+
if self._dwell_start is None:
|
|
175
|
+
self._dwell_start = now
|
|
176
|
+
self._dwell_center = (x, y)
|
|
177
|
+
return
|
|
178
|
+
dist = math.hypot(x - self._dwell_center[0], y - self._dwell_center[1])
|
|
179
|
+
if dist > self.dwell_radius:
|
|
180
|
+
# moved away: finalize previous dwell if significant
|
|
181
|
+
dwell_ms = (now - self._dwell_start) * 1000
|
|
182
|
+
if dwell_ms >= self.dwell_threshold_ms:
|
|
183
|
+
self._dwells.append(DwellRegion(
|
|
184
|
+
x=self._dwell_center[0],
|
|
185
|
+
y=self._dwell_center[1],
|
|
186
|
+
radius=self.dwell_radius,
|
|
187
|
+
dwell_ms=dwell_ms,
|
|
188
|
+
event_count=1,
|
|
189
|
+
started_at=self._dwell_start,
|
|
190
|
+
ended_at=now,
|
|
191
|
+
))
|
|
192
|
+
if len(self._dwells) > 100:
|
|
193
|
+
self._dwells = self._dwells[-50:]
|
|
194
|
+
self._dwell_start = now
|
|
195
|
+
self._dwell_center = (x, y)
|
|
196
|
+
# else still dwelling — no action until move away or query
|
|
197
|
+
|
|
198
|
+
def get_current_dwell_ms(self) -> float:
|
|
199
|
+
if self._dwell_start is None:
|
|
200
|
+
return 0.0
|
|
201
|
+
return (time.time() - self._dwell_start) * 1000
|
|
202
|
+
|
|
203
|
+
# -- queries --
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def events(self) -> List[PointerEvent]:
|
|
207
|
+
return list(self._events)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def dwells(self) -> List[DwellRegion]:
|
|
211
|
+
return list(self._dwells)
|
|
212
|
+
|
|
213
|
+
def recent_events(self, n: int = 50) -> List[Dict]:
|
|
214
|
+
return [e.to_dict() for e in self._events[-n:]]
|
|
215
|
+
|
|
216
|
+
def get_cursor_context(self) -> Dict[str, Any]:
|
|
217
|
+
"""Probabilistic cursor context for AI — never mind-reading."""
|
|
218
|
+
if not self._events:
|
|
219
|
+
return {"has_data": False}
|
|
220
|
+
latest = self._events[-1]
|
|
221
|
+
dwell_ms = self.get_current_dwell_ms()
|
|
222
|
+
recent = self._events[-20:]
|
|
223
|
+
# attention inference
|
|
224
|
+
observations: List[str] = []
|
|
225
|
+
inferences: List[str] = []
|
|
226
|
+
if dwell_ms >= self.dwell_threshold_ms:
|
|
227
|
+
observations.append(f"Cursor remained near ({latest.x:.2f}, {latest.y:.2f}) for {dwell_ms:.0f}ms.")
|
|
228
|
+
inferences.append("User is likely directing attention to this region.")
|
|
229
|
+
if self._click_count >= 2:
|
|
230
|
+
clicks_near = [e for e in recent if e.is_click and math.hypot(e.x - latest.x, e.y - latest.y) < 0.05]
|
|
231
|
+
if len(clicks_near) >= 2:
|
|
232
|
+
observations.append(f"User clicked near ({latest.x:.2f}, {latest.y:.2f}) {len(clicks_near)} times.")
|
|
233
|
+
inferences.append("Repeated clicks suggest the target may be unresponsive or needs attention.")
|
|
234
|
+
if any(e.is_drag for e in recent):
|
|
235
|
+
observations.append("User performed a drag gesture recently.")
|
|
236
|
+
inferences.append("User may be repositioning or selecting content.")
|
|
237
|
+
if abs(self._scroll_total) > 0.5:
|
|
238
|
+
observations.append(f"User scrolled (total delta {self._scroll_total:.1f}).")
|
|
239
|
+
return {
|
|
240
|
+
"has_data": True,
|
|
241
|
+
"latest": latest.to_dict(),
|
|
242
|
+
"dwell_ms": round(dwell_ms, 1),
|
|
243
|
+
"is_dwelling": dwell_ms >= self.dwell_threshold_ms,
|
|
244
|
+
"click_count": self._click_count,
|
|
245
|
+
"scroll_total": round(self._scroll_total, 2),
|
|
246
|
+
"recent_dwell_regions": [d.to_dict() for d in self._dwells[-5:]],
|
|
247
|
+
"observations": observations,
|
|
248
|
+
"inferences": inferences,
|
|
249
|
+
"cursor_label": f"Cursor: X {int(latest.x*1920)} Y {int(latest.y*1080)} (normalized {latest.x:.3f}, {latest.y:.3f})",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def clear(self):
|
|
253
|
+
self._events.clear()
|
|
254
|
+
self._dwells.clear()
|
|
255
|
+
self._click_count = 0
|
|
256
|
+
self._scroll_total = 0
|
|
257
|
+
self._dwell_start = None
|
|
258
|
+
self._last_emit_time = 0.0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT v0.8.a — Vision Event System (spec 41).
|
|
3
|
+
|
|
4
|
+
Shared event names + helper to emit on both the backend bus (eventbus)
|
|
5
|
+
and the UI event stream (event_stream) where appropriate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
# Vision event names (spec 41)
|
|
14
|
+
VISION_SESSION_STARTED = "VISION_SESSION_STARTED"
|
|
15
|
+
VISION_SESSION_STOPPED = "VISION_SESSION_STOPPED"
|
|
16
|
+
VISION_CAPTURE_STARTED = "VISION_CAPTURE_STARTED"
|
|
17
|
+
VISION_CAPTURE_ENDED = "VISION_CAPTURE_ENDED"
|
|
18
|
+
VISION_FRAME_SELECTED = "VISION_FRAME_SELECTED"
|
|
19
|
+
VISION_FRAME_ANALYZED = "VISION_FRAME_ANALYZED"
|
|
20
|
+
VISION_CURSOR_EVENT = "VISION_CURSOR_EVENT"
|
|
21
|
+
VISION_ANNOTATION_CREATED = "VISION_ANNOTATION_CREATED"
|
|
22
|
+
VISION_ANNOTATION_UPDATED = "VISION_ANNOTATION_UPDATED"
|
|
23
|
+
VISION_ANNOTATION_DELETED = "VISION_ANNOTATION_DELETED"
|
|
24
|
+
VISION_USER_MESSAGE = "VISION_USER_MESSAGE"
|
|
25
|
+
VISION_AI_ANALYSIS = "VISION_AI_ANALYSIS"
|
|
26
|
+
VISION_FIX_PROPOSED = "VISION_FIX_PROPOSED"
|
|
27
|
+
VISION_FIX_APPLIED = "VISION_FIX_APPLIED"
|
|
28
|
+
VISION_PREVIEW_REFRESHED = "VISION_PREVIEW_REFRESHED"
|
|
29
|
+
VISION_VERIFICATION_COMPLETE = "VISION_VERIFICATION_COMPLETE"
|
|
30
|
+
|
|
31
|
+
ALL_VISION_EVENTS = [
|
|
32
|
+
VISION_SESSION_STARTED,
|
|
33
|
+
VISION_SESSION_STOPPED,
|
|
34
|
+
VISION_CAPTURE_STARTED,
|
|
35
|
+
VISION_CAPTURE_ENDED,
|
|
36
|
+
VISION_FRAME_SELECTED,
|
|
37
|
+
VISION_FRAME_ANALYZED,
|
|
38
|
+
VISION_CURSOR_EVENT,
|
|
39
|
+
VISION_ANNOTATION_CREATED,
|
|
40
|
+
VISION_ANNOTATION_UPDATED,
|
|
41
|
+
VISION_ANNOTATION_DELETED,
|
|
42
|
+
VISION_USER_MESSAGE,
|
|
43
|
+
VISION_AI_ANALYSIS,
|
|
44
|
+
VISION_FIX_PROPOSED,
|
|
45
|
+
VISION_FIX_APPLIED,
|
|
46
|
+
VISION_PREVIEW_REFRESHED,
|
|
47
|
+
VISION_VERIFICATION_COMPLETE,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit_vision_event(event: str, **data: Any):
|
|
52
|
+
"""Emit a vision event on the backend bus + event_stream (best effort)."""
|
|
53
|
+
data.setdefault("ts", time.time())
|
|
54
|
+
data.setdefault("source", "vision")
|
|
55
|
+
# backend bus (sync pub/sub) — ui/app.py can subscribe
|
|
56
|
+
try:
|
|
57
|
+
from .. import eventbus
|
|
58
|
+
eventbus.bus.publish(event, **data)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
# event_stream (bounded buffer with subscribers)
|
|
62
|
+
try:
|
|
63
|
+
from .. import event_stream
|
|
64
|
+
event_stream.stream.emit(event, **data)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAT v0.8.a — Frame Pipeline.
|
|
3
|
+
|
|
4
|
+
Adaptive sampling, change detection, quality scoring, and frame selection.
|
|
5
|
+
The pipeline decides which frames are worth sending to the AI model,
|
|
6
|
+
discards duplicates/blur, and maintains a sliding window of context frames.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChangeLevel(str, Enum):
|
|
20
|
+
NONE = "none" # No meaningful change
|
|
21
|
+
MINOR = "minor" # Small UI update
|
|
22
|
+
MODERATE = "moderate" # Noticeable change, worth keeping
|
|
23
|
+
MAJOR = "major" # Significant change, must keep
|
|
24
|
+
CRITICAL = "critical" # Error/screen change, always keep
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FrameMeta:
|
|
29
|
+
timestamp: float = 0.0
|
|
30
|
+
width: int = 0
|
|
31
|
+
height: int = 0
|
|
32
|
+
pointer_x: float = 0.0
|
|
33
|
+
pointer_y: float = 0.0
|
|
34
|
+
pointer_down: bool = False
|
|
35
|
+
scroll_y: int = 0
|
|
36
|
+
active_element: str = ""
|
|
37
|
+
url: str = ""
|
|
38
|
+
title: str = ""
|
|
39
|
+
annotations: List[Dict] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"timestamp": self.timestamp,
|
|
44
|
+
"width": self.width,
|
|
45
|
+
"height": self.height,
|
|
46
|
+
"pointer_x": self.pointer_x,
|
|
47
|
+
"pointer_y": self.pointer_y,
|
|
48
|
+
"pointer_down": self.pointer_down,
|
|
49
|
+
"scroll_y": self.scroll_y,
|
|
50
|
+
"active_element": self.active_element,
|
|
51
|
+
"url": self.url,
|
|
52
|
+
"title": self.title,
|
|
53
|
+
"annotations": self.annotations,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class FrameEntry:
|
|
59
|
+
frame_b64: str
|
|
60
|
+
meta: FrameMeta
|
|
61
|
+
index: int
|
|
62
|
+
change_level: ChangeLevel
|
|
63
|
+
quality_score: float
|
|
64
|
+
hash: str
|
|
65
|
+
kept: bool = True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FramePipeline:
|
|
69
|
+
"""Manages frame ingestion, change detection, and selection."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, config: Any = None):
|
|
72
|
+
self._config = config
|
|
73
|
+
self._frames: List[FrameEntry] = []
|
|
74
|
+
self._last_hash: str = ""
|
|
75
|
+
self._last_change_time: float = 0.0
|
|
76
|
+
self._frame_count: int = 0
|
|
77
|
+
self._change_threshold = getattr(config, "change_threshold", 0.05)
|
|
78
|
+
self._max_frames = getattr(config, "max_frames", 500)
|
|
79
|
+
self._saved_max = getattr(config, "saved_frames_max", 20)
|
|
80
|
+
self._context_window = getattr(config, "context_window_frames", 5)
|
|
81
|
+
self._min_frame_interval = 1.0 / max(getattr(config, "fps", 1.0), 0.1)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def frame_count(self) -> int:
|
|
85
|
+
return self._frame_count
|
|
86
|
+
|
|
87
|
+
def ingest(self, frame_b64: str,
|
|
88
|
+
meta: Optional[FrameMeta] = None) -> Optional[Dict[str, Any]]:
|
|
89
|
+
"""Ingest a frame, detect change, decide whether to keep it.
|
|
90
|
+
Returns frame info dict if the frame was selected, None otherwise.
|
|
91
|
+
"""
|
|
92
|
+
if meta is None:
|
|
93
|
+
meta = FrameMeta(timestamp=time.time())
|
|
94
|
+
|
|
95
|
+
self._frame_count += 1
|
|
96
|
+
frame_hash = self._compute_hash(frame_b64)
|
|
97
|
+
change_level = self._detect_change(frame_b64, frame_hash)
|
|
98
|
+
quality = self._score_quality(frame_b64, meta)
|
|
99
|
+
|
|
100
|
+
if change_level == ChangeLevel.NONE and quality < 0.3:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
entry = FrameEntry(
|
|
104
|
+
frame_b64=frame_b64,
|
|
105
|
+
meta=meta,
|
|
106
|
+
index=self._frame_count,
|
|
107
|
+
change_level=change_level,
|
|
108
|
+
quality_score=quality,
|
|
109
|
+
hash=frame_hash,
|
|
110
|
+
kept=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if len(self._frames) >= self._max_frames:
|
|
114
|
+
self._prune()
|
|
115
|
+
|
|
116
|
+
self._frames.append(entry)
|
|
117
|
+
self._last_hash = frame_hash
|
|
118
|
+
if change_level.value in ("moderate", "major", "critical"):
|
|
119
|
+
self._last_change_time = time.time()
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"frame_index": entry.index,
|
|
123
|
+
"change_level": change_level.value,
|
|
124
|
+
"quality_score": round(quality, 3),
|
|
125
|
+
"kept": True,
|
|
126
|
+
"meta": meta.to_dict(),
|
|
127
|
+
"total_frames": self._frame_count,
|
|
128
|
+
"stored_frames": len(self._frames),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def get_context_frames(self, n: Optional[int] = None) -> List[Dict]:
|
|
132
|
+
"""Return the last N frames for AI context (without full base64)."""
|
|
133
|
+
n = n or self._context_window
|
|
134
|
+
recent = [f for f in self._frames if f.kept][-n:]
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
"frame_index": f.index,
|
|
138
|
+
"change_level": f.change_level.value,
|
|
139
|
+
"quality_score": round(f.quality_score, 3),
|
|
140
|
+
"meta": f.meta.to_dict(),
|
|
141
|
+
"has_annotations": bool(f.meta.annotations),
|
|
142
|
+
}
|
|
143
|
+
for f in recent
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
def get_latest_frame(self) -> Optional[FrameEntry]:
|
|
147
|
+
kept = [f for f in self._frames if f.kept]
|
|
148
|
+
return kept[-1] if kept else None
|
|
149
|
+
|
|
150
|
+
def get_frame_b64(self, frame_index: int) -> Optional[str]:
|
|
151
|
+
for f in self._frames:
|
|
152
|
+
if f.index == frame_index:
|
|
153
|
+
return f.frame_b64
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def get_best_frame(self) -> Optional[FrameEntry]:
|
|
157
|
+
"""Select the best frame for AI analysis: highest change + quality."""
|
|
158
|
+
kept = [f for f in self._frames if f.kept]
|
|
159
|
+
if not kept:
|
|
160
|
+
return None
|
|
161
|
+
ranked = sorted(
|
|
162
|
+
kept,
|
|
163
|
+
key=lambda f: (
|
|
164
|
+
list(ChangeLevel).index(f.change_level) * 10
|
|
165
|
+
+ f.quality_score
|
|
166
|
+
),
|
|
167
|
+
reverse=True,
|
|
168
|
+
)
|
|
169
|
+
return ranked[0]
|
|
170
|
+
|
|
171
|
+
def get_summary(self) -> Dict[str, Any]:
|
|
172
|
+
kept = [f for f in self._frames if f.kept]
|
|
173
|
+
return {
|
|
174
|
+
"total_frames": self._frame_count,
|
|
175
|
+
"stored_frames": len(self._frames),
|
|
176
|
+
"kept_frames": len(kept),
|
|
177
|
+
"change_levels": {
|
|
178
|
+
level.value: sum(
|
|
179
|
+
1 for f in kept if f.change_level == level
|
|
180
|
+
)
|
|
181
|
+
for level in ChangeLevel
|
|
182
|
+
},
|
|
183
|
+
"avg_quality": round(
|
|
184
|
+
sum(f.quality_score for f in kept) / max(len(kept), 1), 3
|
|
185
|
+
),
|
|
186
|
+
"context_window": self._context_window,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def clear(self):
|
|
190
|
+
self._frames.clear()
|
|
191
|
+
self._last_hash = ""
|
|
192
|
+
self._frame_count = 0
|
|
193
|
+
|
|
194
|
+
def _compute_hash(self, frame_b64: str) -> str:
|
|
195
|
+
sample = frame_b64[:4096] if len(frame_b64) > 4096 else frame_b64
|
|
196
|
+
return hashlib.md5(sample.encode("ascii", errors="ignore")).hexdigest()
|
|
197
|
+
|
|
198
|
+
def _detect_change(self, frame_b64: str, frame_hash: str) -> ChangeLevel:
|
|
199
|
+
if not self._last_hash:
|
|
200
|
+
return ChangeLevel.MAJOR
|
|
201
|
+
|
|
202
|
+
if frame_hash == self._last_hash:
|
|
203
|
+
return ChangeLevel.NONE
|
|
204
|
+
|
|
205
|
+
similarity = self._compare_hashes(self._last_hash, frame_hash)
|
|
206
|
+
if similarity > 0.97:
|
|
207
|
+
return ChangeLevel.NONE
|
|
208
|
+
elif similarity > 0.90:
|
|
209
|
+
return ChangeLevel.MINOR
|
|
210
|
+
elif similarity > 0.75:
|
|
211
|
+
return ChangeLevel.MODERATE
|
|
212
|
+
elif similarity > 0.50:
|
|
213
|
+
return ChangeLevel.MAJOR
|
|
214
|
+
else:
|
|
215
|
+
return ChangeLevel.CRITICAL
|
|
216
|
+
|
|
217
|
+
def _compare_hashes(self, h1: str, h2: str) -> float:
|
|
218
|
+
"""Compare two MD5 hashes by hex char similarity."""
|
|
219
|
+
if not h1 or not h2 or len(h1) != len(h2):
|
|
220
|
+
return 0.0
|
|
221
|
+
matches = sum(a == b for a, b in zip(h1, h2))
|
|
222
|
+
return matches / len(h1)
|
|
223
|
+
|
|
224
|
+
def _score_quality(self, frame_b64: str, meta: FrameMeta) -> float:
|
|
225
|
+
"""Score frame quality 0.0-1.0. Higher = clearer, more useful."""
|
|
226
|
+
score = 0.5
|
|
227
|
+
data_len = len(frame_b64)
|
|
228
|
+
if data_len < 1000:
|
|
229
|
+
return 0.1
|
|
230
|
+
if 5000 < data_len < 500000:
|
|
231
|
+
score += 0.15
|
|
232
|
+
elif data_len > 500000:
|
|
233
|
+
score += 0.1
|
|
234
|
+
if meta.width > 800 and meta.height > 600:
|
|
235
|
+
score += 0.15
|
|
236
|
+
elif meta.width > 400 and meta.height > 300:
|
|
237
|
+
score += 0.05
|
|
238
|
+
if meta.pointer_down:
|
|
239
|
+
score += 0.1
|
|
240
|
+
if meta.active_element:
|
|
241
|
+
score += 0.1
|
|
242
|
+
if meta.annotations:
|
|
243
|
+
score += 0.1
|
|
244
|
+
return min(score, 1.0)
|
|
245
|
+
|
|
246
|
+
def _prune(self):
|
|
247
|
+
"""Remove oldest low-priority frames when at capacity."""
|
|
248
|
+
if len(self._frames) <= self._max_frames:
|
|
249
|
+
return
|
|
250
|
+
removable = [
|
|
251
|
+
f for f in self._frames
|
|
252
|
+
if f.change_level in (ChangeLevel.NONE, ChangeLevel.MINOR)
|
|
253
|
+
]
|
|
254
|
+
removable.sort(key=lambda f: f.index)
|
|
255
|
+
to_remove = len(self._frames) - self._max_frames + 50
|
|
256
|
+
for f in removable[:to_remove]:
|
|
257
|
+
self._frames.remove(f)
|
|
258
|
+
if len(self._frames) > self._max_frames:
|
|
259
|
+
self._frames = self._frames[-self._max_frames:]
|