eyra 4.3.4__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 (102) hide show
  1. chat/__init__.py +0 -0
  2. chat/capture.py +153 -0
  3. chat/complexity_scorer.py +330 -0
  4. chat/message_handler.py +270 -0
  5. chat/session_state.py +14 -0
  6. clients/__init__.py +0 -0
  7. clients/ai_client.py +508 -0
  8. clients/base_client.py +131 -0
  9. eyra-4.3.4.dist-info/METADATA +299 -0
  10. eyra-4.3.4.dist-info/RECORD +102 -0
  11. eyra-4.3.4.dist-info/WHEEL +4 -0
  12. eyra-4.3.4.dist-info/entry_points.txt +8 -0
  13. eyra-4.3.4.dist-info/licenses/LICENSE +113 -0
  14. main.py +128 -0
  15. runtime/__init__.py +0 -0
  16. runtime/actions.py +185 -0
  17. runtime/capabilities.py +206 -0
  18. runtime/certification.py +2690 -0
  19. runtime/cli.py +1207 -0
  20. runtime/coding_jobs.py +28 -0
  21. runtime/connectors/__init__.py +0 -0
  22. runtime/connectors/certification.py +136 -0
  23. runtime/connectors/cli.py +52 -0
  24. runtime/connectors/manifest.py +305 -0
  25. runtime/connectors/registry.py +218 -0
  26. runtime/connectors/runner.py +368 -0
  27. runtime/connectors/types.py +179 -0
  28. runtime/context.py +85 -0
  29. runtime/dictation.py +97 -0
  30. runtime/examples.py +47 -0
  31. runtime/external_agents.py +355 -0
  32. runtime/history/__init__.py +38 -0
  33. runtime/history/protocol.py +35 -0
  34. runtime/history/redaction.py +140 -0
  35. runtime/history/store.py +155 -0
  36. runtime/history/types.py +123 -0
  37. runtime/intents.py +90 -0
  38. runtime/jobs.py +523 -0
  39. runtime/live_session.py +2589 -0
  40. runtime/memory/__init__.py +11 -0
  41. runtime/memory/compression.py +48 -0
  42. runtime/memory/files.py +95 -0
  43. runtime/memory/mcp_client.py +181 -0
  44. runtime/memory/policy.py +35 -0
  45. runtime/memory/service.py +241 -0
  46. runtime/models.py +99 -0
  47. runtime/operator_loop.py +55 -0
  48. runtime/planner.py +193 -0
  49. runtime/preflight.py +433 -0
  50. runtime/privacy.py +108 -0
  51. runtime/resources/EyraMenuBar/Package.swift +29 -0
  52. runtime/resources/EyraMenuBar/Sources/EyraMenuBar/AppState.swift +147 -0
  53. runtime/resources/EyraMenuBar/Sources/EyraMenuBar/EyraCLI.swift +64 -0
  54. runtime/resources/EyraMenuBar/Sources/EyraMenuBar/EyraMenuBarApp.swift +22 -0
  55. runtime/resources/EyraMenuBar/Sources/EyraMenuBar/MenuViews.swift +188 -0
  56. runtime/resources/EyraMenuBar/Sources/EyraMenuBarCore/StatusModel.swift +67 -0
  57. runtime/resources/EyraMenuBar/Tests/EyraMenuBarTests/StatusModelTests.swift +58 -0
  58. runtime/resources/scripts/build_menu_bar_app.sh +89 -0
  59. runtime/routing/__init__.py +6 -0
  60. runtime/routing/effort.py +21 -0
  61. runtime/routing/fallback.py +61 -0
  62. runtime/routing/model_registry.py +158 -0
  63. runtime/routing/router.py +352 -0
  64. runtime/routing/tool_policy.py +350 -0
  65. runtime/routing/trace.py +80 -0
  66. runtime/routing/types.py +180 -0
  67. runtime/service.py +184 -0
  68. runtime/settings_catalog.py +131 -0
  69. runtime/shared.py +130 -0
  70. runtime/speech_controller.py +201 -0
  71. runtime/startup.py +550 -0
  72. runtime/status_presenter.py +186 -0
  73. runtime/tasks.py +370 -0
  74. runtime/tooling.py +244 -0
  75. runtime/triggers.py +344 -0
  76. runtime/vision.py +80 -0
  77. runtime/voice_diagnostics.py +529 -0
  78. runtime/voice_input.py +299 -0
  79. tools/__init__.py +6 -0
  80. tools/approval.py +121 -0
  81. tools/base.py +53 -0
  82. tools/browser.py +567 -0
  83. tools/clipboard.py +34 -0
  84. tools/filesystem.py +1146 -0
  85. tools/macos_context.py +94 -0
  86. tools/mcp_stdio.py +224 -0
  87. tools/operator.py +1692 -0
  88. tools/pdf.py +115 -0
  89. tools/registry.py +79 -0
  90. tools/screenshot.py +68 -0
  91. tools/system_info.py +106 -0
  92. tools/time_tool.py +19 -0
  93. tools/weather.py +53 -0
  94. utils/__init__.py +0 -0
  95. utils/image_history.py +55 -0
  96. utils/mock_client.py +60 -0
  97. utils/semantic_history.py +16 -0
  98. utils/settings.py +261 -0
  99. utils/sound_player.py +51 -0
  100. utils/theme.py +14 -0
  101. web/__init__.py +1 -0
  102. web/server.py +2470 -0
