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.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,514 @@
1
+ """
2
+ CAT v0.7.9.0 — vision.py: the real image / multimodal pipeline.
3
+
4
+ image
5
+ ↓ validate (exists, decodable, size caps)
6
+ ↓ decode/normalize (EXIF orientation, color mode, alpha, format)
7
+ ↓ classify (photo / screenshot / scanned document / chart /
8
+ │ diagram / code screenshot / handwritten)
9
+ ↓ strategy (resize/compress only as needed — quality kept)
10
+ ↓ vision-capable model (chosen by model_router when the active one
11
+ │ can't see — never just "model can't see this")
12
+ ↓ structured analysis (comprehensive extraction prompt per kind)
13
+
14
+ Rules:
15
+ * The image BYTES actually travel to the model (base64 data URL), never
16
+ just the filename.
17
+ * Normalization never degrades quality unnecessarily: images within the
18
+ budget pass through byte-for-byte; oversized ones are downscaled with
19
+ a high-quality filter and/or recompressed at high quality.
20
+ * Corrupted/undecodable files produce an explicit honest error — never a
21
+ fabricated description.
22
+ * Pillow is used when available (TIFF/BMP/WEBP conversion, EXIF fix);
23
+ without it, provider-native formats still work and TIFF/BMP get an
24
+ honest "cannot normalize" note instead of silent breakage.
25
+ """
26
+
27
+ if __name__ == "__main__":
28
+ print("This is a library file and is not meant to be run directly.")
29
+ import sys
30
+ sys.exit(1)
31
+
32
+ import base64
33
+ import io
34
+ import os
35
+ from typing import Dict, Optional, Tuple
36
+
37
+ # Provider-safe formats + generous-but-sane transport budget.
38
+ MAX_DIMENSION = 1568 # long-edge cap (matches common vision models' tile grid)
39
+ MAX_BYTES = 4 * 1024 * 1024 # ~4 MB payload after normalization (pre-base64)
40
+ JPEG_QUALITY = 88
41
+
42
+ PROVIDER_NATIVE = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
43
+ ALL_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
44
+ ".tiff", ".tif", ".ico"}
45
+
46
+ _MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
47
+ ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
48
+ ".tiff": "image/tiff", ".tif": "image/tiff", ".ico": "image/x-icon"}
49
+
50
+ _PIL = None
51
+ _PIL_TRIED = False
52
+
53
+
54
+ def _pil():
55
+ global _PIL, _PIL_TRIED
56
+ if not _PIL_TRIED:
57
+ _PIL_TRIED = True
58
+ try:
59
+ from PIL import Image # noqa: N811
60
+ _PIL = Image
61
+ except Exception:
62
+ _PIL = None
63
+ return _PIL
64
+
65
+
66
+ def pil_available() -> bool:
67
+ return _pil() is not None
68
+
69
+
70
+ def ocr_available() -> bool:
71
+ """True when a real OCR engine (pytesseract + tesseract binary) exists.
72
+ Never assumed — checked, honestly reported."""
73
+ try:
74
+ import pytesseract # noqa: F401
75
+ import shutil
76
+ return bool(shutil.which("tesseract"))
77
+ except Exception:
78
+ return False
79
+
80
+
81
+ class ImageError(Exception):
82
+ """Raised for genuinely unreadable/corrupted/missing images."""
83
+
84
+
85
+ # ------------------------------------------------------------- validation --
86
+
87
+ def validate(path: str) -> int:
88
+ """VALIDATE stage: existence, type, non-empty, sane size.
89
+ Returns file size in bytes; raises ImageError with the real reason."""
90
+ if not path or not os.path.lexists(path):
91
+ raise ImageError(f"image not found: {path}")
92
+ if os.path.isdir(path):
93
+ raise ImageError(f"path is a directory, not an image: {path}")
94
+ ext = os.path.splitext(path)[1].lower()
95
+ if ext not in ALL_IMAGE_EXTS:
96
+ raise ImageError(f"unsupported image extension: {ext or '(none)'}")
97
+ try:
98
+ size = os.path.getsize(path)
99
+ except OSError as e:
100
+ raise ImageError(f"cannot stat image: {e}")
101
+ if size == 0:
102
+ raise ImageError("image file is empty (0 bytes)")
103
+ if size > 64 * 1024 * 1024:
104
+ raise ImageError("image larger than the 64 MB safety limit")
105
+ # Magic-byte sniff: catch renamed/corrupted files before decode time.
106
+ try:
107
+ with open(path, "rb") as f:
108
+ head = f.read(16)
109
+ except OSError as e:
110
+ raise ImageError(f"cannot read image bytes: {e}")
111
+ if not head:
112
+ raise ImageError("image file is unreadable")
113
+ return size
114
+
115
+
116
+ # ------------------------------------------------------------ classification --
117
+
118
+ _CODE_IMG_HINTS = ("code", "screenshot_snippet", "ide", "editor")
119
+ _DOC_HINTS = ("document", "scan", "receipt", "invoice", "page", "paper", "letter")
120
+
121
+
122
+ def classify_image(path: str, prompt: str = "") -> str:
123
+ """SMART PIPELINE stage: best-effort local classifier for choosing the
124
+ processing strategy. Returns one of:
125
+ photo | screenshot | scanned_document | chart | diagram |
126
+ code_screenshot | handwritten.
127
+
128
+ Honest heuristic: filename hints + user wording + aspect ratio +
129
+ format priors. It biases the analysis PROMPT, it does not fabricate
130
+ facts about the content."""
131
+ name = os.path.basename(path).lower()
132
+ text = ((prompt or "") + " " + name).lower()
133
+
134
+ if any(h in text for h in ("handwrit", "hand-written", "notes photo")):
135
+ return "handwritten"
136
+ if any(h in text for h in _CODE_IMG_HINTS) or any(
137
+ h in name for h in ("code", "snippet", "ide_", "vscode", "screenshot_code")):
138
+ return "code_screenshot"
139
+ if any(h in text for h in ("chart", "graph", "plot", "bar ", "pie ")) \
140
+ or "chart" in name or "plot" in name:
141
+ return "chart"
142
+ if any(h in text for h in ("diagram", "flowchart", "uml", "architecture diagram",
143
+ "sequence diagram", "er diagram")) or "diagram" in name:
144
+ return "diagram"
145
+ if any(h in text for h in _DOC_HINTS) or any(h in name for h in ("scan", "doc", "receipt", "invoice")):
146
+ return "scanned_document"
147
+ if "screenshot" in text or "screen shot" in text or "screen-" in name or "screenshot" in name:
148
+ return "screenshot"
149
+
150
+ # Aspect/format priors: tall multi-page scans vs typical photos.
151
+ try:
152
+ info = inspect(path)
153
+ w, h = info.get("width", 0), info.get("height", 0)
154
+ fmt = str(info.get("format", "")).upper()
155
+ if w and h:
156
+ ratio = max(w, h) / max(1, min(w, h))
157
+ if ratio > 2.8 and min(w, h) > 800:
158
+ return "scanned_document"
159
+ if fmt == "PNG" and ratio < 1.6 and max(w, h) <= 2400:
160
+ return "screenshot"
161
+ except Exception:
162
+ pass
163
+ return "photo"
164
+
165
+
166
+ # ----------------------------------------------------------------- inspect --
167
+
168
+ def inspect(path: str) -> Dict:
169
+ """Real metadata about an image (dimensions, format, mode) — no
170
+ fabrication; empty dict fields when Pillow isn't available."""
171
+ out: Dict = {"width": 0, "height": 0, "format": "", "mode": ""}
172
+ Image = _pil()
173
+ if Image is None:
174
+ return out
175
+ try:
176
+ with Image.open(path) as im:
177
+ out["width"], out["height"] = im.size
178
+ out["format"] = (im.format or "").upper()
179
+ out["mode"] = (im.mode or "").upper()
180
+ except Exception:
181
+ pass
182
+ return out
183
+
184
+
185
+ # ------------------------------------------------------------- normalize --
186
+
187
+ def _exif_transpose(im):
188
+ """Apply EXIF orientation so photos taken sideways/right-way-down
189
+ display correctly. No-op for images without EXIF orientation."""
190
+ try:
191
+ from PIL import ImageOps
192
+ return ImageOps.exif_transpose(im)
193
+ except Exception:
194
+ return im
195
+
196
+
197
+ def _needs_conversion(fmt: str, mode: str, ext: str) -> bool:
198
+ """Formats/modes providers reject or mishandle."""
199
+ if ext == ".tiff" or ext == ".tif":
200
+ return True # TIFF rarely accepted by vision APIs
201
+ if ext == ".bmp":
202
+ return True # BMP is huge; convert to PNG/JPEG
203
+ if fmt in ("TIFF", "BMP", "MPO", "PPM"):
204
+ return True
205
+ if mode in ("CMYK", "I;16", "1"):
206
+ return True
207
+ if fmt == "GIF" and ext == ".gif":
208
+ return False # static GIFs are accepted natively
209
+ return False
210
+
211
+
212
+ def normalize(path: str,
213
+ max_dimension: int = MAX_DIMENSION,
214
+ max_bytes: int = MAX_BYTES) -> Tuple[str, bytes, Dict]:
215
+ """NORMALIZE stage: validate → decode → EXIF-fix → convert/rescale/
216
+ recompress ONLY as needed. Returns (mime, raw_bytes, meta).
217
+
218
+ meta includes what was actually done (honest reporting):
219
+ action: passthrough | converted | resized | resized+converted | error
220
+ Raises ImageError for genuinely corrupted files."""
221
+ size = validate(path)
222
+ ext = os.path.splitext(path)[1].lower()
223
+ meta = {"source_size": size, "action": "passthrough",
224
+ "normalized": False, "pil": pil_available()}
225
+
226
+ Image = _pil()
227
+ if Image is None:
228
+ # No Pillow: native formats pass through untouched; exotic ones
229
+ # fail loudly and honestly here rather than at the API.
230
+ if ext in PROVIDER_NATIVE:
231
+ with open(path, "rb") as f:
232
+ return _MIME[ext], f.read(), meta
233
+ raise ImageError(
234
+ f"{ext} images need Pillow ('pip install pillow') to be "
235
+ f"converted into a provider-compatible format")
236
+
237
+ try:
238
+ with Image.open(path) as im:
239
+ im.load()
240
+ fmt = (im.format or "").upper()
241
+ mode = im.mode or ""
242
+ meta.update({"width": im.size[0], "height": im.size[1],
243
+ "format": fmt, "mode": mode})
244
+ needs_convert = _needs_conversion(fmt, mode, ext)
245
+ too_big_dim = max(im.size) > max_dimension
246
+
247
+ if not needs_convert and not too_big_dim and size <= max_bytes:
248
+ # Quality rule: nothing to do → original bytes verbatim.
249
+ with open(path, "rb") as f:
250
+ meta["action"] = "passthrough"
251
+ return _MIME.get(ext, "image/png"), f.read(), meta
252
+
253
+ work = _exif_transpose(im)
254
+ if work is not im:
255
+ meta["exif_rotated"] = True
256
+ needs_convert = needs_convert or ext not in PROVIDER_NATIVE \
257
+ or True # re-encoded after transpose; pick encoder below
258
+
259
+ if too_big_dim:
260
+ work.thumbnail((max_dimension, max_dimension),
261
+ getattr(Image, "LANCZOS", 1))
262
+ meta["resized_to"] = list(work.size)
263
+
264
+ has_alpha = "A" in work.getbands() or work.mode == "P"
265
+ if has_alpha and not needs_convert:
266
+ target_fmt, target_mime = "PNG", "image/png"
267
+ elif has_alpha:
268
+ target_fmt, target_mime = "PNG", "image/png"
269
+ else:
270
+ target_fmt, target_mime = "JPEG", "image/jpeg"
271
+ if work.mode != "RGB":
272
+ work = work.convert("RGB")
273
+
274
+ buf = io.BytesIO()
275
+ save_kwargs = {}
276
+ if target_fmt == "JPEG":
277
+ save_kwargs = {"quality": JPEG_QUALITY, "optimize": True}
278
+ elif target_fmt == "PNG":
279
+ save_kwargs = {"optimize": True}
280
+ work.save(buf, format=target_fmt, **save_kwargs)
281
+ data = buf.getvalue()
282
+
283
+ # One extra recompression pass if still over budget (rare).
284
+ attempts = 0
285
+ while len(data) > max_bytes and target_fmt == "JPEG" and attempts < 3:
286
+ q = max(50, JPEG_QUALITY - 15 * (attempts + 1))
287
+ buf = io.BytesIO()
288
+ work.save(buf, format="JPEG", quality=q, optimize=True)
289
+ data = buf.getvalue()
290
+ meta["recompressed_quality"] = q
291
+ attempts += 1
292
+ if len(data) > max_bytes:
293
+ raise ImageError(
294
+ "image cannot be normalized under the transport budget "
295
+ "without unacceptable quality loss")
296
+
297
+ meta["action"] = ("resized+converted" if too_big_dim else "converted") \
298
+ if needs_convert or too_big_dim else "recompressed"
299
+ meta["normalized"] = meta["action"] != "passthrough"
300
+ meta["out_format"] = target_fmt
301
+ return target_mime, data, meta
302
+ except ImageError:
303
+ raise
304
+ except Exception as e:
305
+ raise ImageError(f"corrupted or undecodable image ({type(e).__name__}): {e}")
306
+
307
+
308
+ def encode_for_model(path: str, prompt: str = "",
309
+ max_dimension: int = MAX_DIMENSION,
310
+ max_bytes: int = MAX_BYTES) -> Tuple[str, str, Dict]:
311
+ """The ONE call the attachment/aicore layer uses:
312
+ returns (mime, base64_payload, meta) for a normalized image."""
313
+ import time as _time
314
+ t0 = _time.perf_counter()
315
+ mime, data, meta = normalize(path, max_dimension=max_dimension,
316
+ max_bytes=max_bytes)
317
+ b64 = base64.b64encode(data).decode("ascii")
318
+ try:
319
+ from . import metrics
320
+ m = metrics.current()
321
+ if m is not None:
322
+ m.mark_stage("vision", t0)
323
+ except Exception:
324
+ pass
325
+ meta["payload_b64_chars"] = len(b64)
326
+ return mime, b64, meta
327
+
328
+
329
+ # ------------------------------------------------------------- strategies --
330
+
331
+ _KIND_PROMPTS = {
332
+ "photo": (
333
+ "Describe this photograph thoroughly: main subjects, setting, colors, "
334
+ "lighting, notable details, visible text or brands, and anything unusual."),
335
+ "screenshot": (
336
+ "This is a screen capture. Transcribe all visible UI elements, window "
337
+ "titles, menus, buttons, error messages, code (verbatim, in fenced "
338
+ "blocks), URLs, and values. Explain what application/screen this is."),
339
+ "scanned_document": (
340
+ "This is a scanned/document image. Extract ALL text verbatim preserving "
341
+ "structure (headings, paragraphs, lists, tables as markdown tables). "
342
+ "Include page metadata if visible (title, dates, signatures, stamps)."),
343
+ "chart": (
344
+ "Analyze this chart/graph: state the chart type, axes and their units, "
345
+ "every series/legend entry, key data points (read approximate values), "
346
+ "trends, anomalies, and the takeaway conclusion."),
347
+ "diagram": (
348
+ "Analyze this diagram: identify the diagram type, every node/component "
349
+ "(with labels), every connection/arrow and its direction, groupings, "
350
+ "and reconstruct its structure as an indented outline or mermaid-style "
351
+ "text description."),
352
+ "code_screenshot": (
353
+ "This screenshot contains source code. Transcribe the code VERBATIM in "
354
+ "a fenced block with the correct language tag, then explain what it "
355
+ "does, note any bugs/issues you can see, and mention visible file "
356
+ "names, line numbers or IDE context."),
357
+ "handwritten": (
358
+ "This appears to contain handwriting. Transcribe ALL handwritten "
359
+ "content as faithfully as possible, marking unclear words with [?]. "
360
+ "Preserve layout, numbering, equations (as LaTeX-ish plain text) and "
361
+ "any printed content separately."),
362
+ }
363
+
364
+ BASE_ANALYSIS_PROMPT = (
365
+ "Analyze the attached image comprehensively. Wherever supported by the "
366
+ "image content, extract: visible objects and scene; ALL text (OCR); "
367
+ "tables (as markdown); charts (axes, series, values, trends); diagrams "
368
+ "(nodes, edges, structure); UI elements; code shown (verbatim in fenced "
369
+ "blocks); document layout; mathematical expressions; visual relationships; "
370
+ "and notable metadata. Be specific and complete — do NOT merely say what "
371
+ "kind of thing the image is."
372
+ )
373
+
374
+
375
+ def analysis_prompt(kind: str, user_text: str = "") -> str:
376
+ """Strategy-specific instruction combined with the user's actual ask.
377
+ Used by both the direct-vision path and the agent's analyze_image tool."""
378
+ specific = _KIND_PROMPTS.get(kind, BASE_ANALYSIS_PROMPT)
379
+ ask = (user_text or "").strip()
380
+ ocr_note = ""
381
+ pre_ocr = ""
382
+ if kind in ("scanned_document", "handwritten", "screenshot",
383
+ "code_screenshot", "chart", "diagram"):
384
+ if ocr_available():
385
+ ocr_note = ("\n\nA local OCR engine is available; combine its raw "
386
+ "output (provided below) with your own visual reading "
387
+ "— reconcile differences explicitly.")
388
+ else:
389
+ ocr_note = ("\n\nPerform careful character-level OCR yourself as "
390
+ "part of the analysis.")
391
+ prefix = f"The user asked: \"{ask}\"\n\n" if ask and ask.lower() not in (
392
+ "analyze this image", "describe this image") else ""
393
+ return (prefix + BASE_ANALYSIS_PROMPT + "\n\nImage-type strategy ("
394
+ + kind + "): " + specific + ocr_note + pre_ocr)
395
+
396
+
397
+ def run_local_ocr(path: str) -> str:
398
+ """Best-effort local OCR via pytesseract when installed. Returns ''
399
+ (not an error) when unavailable — callers fold it into the prompt."""
400
+ if not ocr_available():
401
+ return ""
402
+ try:
403
+ import pytesseract
404
+ from PIL import Image
405
+ with Image.open(path) as im:
406
+ return pytesseract.image_to_string(im) or ""
407
+ except Exception:
408
+ return ""
409
+
410
+
411
+ # ------------------------------------------------------------ model bridge --
412
+
413
+ def find_vision_config():
414
+ """Bridge to the router: a config dict for a vision-capable configured
415
+ model, or None when none exists (callers report that honestly)."""
416
+ try:
417
+ from . import model_router
418
+ cfg, caps = model_router.find_vision_capable()
419
+ return cfg
420
+ except Exception:
421
+ return None
422
+
423
+
424
+ def analyze(path: str, user_text: str = "", history=None,
425
+ config: Optional[dict] = None):
426
+ """Full VISION PATH: normalize → classify (+local OCR when available)
427
+ → vision-capable model → structured answer string. Uses the given
428
+ config when it can see, otherwise reroutes to a capable backup;
429
+ raises ImageError for bad files and returns an honest message when no
430
+ vision-capable model is configured at all."""
431
+ mime, b64, meta = encode_for_model(path, user_text)
432
+ kind = classify_image(path, user_text)
433
+ prompt = analysis_prompt(kind, user_text)
434
+ local_text = run_local_ocr(path)
435
+ if local_text.strip():
436
+ prompt += "\n\nLOCAL OCR OUTPUT (machine-read, may contain errors):\n" \
437
+ + local_text[:4000]
438
+
439
+ try:
440
+ from .event_stream import stream, IMAGE_ANALYSIS_STARTED, IMAGE_ANALYSIS_FINISHED
441
+ stream.emit(IMAGE_ANALYSIS_STARTED, source="vision", path=os.path.basename(path),
442
+ kind=kind, action=meta.get("action"))
443
+ except Exception:
444
+ pass
445
+
446
+ cfg = config
447
+ if cfg is not None:
448
+ try:
449
+ from .attachments import provider_capabilities
450
+ if not provider_capabilities(cfg).get("vision"):
451
+ cfg = None
452
+ except Exception:
453
+ cfg = None
454
+ if cfg is None:
455
+ cfg = find_vision_config()
456
+ if cfg is None:
457
+ return ("No vision-capable AI model is currently configured, so I "
458
+ "cannot visually analyze this image. Configure one (e.g. a "
459
+ "gpt-4o / claude / gemini-class vision model via /model) and "
460
+ "attach it again.", meta)
461
+
462
+ answer = _query_vision_model(cfg, prompt, mime, b64, system_extra=kind)
463
+ try:
464
+ from .event_stream import stream, IMAGE_ANALYSIS_FINISHED
465
+ stream.emit(IMAGE_ANALYSIS_FINISHED, source="vision", path=os.path.basename(path),
466
+ kind=kind, chars=len(answer or ""))
467
+ except Exception:
468
+ pass
469
+ return answer, meta
470
+
471
+
472
+ def _query_vision_model(config: dict, prompt: str, mime: str, b64: str,
473
+ system_extra: str = "") -> str:
474
+ """One vision completion against an explicit provider config, using
475
+ aicore's own request machinery through the public attachments path
476
+ (keeps per-provider payload shaping in exactly one place)."""
477
+ try:
478
+ from . import aicore
479
+ try:
480
+ from . import identity
481
+ system_prompt = ("You are CAT Vision, CAT's precise image-analysis "
482
+ "specialist. Be exhaustive and concrete.\n\n"
483
+ + identity.IDENTITY_BLOCK)
484
+ except Exception:
485
+ system_prompt = ("You are CAT Vision, CAT's precise image-analysis "
486
+ "specialist. Be exhaustive and concrete.")
487
+ att = _temp_attachment(mime, b64)
488
+ return aicore.query_ai(prompt, system_prompt=system_prompt,
489
+ history=None, config=config,
490
+ attachments=[att], size_class="large")
491
+ except Exception as e:
492
+ return f"Image analysis failed: {e}"
493
+
494
+
495
+ def _temp_attachment(mime, b64):
496
+ """Build a minimal Attachment-like object carrying an already-encoded
497
+ image payload so aicore._user_content can shape it per-provider
498
+ without re-reading or re-normalizing the file."""
499
+ from .attachments import Attachment
500
+ att = Attachment.__new__(Attachment)
501
+ att.id = f"att-vision-{abs(hash(b64[:64])) % 1000000}"
502
+ att.name = "image"
503
+ att.path = ""
504
+ att.extension = "." + mime.split("/")[-1].split("+")[0]
505
+ att.mime_type = mime
506
+ att.size = 0
507
+ att.kind = "image"
508
+ att.content = "[native image payload]"
509
+ att.metadata = {"inline_b64": b64}
510
+ att.extraction_status = "ready"
511
+ att.error = None
512
+ att.created_at = 0.0
513
+ att.source = "pipeline"
514
+ return att
@@ -0,0 +1,113 @@
1
+ """
2
+ CAT VS Code integration (spec 28).
3
+
4
+ Exposes CAT Vision commands to VS Code panel. In pure Python (no VS Code),
5
+ these are graceful stubs that still report honest status and allow the CLI
6
+ and TUI to route Vision context through the same code path:
7
+
8
+ CAT: Start Vision Session
9
+ CAT: Analyze Current Screen
10
+ CAT: Analyze Selected Region
11
+ CAT: Send Screenshot to CAT
12
+ CAT: Open CAT Vision
13
+ CAT: Stop Vision Session
14
+
15
+ When VS Code is the current project, Vision context may include:
16
+ VS Code active file + selected code + workspace + terminal
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ from typing import Dict, Optional, Tuple
25
+
26
+
27
+ _CLI_CANDIDATES = ("code", "code-insiders", "codium", "code.cmd")
28
+
29
+
30
+ def _find_cli() -> Optional[str]:
31
+ for name in _CLI_CANDIDATES:
32
+ p = shutil.which(name)
33
+ if p:
34
+ return p
35
+ return None
36
+
37
+
38
+ def status() -> Dict[str, object]:
39
+ cli = _find_cli()
40
+ return {
41
+ "available": cli is not None,
42
+ "cli_path": cli or "",
43
+ "vision_commands": [
44
+ "CAT: Start Vision Session",
45
+ "CAT: Analyze Current Screen",
46
+ "CAT: Analyze Selected Region",
47
+ "CAT: Send Screenshot to CAT",
48
+ "CAT: Open CAT Vision",
49
+ "CAT: Stop Vision Session",
50
+ ],
51
+ }
52
+
53
+
54
+ def _run_code(args, cwd: Optional[str] = None) -> Tuple[bool, str]:
55
+ cli = _find_cli()
56
+ if not cli:
57
+ return False, "VS Code CLI not found (install VS Code and ensure 'code' is on PATH)."
58
+ try:
59
+ proc = subprocess.run(
60
+ [cli] + args,
61
+ cwd=cwd or os.getcwd(),
62
+ capture_output=True,
63
+ text=True,
64
+ timeout=8,
65
+ )
66
+ ok = proc.returncode == 0
67
+ msg = (proc.stdout or proc.stderr or "").strip() or ("OK" if ok else "failed")
68
+ return ok, msg[:400]
69
+ except Exception as e:
70
+ return False, str(e)[:400]
71
+
72
+
73
+ def open_workspace(path: str = "") -> Tuple[bool, str]:
74
+ path = (path or "").strip() or os.getcwd()
75
+ return _run_code([path])
76
+
77
+
78
+ def open_workspace_from_current() -> Tuple[bool, str]:
79
+ try:
80
+ from . import workspace as ws
81
+ root = ws.root_dir() or ws.active_project() or os.getcwd()
82
+ except Exception:
83
+ root = os.getcwd()
84
+ return _run_code([root])
85
+
86
+
87
+ def open_file(path: str, line: Optional[str] = None) -> Tuple[bool, str]:
88
+ path = (path or "").strip()
89
+ if not path:
90
+ return False, "No file path given."
91
+ arg = f"{path}:{line}" if line and str(line).isdigit() else path
92
+ return _run_code(["-g", arg])
93
+
94
+
95
+ def open_diff(a: str, b: str) -> Tuple[bool, str]:
96
+ return _run_code(["--diff", a, b])
97
+
98
+
99
+ # Vision-specific helpers — Conceptually these route through the CAT Vision service.
100
+ # In the current runtime they acknowledge the request honestly and point to the panel.
101
+ def vision_command(name: str, file_path: str = "", selected_code: str = "") -> Tuple[bool, str]:
102
+ known = {
103
+ "CAT: Start Vision Session": "Vision session started (use the CAT Vision panel).",
104
+ "CAT: Analyze Current Screen": "Analyzing current screen via CAT Vision.",
105
+ "CAT: Analyze Selected Region": "Analyzing selected region via CAT Vision.",
106
+ "CAT: Send Screenshot to CAT": "Screenshot sent to CAT Vision (attach via /import or PWA Upload Screenshot).",
107
+ "CAT: Open CAT Vision": "Opening CAT Vision panel.",
108
+ "CAT: Stop Vision Session": "Vision session stopped.",
109
+ }
110
+ msg = known.get(name, f"Vision command '{name}' — context: file={file_path[:80]} selected={len(selected_code)} chars")
111
+ # In a real VS Code extension this would post to the CAT Vision service with workspace context.
112
+ # Here we keep the shared state promise: VisionSession lives in CAT Core, not per-UI.
113
+ return True, msg
@@ -0,0 +1,8 @@
1
+ """CAT web package (Fatty CAT server + web runtime).
2
+
3
+ This package ships the FastAPI server and the bundled PWA static assets
4
+ (``static/``). The directory previously had no ``__init__.py`` and only
5
+ worked by accident via namespace-package semantics; an explicit package
6
+ marker makes ``calc_terminal.web`` importable identically from a source
7
+ checkout and from an installed wheel.
8
+ """