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,703 @@
1
+ """
2
+ CAT — Workspace-aware Chat History Store.
3
+
4
+ Persists chat sessions to ~/.cat/chats/ with strict workspace isolation,
5
+ per-conversation AI mode metadata, and backward-compatible migration from
6
+ legacy chat records.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import time
16
+ import uuid
17
+ from datetime import datetime, timedelta
18
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
19
+
20
+ log = logging.getLogger("cat.chat_store")
21
+
22
+ _CHATS_DIR = os.path.join(os.path.expanduser("~"), ".cat", "chats")
23
+ _INDEX_FILE = os.path.join(_CHATS_DIR, "_index.json")
24
+ _SCHEMA_VERSION = 2
25
+ _LEGACY_WORKSPACE = "__legacy_unassigned__"
26
+
27
+
28
+ def _ensure_dir():
29
+ os.makedirs(_CHATS_DIR, exist_ok=True)
30
+
31
+
32
+ def _index_path() -> str:
33
+ return _INDEX_FILE
34
+
35
+
36
+ def _chat_path(chat_id: str) -> str:
37
+ safe = chat_id.replace("/", "_").replace("\\", "_").replace("..", "_")
38
+ return os.path.join(_CHATS_DIR, f"{safe}.json")
39
+
40
+
41
+ def normalize_workspace_path(path: Optional[str]) -> str:
42
+ """Canonical absolute path for workspace comparison (platform-aware)."""
43
+ if not path or not str(path).strip():
44
+ return ""
45
+ try:
46
+ raw = str(path).strip()
47
+ if "\x00" in raw:
48
+ return ""
49
+ expanded = os.path.expanduser(raw)
50
+ resolved = os.path.abspath(expanded)
51
+ normalized = os.path.normpath(resolved)
52
+ if os.name == "nt":
53
+ normalized = normalized.rstrip("\\")
54
+ else:
55
+ normalized = normalized.rstrip("/")
56
+ return normalized
57
+ except (OSError, ValueError, TypeError):
58
+ return ""
59
+
60
+
61
+ def paths_equal(a: Optional[str], b: Optional[str]) -> bool:
62
+ """Strict workspace ownership comparison — never loose substring match."""
63
+ na = normalize_workspace_path(a)
64
+ nb = normalize_workspace_path(b)
65
+ if not na or not nb:
66
+ return False
67
+ return os.path.normcase(na) == os.path.normcase(nb)
68
+
69
+
70
+ def workspace_key(path: Optional[str]) -> str:
71
+ """Stable grouping key for workspace-organized UI."""
72
+ norm = normalize_workspace_path(path)
73
+ if not norm:
74
+ return _LEGACY_WORKSPACE
75
+ return os.path.normcase(norm)
76
+
77
+
78
+ def workspace_display_name(path: Optional[str]) -> str:
79
+ norm = normalize_workspace_path(path)
80
+ if not norm:
81
+ return "Legacy / Unassigned"
82
+ base = os.path.basename(norm.rstrip(os.sep))
83
+ return base or norm
84
+
85
+
86
+ def _atomic_write_json(path: str, data: Any):
87
+ _ensure_dir()
88
+ tmp = f"{path}.{uuid.uuid4().hex}.tmp"
89
+ with open(tmp, "w", encoding="utf-8") as f:
90
+ json.dump(data, f, indent=2, ensure_ascii=False)
91
+ try:
92
+ os.replace(tmp, path)
93
+ except OSError:
94
+ try:
95
+ if os.path.exists(path):
96
+ os.remove(path)
97
+ os.replace(tmp, path)
98
+ except OSError:
99
+ shutil.copyfile(tmp, path)
100
+ try:
101
+ os.remove(tmp)
102
+ except OSError:
103
+ pass
104
+
105
+
106
+ def _load_index() -> List[Dict]:
107
+ _ensure_dir()
108
+ try:
109
+ with open(_index_path(), "r", encoding="utf-8") as f:
110
+ data = json.load(f)
111
+ if isinstance(data, list):
112
+ return data
113
+ except (FileNotFoundError, json.JSONDecodeError):
114
+ pass
115
+ return []
116
+
117
+
118
+ def _save_index(entries: List[Dict]):
119
+ _atomic_write_json(_index_path(), entries)
120
+
121
+
122
+ def _migrate_turn(turn: Dict) -> Dict:
123
+ turn = dict(turn)
124
+ if "timestamp" not in turn:
125
+ turn["timestamp"] = time.time()
126
+ return turn
127
+
128
+
129
+ def _migrate_chat(chat: Dict) -> Dict:
130
+ """Upgrade legacy chat records in-place (non-destructive)."""
131
+ chat = dict(chat)
132
+ version = chat.get("schema_version", 1)
133
+
134
+ ws = chat.get("workspace_root") or chat.get("project_path") or ""
135
+ ws_norm = normalize_workspace_path(ws)
136
+ chat["workspace_root"] = ws_norm
137
+ chat["project_path"] = ws_norm # legacy alias
138
+
139
+ if not ws_norm:
140
+ chat["legacy_unassigned"] = True
141
+ else:
142
+ chat.setdefault("legacy_unassigned", False)
143
+
144
+ chat.setdefault("cwd", ws_norm or os.getcwd())
145
+ chat.setdefault("created_at", chat.get("updated_at", time.time()))
146
+ chat.setdefault("updated_at", chat.get("created_at", time.time()))
147
+ chat.setdefault("last_opened_at", 0.0)
148
+ chat.setdefault("turn_count", len(chat.get("turns", [])))
149
+ chat.setdefault("summary", chat.get("summary", ""))
150
+ chat.setdefault("pinned", bool(chat.get("pinned", False)))
151
+ chat.setdefault("archived", bool(chat.get("archived", False)))
152
+ chat.setdefault("provider", chat.get("provider", "") or "")
153
+ chat.setdefault("model", chat.get("model", "") or "")
154
+
155
+ turns = [_migrate_turn(t) for t in chat.get("turns", [])]
156
+ chat["turns"] = turns
157
+
158
+ first_mode = ""
159
+ last_mode = ""
160
+ for t in turns:
161
+ m = t.get("mode") or (t.get("mode_snapshot") or {}).get("mode", "")
162
+ if m:
163
+ if not first_mode:
164
+ first_mode = m
165
+ last_mode = m
166
+
167
+ chat.setdefault("created_mode", chat.get("created_mode") or first_mode or "notebook")
168
+ chat.setdefault("current_mode", chat.get("current_mode") or last_mode or chat["created_mode"])
169
+
170
+ if version < _SCHEMA_VERSION:
171
+ chat["schema_version"] = _SCHEMA_VERSION
172
+ _save_chat(chat)
173
+ log.info("migrated chat %s to schema v%s", chat.get("id"), _SCHEMA_VERSION)
174
+
175
+ return chat
176
+
177
+
178
+ def _load_chat_file(chat_id: str) -> Optional[Dict]:
179
+ path = _chat_path(chat_id)
180
+ try:
181
+ with open(path, "r", encoding="utf-8") as f:
182
+ chat = json.load(f)
183
+ if isinstance(chat, dict):
184
+ return _migrate_chat(chat)
185
+ except (FileNotFoundError, json.JSONDecodeError, TypeError):
186
+ pass
187
+ return None
188
+
189
+
190
+ def _save_chat(chat: Dict):
191
+ chat = _migrate_chat(chat)
192
+ _atomic_write_json(_chat_path(chat.get("id", "unknown")), chat)
193
+
194
+
195
+ def _index_entry(chat: Dict) -> Dict:
196
+ chat = _migrate_chat(chat)
197
+ return {k: v for k, v in chat.items() if k != "turns"}
198
+
199
+
200
+ def list_chats() -> List[Dict]:
201
+ return [_migrate_chat(dict(e)) for e in _load_index()]
202
+
203
+
204
+ def get_chat(chat_id: str) -> Optional[Dict]:
205
+ chat = _load_chat_file(chat_id)
206
+ if chat:
207
+ entry = _index_entry(chat)
208
+ index = _load_index()
209
+ found = any(e.get("id") == chat_id for e in index)
210
+ if not found:
211
+ index.insert(0, entry)
212
+ _save_index(index)
213
+ return chat
214
+
215
+
216
+ def create_chat(
217
+ name: Optional[str] = None,
218
+ project_path: Optional[str] = None,
219
+ *,
220
+ workspace_root: Optional[str] = None,
221
+ cwd: Optional[str] = None,
222
+ created_mode: Optional[str] = None,
223
+ current_mode: Optional[str] = None,
224
+ provider: Optional[str] = None,
225
+ model: Optional[str] = None,
226
+ ) -> Dict:
227
+ """Create a new empty chat bound to a workspace."""
228
+ chat_id = str(uuid.uuid4())[:12]
229
+ now = time.time()
230
+ ws = normalize_workspace_path(workspace_root or project_path or cwd or os.getcwd())
231
+ mode = created_mode or current_mode or "notebook"
232
+ chat = {
233
+ "schema_version": _SCHEMA_VERSION,
234
+ "id": chat_id,
235
+ "name": name or f"Chat {time.strftime('%m/%d %H:%M')}",
236
+ "created_at": now,
237
+ "updated_at": now,
238
+ "last_opened_at": now,
239
+ "turn_count": 0,
240
+ "workspace_root": ws,
241
+ "project_path": ws,
242
+ "cwd": normalize_workspace_path(cwd) or ws or normalize_workspace_path(os.getcwd()),
243
+ "created_mode": mode,
244
+ "current_mode": current_mode or mode,
245
+ "provider": provider or "",
246
+ "model": model or "",
247
+ "pinned": False,
248
+ "archived": False,
249
+ "legacy_unassigned": not bool(ws),
250
+ "turns": [],
251
+ "summary": "",
252
+ }
253
+ _save_chat(chat)
254
+ index = _load_index()
255
+ index.insert(0, _index_entry(chat))
256
+ _save_index(index)
257
+ log.info("created chat %s workspace=%s mode=%s", chat_id, ws or _LEGACY_WORKSPACE, mode)
258
+ return chat
259
+
260
+
261
+ def save_chat(chat: Dict):
262
+ chat = _migrate_chat(chat)
263
+ _save_chat(chat)
264
+ index = _load_index()
265
+ entry = _index_entry(chat)
266
+ found = False
267
+ for i, e in enumerate(index):
268
+ if e.get("id") == chat.get("id"):
269
+ index[i] = entry
270
+ found = True
271
+ break
272
+ if not found:
273
+ index.insert(0, entry)
274
+ _save_index(index)
275
+
276
+
277
+ def rename_chat(chat_id: str, new_name: str) -> bool:
278
+ chat = get_chat(chat_id)
279
+ if not chat:
280
+ return False
281
+ chat["name"] = new_name.strip() or chat.get("name", "Untitled")
282
+ chat["updated_at"] = time.time()
283
+ save_chat(chat)
284
+ return True
285
+
286
+
287
+ def delete_chat(chat_id: str) -> bool:
288
+ path = _chat_path(chat_id)
289
+ try:
290
+ os.remove(path)
291
+ except FileNotFoundError:
292
+ pass
293
+ index = _load_index()
294
+ index = [e for e in index if e.get("id") != chat_id]
295
+ _save_index(index)
296
+ log.info("deleted chat %s", chat_id)
297
+ return True
298
+
299
+
300
+ def update_chat_metadata(chat_id: str, **fields) -> bool:
301
+ chat = get_chat(chat_id)
302
+ if not chat:
303
+ return False
304
+ for key, val in fields.items():
305
+ if key == "workspace_root" or key == "project_path":
306
+ ws = normalize_workspace_path(val)
307
+ chat["workspace_root"] = ws
308
+ chat["project_path"] = ws
309
+ chat["legacy_unassigned"] = not bool(ws)
310
+ elif key in chat or key in (
311
+ "pinned", "archived", "current_mode", "created_mode",
312
+ "provider", "model", "cwd", "last_opened_at", "summary",
313
+ ):
314
+ chat[key] = val
315
+ chat["updated_at"] = time.time()
316
+ save_chat(chat)
317
+ return True
318
+
319
+
320
+ def touch_last_opened(chat_id: str) -> bool:
321
+ return update_chat_metadata(chat_id, last_opened_at=time.time())
322
+
323
+
324
+ def pin_chat(chat_id: str, pinned: bool = True) -> bool:
325
+ return update_chat_metadata(chat_id, pinned=bool(pinned))
326
+
327
+
328
+ def archive_chat(chat_id: str, archived: bool = True) -> bool:
329
+ return update_chat_metadata(chat_id, archived=bool(archived))
330
+
331
+
332
+ def associate_chat_workspace(chat_id: str, workspace_root: str) -> bool:
333
+ ws = normalize_workspace_path(workspace_root)
334
+ if not ws:
335
+ return False
336
+ return update_chat_metadata(
337
+ chat_id,
338
+ workspace_root=ws,
339
+ project_path=ws,
340
+ legacy_unassigned=False,
341
+ )
342
+
343
+
344
+ def duplicate_chat(chat_id: str, new_name: Optional[str] = None) -> Optional[Dict]:
345
+ src = get_chat(chat_id)
346
+ if not src:
347
+ return None
348
+ copy = create_chat(
349
+ name=new_name or f"{src.get('name', 'Chat')} (copy)",
350
+ workspace_root=src.get("workspace_root"),
351
+ cwd=src.get("cwd"),
352
+ created_mode=src.get("created_mode"),
353
+ current_mode=src.get("current_mode"),
354
+ provider=src.get("provider"),
355
+ model=src.get("model"),
356
+ )
357
+ copy["turns"] = [dict(t) for t in src.get("turns", [])]
358
+ copy["turn_count"] = len(copy["turns"])
359
+ copy["summary"] = src.get("summary", "")
360
+ save_chat(copy)
361
+ return copy
362
+
363
+
364
+ def add_turn(
365
+ chat_id: str,
366
+ role: str,
367
+ content: str,
368
+ *,
369
+ mode: Optional[str] = None,
370
+ mode_snapshot: Optional[Dict] = None,
371
+ provider: Optional[str] = None,
372
+ model: Optional[str] = None,
373
+ ) -> Optional[Dict]:
374
+ """Append a turn with full mode/provider metadata."""
375
+ chat = get_chat(chat_id)
376
+ if not chat:
377
+ return None
378
+ turn: Dict[str, Any] = {
379
+ "role": role,
380
+ "content": content,
381
+ "timestamp": time.time(),
382
+ }
383
+ if mode:
384
+ turn["mode"] = mode
385
+ if mode_snapshot:
386
+ turn["mode_snapshot"] = dict(mode_snapshot)
387
+ turn.setdefault("mode", mode_snapshot.get("mode", mode))
388
+ elif mode:
389
+ try:
390
+ from . import ai_modes
391
+ turn["mode_snapshot"] = ai_modes.snapshot(mode)
392
+ except Exception:
393
+ pass
394
+ if provider:
395
+ turn["provider"] = provider
396
+ if model:
397
+ turn["model"] = model
398
+ chat.setdefault("turns", []).append(turn)
399
+ chat["turn_count"] = len(chat["turns"])
400
+ chat["updated_at"] = time.time()
401
+ if mode:
402
+ chat["current_mode"] = mode
403
+ if provider:
404
+ turn_prov = provider
405
+ chat["provider"] = provider
406
+ if model:
407
+ chat["model"] = model
408
+ save_chat(chat)
409
+ return turn
410
+
411
+
412
+ def update_turn(chat_id: str, turn_index: int, content: str) -> bool:
413
+ chat = get_chat(chat_id)
414
+ if not chat:
415
+ return False
416
+ turns = chat.get("turns", [])
417
+ if 0 <= turn_index < len(turns):
418
+ turns[turn_index]["content"] = content
419
+ turns[turn_index]["edited"] = True
420
+ chat["updated_at"] = time.time()
421
+ save_chat(chat)
422
+ return True
423
+ return False
424
+
425
+
426
+ def _summary_from_entry(entry: Dict) -> Dict:
427
+ entry = _migrate_chat(dict(entry))
428
+ ws = entry.get("workspace_root") or entry.get("project_path") or ""
429
+ return {
430
+ "id": entry.get("id"),
431
+ "name": entry.get("name", "Untitled"),
432
+ "turn_count": entry.get("turn_count", 0),
433
+ "updated_at": entry.get("updated_at", 0),
434
+ "created_at": entry.get("created_at", 0),
435
+ "last_opened_at": entry.get("last_opened_at", 0),
436
+ "workspace_root": ws,
437
+ "project_path": ws,
438
+ "cwd": entry.get("cwd", ws),
439
+ "created_mode": entry.get("created_mode", "notebook"),
440
+ "current_mode": entry.get("current_mode", entry.get("created_mode", "notebook")),
441
+ "provider": entry.get("provider", ""),
442
+ "model": entry.get("model", ""),
443
+ "pinned": bool(entry.get("pinned", False)),
444
+ "archived": bool(entry.get("archived", False)),
445
+ "legacy_unassigned": bool(entry.get("legacy_unassigned", not bool(ws))),
446
+ "summary": entry.get("summary", ""),
447
+ }
448
+
449
+
450
+ def get_chat_summaries(
451
+ workspace_root: Optional[str] = None,
452
+ *,
453
+ include_archived: bool = False,
454
+ include_legacy: bool = True,
455
+ ) -> List[Dict]:
456
+ """Metadata-only list; optionally scoped to one workspace."""
457
+ summaries = []
458
+ target = normalize_workspace_path(workspace_root) if workspace_root else ""
459
+ for entry in _load_index():
460
+ s = _summary_from_entry(entry)
461
+ if not include_archived and s.get("archived"):
462
+ continue
463
+ if s.get("legacy_unassigned") and not include_legacy:
464
+ continue
465
+ if target:
466
+ if s.get("legacy_unassigned"):
467
+ continue
468
+ if not paths_equal(s.get("workspace_root"), target):
469
+ continue
470
+ summaries.append(s)
471
+ summaries.sort(key=lambda x: x.get("updated_at", 0), reverse=True)
472
+ return summaries
473
+
474
+
475
+ def filter_chats(
476
+ summaries: Sequence[Dict],
477
+ *,
478
+ filter_name: str = "all",
479
+ mode_filter: Optional[str] = None,
480
+ workspace_root: Optional[str] = None,
481
+ ) -> List[Dict]:
482
+ """Apply UI filter presets to summary list."""
483
+ items = list(summaries)
484
+ ws = normalize_workspace_path(workspace_root) if workspace_root else ""
485
+ now = datetime.now()
486
+ today_start = datetime(now.year, now.month, now.day).timestamp()
487
+ yesterday_start = (datetime(now.year, now.month, now.day) - timedelta(days=1)).timestamp()
488
+ week_start = (now - timedelta(days=7)).timestamp()
489
+
490
+ if filter_name == "current_workspace":
491
+ if ws:
492
+ items = [c for c in items if paths_equal(c.get("workspace_root"), ws) and not c.get("archived")]
493
+ else:
494
+ items = []
495
+ elif filter_name == "pinned":
496
+ items = [c for c in items if c.get("pinned") and not c.get("archived")]
497
+ elif filter_name == "archived":
498
+ items = [c for c in items if c.get("archived")]
499
+ elif filter_name == "today":
500
+ items = [c for c in items if c.get("updated_at", 0) >= today_start and not c.get("archived")]
501
+ elif filter_name == "recent":
502
+ items = [c for c in items if c.get("updated_at", 0) >= week_start and not c.get("archived")]
503
+ else:
504
+ items = [c for c in items if not c.get("archived")]
505
+
506
+ if mode_filter:
507
+ mf = mode_filter.lower()
508
+ items = [
509
+ c for c in items
510
+ if (c.get("current_mode") or c.get("created_mode", "")).lower() == mf
511
+ ]
512
+ return items
513
+
514
+
515
+ def group_chats_by_workspace(summaries: Sequence[Dict]) -> List[Dict]:
516
+ """Group chat summaries by workspace for hierarchical UI."""
517
+ buckets: Dict[str, Dict] = {}
518
+ seen_ids: set = set()
519
+ for s in summaries:
520
+ cid = s.get("id")
521
+ if cid in seen_ids:
522
+ continue
523
+ seen_ids.add(cid)
524
+ key = workspace_key(s.get("workspace_root"))
525
+ if key not in buckets:
526
+ path = s.get("workspace_root") or ""
527
+ buckets[key] = {
528
+ "workspace_key": key,
529
+ "workspace_root": path,
530
+ "workspace_name": workspace_display_name(path),
531
+ "chat_count": 0,
532
+ "last_activity": 0.0,
533
+ "pinned": False,
534
+ "chats": [],
535
+ }
536
+ bucket = buckets[key]
537
+ bucket["chats"].append(s)
538
+ bucket["chat_count"] += 1
539
+ bucket["last_activity"] = max(bucket["last_activity"], s.get("updated_at", 0))
540
+ if s.get("pinned"):
541
+ bucket["pinned"] = True
542
+
543
+ groups = sorted(buckets.values(), key=lambda g: g["last_activity"], reverse=True)
544
+ for g in groups:
545
+ g["chats"].sort(key=lambda c: c.get("updated_at", 0), reverse=True)
546
+ return groups
547
+
548
+
549
+ def _date_bucket(ts: float) -> str:
550
+ if not ts:
551
+ return "Previous"
552
+ now = datetime.now()
553
+ dt = datetime.fromtimestamp(ts)
554
+ today = datetime(now.year, now.month, now.day)
555
+ chat_day = datetime(dt.year, dt.month, dt.day)
556
+ delta = (today - chat_day).days
557
+ if delta == 0:
558
+ return "Today"
559
+ if delta == 1:
560
+ return "Yesterday"
561
+ if delta < 7:
562
+ return "This Week"
563
+ return "Previous"
564
+
565
+
566
+ def group_chats_by_date(chats: Sequence[Dict]) -> List[Tuple[str, List[Dict]]]:
567
+ order = ["Today", "Yesterday", "This Week", "Previous"]
568
+ buckets: Dict[str, List[Dict]] = {k: [] for k in order}
569
+ seen_ids: set = set()
570
+ for c in chats:
571
+ cid = c.get("id")
572
+ if cid in seen_ids:
573
+ continue
574
+ seen_ids.add(cid)
575
+ buckets[_date_bucket(c.get("updated_at", 0))].append(c)
576
+ return [(label, buckets[label]) for label in order if buckets[label]]
577
+
578
+
579
+ def get_pinned_chats(limit: int = 20, workspace_root: Optional[str] = None) -> List[Dict]:
580
+ if workspace_root:
581
+ pinned = [s for s in get_chat_summaries(workspace_root, include_archived=False) if s.get("pinned")]
582
+ else:
583
+ pinned = [s for s in get_chat_summaries(include_archived=False) if s.get("pinned")]
584
+ pinned.sort(key=lambda x: x.get("updated_at", 0), reverse=True)
585
+ return pinned[:limit]
586
+
587
+
588
+ def get_recent_chats(limit: int = 10, workspace_root: Optional[str] = None) -> List[Dict]:
589
+ if workspace_root:
590
+ items = get_chat_summaries(workspace_root, include_archived=False)
591
+ else:
592
+ items = get_chat_summaries(include_archived=False)
593
+ items = [c for c in items if c.get("last_opened_at") or c.get("updated_at")]
594
+ items.sort(
595
+ key=lambda x: x.get("last_opened_at") or x.get("updated_at", 0),
596
+ reverse=True,
597
+ )
598
+ return items[:limit]
599
+
600
+
601
+ def search_chats(
602
+ query: str,
603
+ *,
604
+ workspace_root: Optional[str] = None,
605
+ scope: str = "current",
606
+ mode_filter: Optional[str] = None,
607
+ provider_filter: Optional[str] = None,
608
+ date_after: Optional[float] = None,
609
+ ) -> List[Dict]:
610
+ """Search chat metadata and message content."""
611
+ q = (query or "").strip().lower()
612
+ if scope == "current":
613
+ if workspace_root:
614
+ candidates = get_chat_summaries(workspace_root, include_archived=True, include_legacy=False)
615
+ else:
616
+ candidates = []
617
+ else:
618
+ candidates = get_chat_summaries(include_archived=True)
619
+
620
+ if mode_filter:
621
+ mf = mode_filter.lower()
622
+ candidates = [
623
+ c for c in candidates
624
+ if (c.get("current_mode") or c.get("created_mode", "")).lower() == mf
625
+ ]
626
+ if provider_filter:
627
+ pf = provider_filter.lower()
628
+ candidates = [c for c in candidates if pf in (c.get("provider") or "").lower()]
629
+ if date_after:
630
+ candidates = [c for c in candidates if c.get("updated_at", 0) >= date_after]
631
+
632
+ if not q:
633
+ return candidates
634
+
635
+ results: List[Dict] = []
636
+ seen: set = set()
637
+ for entry in candidates:
638
+ cid = entry.get("id", "")
639
+ if cid in seen:
640
+ continue
641
+ name = entry.get("name", "").lower()
642
+ ws_name = workspace_display_name(entry.get("workspace_root")).lower()
643
+ ws_path = (entry.get("workspace_root") or "").lower()
644
+ mode = (entry.get("current_mode") or entry.get("created_mode") or "").lower()
645
+ model = (entry.get("model") or "").lower()
646
+ provider = (entry.get("provider") or "").lower()
647
+ if any(q in field for field in (name, ws_name, ws_path, mode, model, provider, cid.lower())):
648
+ results.append(entry)
649
+ seen.add(cid)
650
+ continue
651
+ chat = get_chat(cid)
652
+ if not chat:
653
+ continue
654
+ for turn in chat.get("turns", []):
655
+ if q in turn.get("content", "").lower():
656
+ results.append(entry)
657
+ seen.add(cid)
658
+ break
659
+ return results
660
+
661
+
662
+ def export_chat_markdown(chat_id: str) -> Optional[str]:
663
+ chat = get_chat(chat_id)
664
+ if not chat:
665
+ return None
666
+ lines = [
667
+ f"# {chat.get('name', 'CAT Conversation')}",
668
+ "",
669
+ f"- **Workspace:** `{chat.get('workspace_root') or 'Unassigned'}`",
670
+ f"- **Mode:** {chat.get('current_mode') or chat.get('created_mode', 'notebook')}",
671
+ f"- **Provider:** {chat.get('provider') or 'unknown'}",
672
+ f"- **Model:** {chat.get('model') or 'unknown'}",
673
+ f"- **Created:** {datetime.fromtimestamp(chat.get('created_at', 0)).isoformat()}",
674
+ f"- **Updated:** {datetime.fromtimestamp(chat.get('updated_at', 0)).isoformat()}",
675
+ "",
676
+ "---",
677
+ "",
678
+ ]
679
+ for turn in chat.get("turns", []):
680
+ role = turn.get("role", "user")
681
+ label = "You" if role == "user" else "CAT Assistant" if role == "assistant" else "System"
682
+ ts = turn.get("timestamp", 0)
683
+ when = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
684
+ mode = turn.get("mode") or (turn.get("mode_snapshot") or {}).get("mode", "")
685
+ header = f"## {label}"
686
+ if when:
687
+ header += f" ({when})"
688
+ if mode:
689
+ header += f" — {mode}"
690
+ lines.extend([header, "", turn.get("content", ""), "", "---", ""])
691
+ return "\n".join(lines)
692
+
693
+
694
+ def validate_workspace_metadata(path: Optional[str]) -> bool:
695
+ """Reject malformed paths that could corrupt grouping."""
696
+ if not path:
697
+ return True
698
+ norm = normalize_workspace_path(path)
699
+ if not norm:
700
+ return False
701
+ if "\x00" in norm:
702
+ return False
703
+ return True