chat/__init__.py ADDED
File without changes
chat/capture.py ADDED
@@ -0,0 +1,153 @@
1
+ """In-memory screenshot capture and base64 encoding via mss + Pillow."""
2
+
3
+ import base64
4
+ import logging
5
+ from collections.abc import Awaitable, Callable
6
+ from io import BytesIO
7
+
8
+ import mss
9
+ from PIL import Image
10
+
11
+ from utils.sound_player import play_sound
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _validate_pil_image(img: Image.Image) -> bool:
17
+ """
18
+ Validate that 'img' is a non-empty, valid PIL Image.
19
+
20
+ Returns:
21
+ bool: True if 'img' is valid; False otherwise.
22
+ """
23
+ if not img:
24
+ logger.error("Validation failed: Image object is None.")
25
+ return False
26
+
27
+ if not isinstance(img, Image.Image):
28
+ logger.error("Validation failed: Provided object is not a PIL Image.")
29
+ return False
30
+
31
+ width, height = img.size
32
+ if width == 0 or height == 0:
33
+ logger.warning("Validation failed: Image has zero width or height.")
34
+ return False
35
+
36
+ return True
37
+
38
+
39
+ def _encode_pil_image_in_memory(
40
+ img: Image.Image, max_size: tuple[int, int] = (1024, 728), quality: int = 85
41
+ ) -> str:
42
+ """
43
+ 1) Optionally resizes 'img' if it's larger than 'max_size'.
44
+ 2) Converts to "RGB" if needed.
45
+ 3) Removes all metadata.
46
+ 4) Saves to an in-memory buffer (JPEG) with the specified 'quality'.
47
+ 5) Returns the base64-encoded string of that buffer.
48
+
49
+ Args:
50
+ img (Image.Image): The PIL Image to encode.
51
+ max_size (tuple[int, int]): (width, height) limit for in-memory resize.
52
+ quality (int): JPEG compression quality (1-100).
53
+
54
+ Raises:
55
+ ValueError: If 'img' is invalid or if the in-memory buffer is empty.
56
+
57
+ Returns:
58
+ str: The base64-encoded JPEG data.
59
+ """
60
+ if not _validate_pil_image(img):
61
+ raise ValueError("Invalid in-memory image; cannot encode.")
62
+
63
+ logger.debug(f"Original capture size: {img.size}, mode: {img.mode}")
64
+
65
+ # Resize if needed
66
+ if img.width > max_size[0] or img.height > max_size[1]:
67
+ logger.debug("Resizing image in-memory before encoding.")
68
+ img.thumbnail(max_size, Image.Resampling.LANCZOS)
69
+
70
+ # Convert to RGB if needed
71
+ if img.mode in ("RGBA", "P", "CMYK", "L"):
72
+ img = img.convert("RGB")
73
+
74
+ # Remove metadata
75
+ img.info = {}
76
+
77
+ # Save to a BytesIO buffer
78
+ buf = BytesIO()
79
+ img.save(
80
+ buf,
81
+ format="JPEG",
82
+ quality=quality,
83
+ optimize=True,
84
+ progressive=True,
85
+ subsampling=2, # 4:2:0 subsampling
86
+ )
87
+ buf.seek(0)
88
+
89
+ data = buf.read()
90
+ if not data:
91
+ raise ValueError("No data found in the in-memory buffer after saving.")
92
+
93
+ encoded = base64.b64encode(data).decode("utf-8")
94
+ if not encoded:
95
+ raise ValueError("Base64-encoded string is empty.")
96
+
97
+ logger.debug(f"Encoded image size in base64: {len(encoded)} characters")
98
+ return encoded
99
+
100
+
101
+ async def capture_screenshot_in_memory() -> Image.Image:
102
+ """
103
+ Capture the primary monitor's screenshot directly into a PIL Image
104
+ without writing to disk (using 'mss').
105
+
106
+ Returns:
107
+ PIL.Image: The screenshot as an in-memory PIL Image.
108
+ """
109
+ import asyncio
110
+
111
+ await play_sound("camera")
112
+
113
+ def _grab():
114
+ with mss.mss() as sct:
115
+ monitor = sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0]
116
+ raw_screenshot = sct.grab(monitor)
117
+ return Image.frombytes(
118
+ "RGB", (raw_screenshot.width, raw_screenshot.height), raw_screenshot.rgb
119
+ )
120
+
121
+ img = await asyncio.to_thread(_grab)
122
+ logger.info("Screenshot captured in memory.")
123
+ return img
124
+
125
+
126
+ async def _capture_and_encode(
127
+ capture_func: Callable[[], Awaitable[Image.Image]],
128
+ max_size: tuple[int, int],
129
+ quality: int,
130
+ ) -> str:
131
+ """
132
+ Private helper to unify the flow of capture + encode.
133
+ """
134
+ pil_image = await capture_func()
135
+ return _encode_pil_image_in_memory(pil_image, max_size, quality)
136
+
137
+
138
+ async def capture_screenshot_and_encode(
139
+ max_size: tuple[int, int] = (1024, 728), quality: int = 99
140
+ ) -> str:
141
+ """
142
+ Capture a screenshot entirely in memory, then encode it as a base64 JPEG.
143
+
144
+ Args:
145
+ max_size (Tuple[int,int]): Resize limit for final image if needed.
146
+ quality (int): JPEG compression quality.
147
+
148
+ Returns:
149
+ str: Base64-encoded JPEG data.
150
+ """
151
+ return await _capture_and_encode(
152
+ capture_func=capture_screenshot_in_memory, max_size=max_size, quality=quality
153
+ )
@@ -0,0 +1,330 @@
1
+ """
2
+ Deterministic prompt router for Eyra.
3
+
4
+ Routes user prompts to Simple, Moderate, or Complex models using
5
+ pattern matching and weighted signal scoring. No ML dependencies.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from enum import Enum
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Public types (unchanged interface)
18
+ # ---------------------------------------------------------------------------
19
+
20
+ class ComplexityLevel(str, Enum):
21
+ SIMPLE = "Simple"
22
+ MODERATE = "Moderate"
23
+ COMPLEX = "Complex"
24
+
25
+
26
+ class ComplexityResponse(BaseModel):
27
+ model_config = ConfigDict(use_enum_values=True)
28
+
29
+ classification: ComplexityLevel = Field(..., description="Complexity level.")
30
+ confidence: float = Field(..., ge=0, le=1, description="Confidence [0..1]")
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Hard-match patterns
35
+ # ---------------------------------------------------------------------------
36
+
37
+ _SIMPLE_PATTERNS: list[re.Pattern] = [
38
+ # Greetings
39
+ re.compile(r"^(hi|hello|hey|yo|sup|howdy|hola|morning|evening|afternoon)[\s!?.]*$", re.I),
40
+ # Acknowledgments and thanks
41
+ re.compile(r"^(thanks?|thank\s*you|thx|ty|cheers|appreciated|cool|nice|great|awesome|perfect|ok(ay)?|sure|got\s*it|noted|done|right|yep|yup|nope|nah)[\s!?.]*$", re.I),
42
+ # Yes / no
43
+ re.compile(r"^(yes|no|yeah|nah|yep|yup|nope|y|n)[\s!?.]*$", re.I),
44
+ # Farewells
45
+ re.compile(r"^(bye|goodbye|see\s*ya|later|ciao|goodnight|gn)[\s!?.]*$", re.I),
46
+ # Tiny social turns
47
+ re.compile(r"^(lol|haha|heh|wow|omg|bruh|nice one|fair enough|same|true)[\s!?.]*$", re.I),
48
+ ]
49
+
50
+ _COMPLEX_CODE_PATTERNS: list[re.Pattern] = [
51
+ re.compile(r"\b(implement|write|code|build|create)\b.{0,40}\b(function|class|module|script|program|api|endpoint|server|parser|compiler|handler|service|component|tree|queue|stack|graph|struct|iterator)\b", re.I),
52
+ re.compile(r"\b(debug|fix|patch|troubleshoot|diagnose)\b.{0,30}\b(bug|error|issue|crash|exception|failure|segfault)\b", re.I),
53
+ re.compile(r"\b(debug|fix|patch|rewrite|optimize)\b.{0,20}\b(this\s+)?(regex|query|sql|css|code|script|function|loop|algorithm)\b", re.I),
54
+ re.compile(r"\b(review|audit|inspect)\b.{0,30}\b(code|pull\s*request|pr|commit|diff|security)\b", re.I),
55
+ re.compile(r"\b(refactor|rewrite|optimize|redesign)\b.{0,30}\b(code|function|class|module|system|architecture)\b", re.I),
56
+ ]
57
+
58
+ _COMPLEX_ANALYSIS_PATTERNS: list[re.Pattern] = [
59
+ re.compile(r"\b(compare|contrast|evaluate|analyze|assess)\b.{0,80}\b(approach|methods?|algorithms?|architecture|frameworks?|strategy|strategies|trade.?offs?)\b", re.I),
60
+ re.compile(r"\b(design|architect|plan)\b.{0,30}\b(system|schema|database|pipeline|workflow|infrastructure)\b", re.I),
61
+ re.compile(r"\b(design|architect|plan)\b.{0,80}\b(scheduler|worker\s+pool|task\s+(queue|runner|scheduler|system)|job\s+queue|retries?|cancell?ation|persistence)\b", re.I),
62
+ re.compile(r"\b(prove|derive|formalize)\b", re.I),
63
+ re.compile(r"\bmulti.?step\b|\bstep.?by.?step\b.{0,20}\b(plan|solution|approach)\b", re.I),
64
+ re.compile(r"\b(expert|in.?depth|thorough|comprehensive|detailed)\b.{0,20}\b(analysis|review|assessment|explanation)\b", re.I),
65
+ ]
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Scoring cues (weighted signal lists)
69
+ # ---------------------------------------------------------------------------
70
+
71
+ _REASONING_CUES = [
72
+ "why", "how does", "explain", "what causes", "reason", "because",
73
+ "trade-off", "trade-offs", "tradeoff", "tradeoffs", "pros and cons", "implications", "consequence",
74
+ "assumption", "constraint", "caveat",
75
+ "difference between", "differences between", "distinguish",
76
+ ]
77
+
78
+ _CODE_DEBUG_CUES = [
79
+ "function", "class", "method", "variable", "loop", "recursion",
80
+ "algorithm", "regex", "sql", "query", "api", "endpoint", "http",
81
+ "error", "bug", "stack trace", "exception", "crash", "lint",
82
+ "test", "unit test", "integration", "deploy", "docker", "ci/cd",
83
+ "git", "branch", "merge", "commit", "typescript", "python", "rust",
84
+ "javascript", "java", "kotlin", "swift", "go", "golang", "c++",
85
+ "bash", "shell", "terminal", "command line", "async", "scheduler",
86
+ "task queue", "worker pool", "retry", "retries", "cancellation", "persistence",
87
+ ]
88
+
89
+ _STRUCTURED_OUTPUT_CUES = [
90
+ "table", "json", "yaml", "xml", "csv", "markdown", "format as",
91
+ "schema", "template", "spec", "specification",
92
+ ]
93
+
94
+ # Curated domain terms: ~80 high-signal exact phrases
95
+ _DOMAIN_TERMS = frozenset([
96
+ # CS / programming
97
+ "algorithm", "data structure", "time complexity", "space complexity",
98
+ "dynamic programming", "recursion", "binary search", "hash map",
99
+ "linked list", "tree traversal", "graph algorithm", "sorting",
100
+ "concurrency", "mutex", "semaphore", "deadlock", "race condition",
101
+ "design pattern", "dependency injection", "microservice",
102
+ # ML / AI
103
+ "machine learning", "deep learning", "neural network", "neural networks", "transformer",
104
+ "gradient descent", "backpropagation", "loss function", "overfitting",
105
+ "reinforcement learning", "attention mechanism", "fine-tuning",
106
+ "embedding", "tokenizer", "inference", "training",
107
+ # Math
108
+ "linear algebra", "calculus", "differential equation", "probability",
109
+ "statistics", "eigenvalue", "matrix", "fourier transform",
110
+ "optimization", "convex", "stochastic",
111
+ # Infra / DevOps
112
+ "kubernetes", "docker", "ci/cd", "load balancer", "reverse proxy",
113
+ "database", "replication", "sharding", "cache invalidation",
114
+ "message queue", "event-driven", "serverless",
115
+ # Security
116
+ "encryption", "authentication", "authorization", "vulnerability",
117
+ "sql injection", "xss", "csrf", "zero-day", "penetration testing",
118
+ "cryptography", "certificate", "tls", "oauth",
119
+ # Systems
120
+ "operating system", "kernel", "memory management", "file system",
121
+ "process", "thread", "scheduler", "task scheduler", "worker pool", "job queue", "virtual memory",
122
+ "compiler", "interpreter", "garbage collection",
123
+ # Physics / quantum
124
+ "qubit", "quantum", "relativity", "superposition", "entanglement",
125
+ ])
126
+
127
+ # Short referential follow-ups that should inherit prior context
128
+ _FOLLOWUP_PATTERNS: list[re.Pattern] = [
129
+ re.compile(r"^(explain|elaborate|tell me)\s*(more|further|again)[\s?.!]*$", re.I),
130
+ re.compile(r"^(why|how|what)[\s?!.]*$", re.I),
131
+ re.compile(r"^(continue|go on|keep going|more)[\s?.!]*$", re.I),
132
+ re.compile(r"^(rewrite|redo|do)\s*(it|that|this)(.{0,20})$", re.I),
133
+ re.compile(r"^(now\s+)?(in|using|with)\s+\w+[\s?.!]*$", re.I),
134
+ re.compile(r"^(can you|could you|please)\s+(expand|clarify|rephrase)[\s?.!]*", re.I),
135
+ ]
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Scorer
140
+ # ---------------------------------------------------------------------------
141
+
142
+ class ComplexityScorer:
143
+ """
144
+ Deterministic prompt router. No ML models, no heavy dependencies.
145
+ """
146
+
147
+ def __init__(self):
148
+ logger.info("ComplexityScorer (deterministic router) initialized.")
149
+
150
+ async def score_complexity(
151
+ self,
152
+ text_content: str | None,
153
+ messages: list[dict] | None = None,
154
+ ) -> ComplexityResponse:
155
+ text = (text_content or "").strip()
156
+
157
+ # 1. Hard-match simple
158
+ if text and self._matches_any(text, _SIMPLE_PATTERNS):
159
+ result = ComplexityResponse(classification=ComplexityLevel.SIMPLE, confidence=0.90)
160
+ logger.info(f"[Router] hard-simple -> {result.classification}")
161
+ return result
162
+
163
+ # 2. Hard-match complex
164
+ if text and self._matches_any(text, _COMPLEX_CODE_PATTERNS + _COMPLEX_ANALYSIS_PATTERNS):
165
+ result = ComplexityResponse(classification=ComplexityLevel.COMPLEX, confidence=0.90)
166
+ logger.info(f"[Router] hard-complex -> {result.classification}")
167
+ return result
168
+
169
+ # 3. Score-based routing
170
+ score = self._compute_text_score(text, messages)
171
+ result = self._classify_from_score(score)
172
+ logger.info(f"[Router] score={score:.3f} -> {result.classification} (conf={result.confidence:.2f})")
173
+ return result
174
+
175
+ # ----- Text score computation -----
176
+
177
+ def _compute_text_score(self, text: str, messages: list[dict] | None) -> float:
178
+ if not text:
179
+ return 0.0
180
+
181
+ text_lower = text.lower()
182
+ words = text_lower.split()
183
+ score = 0.0
184
+
185
+ # Reasoning cues (weight: 0.15 each, max ~0.45)
186
+ reasoning_hits = sum(1 for cue in _REASONING_CUES if re.search(r'\b' + re.escape(cue.strip()) + r'\b', text_lower))
187
+ score += min(0.45, reasoning_hits * 0.15)
188
+
189
+ # Code/debug cues (weight: 0.10 each, max ~0.40)
190
+ code_hits = sum(1 for cue in _CODE_DEBUG_CUES if self._cue_matches(cue, text_lower))
191
+ score += min(0.40, code_hits * 0.10)
192
+
193
+ # Structured output cues (weight: 0.10 each, max ~0.20)
194
+ struct_hits = sum(1 for cue in _STRUCTURED_OUTPUT_CUES if re.search(r'\b' + re.escape(cue.strip()) + r'\b', text_lower))
195
+ score += min(0.20, struct_hits * 0.10)
196
+
197
+ # Domain terms (weight: 0.12 each, max ~0.36)
198
+ domain_hits = sum(1 for term in _DOMAIN_TERMS if re.search(r'\b' + re.escape(term) + r'\b', text_lower))
199
+ score += min(0.36, domain_hits * 0.12)
200
+
201
+ # Constraint count: sentences with "must", "should", "require", "need to", "ensure"
202
+ constraint_words = ["must", "should", "require", "need to", "ensure", "make sure"]
203
+ constraint_hits = sum(1 for c in constraint_words if re.search(r'\b' + re.escape(c.strip()) + r'\b', text_lower))
204
+ score += min(0.20, constraint_hits * 0.10)
205
+
206
+ # Prompt length bonus (longer prompts tend toward complexity)
207
+ if len(words) > 50:
208
+ score += 0.15
209
+ elif len(words) > 25:
210
+ score += 0.08
211
+
212
+ # Short prompt with code cues: ensure at least Moderate threshold
213
+ if len(words) <= 8 and code_hits > 0:
214
+ score = max(score, 0.30)
215
+
216
+ # Very short prompt (1-3 words) with domain terms: ensure at least Moderate
217
+ if len(words) <= 3 and domain_hits > 0:
218
+ score = max(score, 0.30)
219
+
220
+ # Question mark alone (short question) slight reduction,
221
+ # but only if no domain terms or code cues were found
222
+ if text.endswith("?") and len(words) <= 6 and score < 0.4 and domain_hits == 0 and code_hits == 0:
223
+ score *= 0.7
224
+
225
+ # Follow-up context inheritance
226
+ if messages and self._is_followup(text):
227
+ prior_level = self._get_prior_complexity(messages)
228
+ if prior_level is not None:
229
+ # Blend: use at least the prior level
230
+ prior_score = {
231
+ ComplexityLevel.SIMPLE: 0.15,
232
+ ComplexityLevel.MODERATE: 0.45,
233
+ ComplexityLevel.COMPLEX: 0.75,
234
+ }.get(prior_level, 0.0)
235
+ score = max(score, prior_score)
236
+
237
+ return min(1.0, score)
238
+
239
+ def _is_followup(self, text: str) -> bool:
240
+ """Check if text is a short referential follow-up."""
241
+ if len(text.split()) > 8:
242
+ return False
243
+ return self._matches_any(text, _FOLLOWUP_PATTERNS)
244
+
245
+ def _get_prior_complexity(self, messages: list[dict]) -> ComplexityLevel | None:
246
+ """Look at the last 3 user messages for context."""
247
+ user_msgs = [m for m in messages if m.get("role") == "user"]
248
+ recent = user_msgs[-3:] if len(user_msgs) >= 3 else user_msgs
249
+
250
+ if not recent:
251
+ return None
252
+
253
+ # Score the most recent substantial user message
254
+ for msg in reversed(recent):
255
+ content = msg.get("content", "")
256
+ if isinstance(content, list):
257
+ content = " ".join(
258
+ item.get("text", "") for item in content if item.get("type") == "text"
259
+ )
260
+ content = content.strip()
261
+ if not content or self._is_followup(content):
262
+ continue
263
+ return self._classify_text_standalone(content)
264
+
265
+ return None
266
+
267
+ def _classify_text_standalone(self, text: str) -> ComplexityLevel:
268
+ """Classify a text prompt using full routing logic (no follow-up recursion)."""
269
+ if self._matches_any(text, _SIMPLE_PATTERNS):
270
+ return ComplexityLevel.SIMPLE
271
+ if self._matches_any(text, _COMPLEX_CODE_PATTERNS + _COMPLEX_ANALYSIS_PATTERNS):
272
+ return ComplexityLevel.COMPLEX
273
+ score = self._compute_text_score(text, messages=None)
274
+ return self._classify_from_score(score).classification
275
+
276
+ # ----- Classification from score -----
277
+
278
+ def _classify_from_score(self, score: float) -> ComplexityResponse:
279
+ """
280
+ Thresholds:
281
+ < 0.30 -> Simple
282
+ < 0.60 -> Moderate
283
+ >= 0.60 -> Complex
284
+
285
+ Confidence:
286
+ 0.90 for hard-rule (handled upstream)
287
+ 0.75 for clear margin from thresholds
288
+ 0.60 for borderline
289
+ """
290
+ if score < 0.30:
291
+ margin = (0.30 - score) / 0.30
292
+ confidence = 0.60 + 0.15 * margin # 0.60..0.75
293
+ return ComplexityResponse(classification=ComplexityLevel.SIMPLE, confidence=round(confidence, 2))
294
+ elif score < 0.60:
295
+ # Distance from nearest boundary
296
+ dist_low = score - 0.30
297
+ dist_high = 0.60 - score
298
+ margin = min(dist_low, dist_high) / 0.15
299
+ confidence = 0.60 + 0.15 * margin
300
+ return ComplexityResponse(classification=ComplexityLevel.MODERATE, confidence=round(confidence, 2))
301
+ else:
302
+ margin = min(1.0, (score - 0.60) / 0.40)
303
+ confidence = 0.60 + 0.15 * margin
304
+ return ComplexityResponse(classification=ComplexityLevel.COMPLEX, confidence=round(confidence, 2))
305
+
306
+ # ----- Helpers -----
307
+
308
+ @staticmethod
309
+ def _matches_any(text: str, patterns: list[re.Pattern]) -> bool:
310
+ return any(p.search(text) for p in patterns)
311
+
312
+ @staticmethod
313
+ def _cue_matches(cue: str, text_lower: str) -> bool:
314
+ """Match a scoring cue without treating ordinary English as code."""
315
+ normalized = cue.strip().lower()
316
+ if normalized == "go":
317
+ # "go" is common English. Count it as the language only near code/language cues.
318
+ return bool(
319
+ re.search(
320
+ r"\b(?:write|use|using|in|with|learn|code|programming|language)\b.{0,30}\bgo\b"
321
+ r"|\bgo\b.{0,30}\b(?:code|program|programming|function|module|package|language)\b",
322
+ text_lower,
323
+ re.I,
324
+ )
325
+ )
326
+ if normalized == "c++":
327
+ return bool(re.search(r"(?<!\w)c\+\+(?!\w)", text_lower, re.I))
328
+ if normalized and normalized[0].isalnum() and normalized[-1].isalnum():
329
+ return bool(re.search(r"\b" + re.escape(normalized) + r"\b", text_lower))
330
+ return normalized in text_lower