cecli-dev 0.93.1__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 (366) hide show
  1. cecli/__init__.py +20 -0
  2. cecli/__main__.py +4 -0
  3. cecli/_version.py +34 -0
  4. cecli/args.py +1092 -0
  5. cecli/args_formatter.py +228 -0
  6. cecli/change_tracker.py +133 -0
  7. cecli/coders/__init__.py +38 -0
  8. cecli/coders/agent_coder.py +1872 -0
  9. cecli/coders/architect_coder.py +63 -0
  10. cecli/coders/ask_coder.py +8 -0
  11. cecli/coders/base_coder.py +3993 -0
  12. cecli/coders/chat_chunks.py +116 -0
  13. cecli/coders/context_coder.py +52 -0
  14. cecli/coders/copypaste_coder.py +269 -0
  15. cecli/coders/editblock_coder.py +656 -0
  16. cecli/coders/editblock_fenced_coder.py +9 -0
  17. cecli/coders/editblock_func_coder.py +140 -0
  18. cecli/coders/editor_diff_fenced_coder.py +8 -0
  19. cecli/coders/editor_editblock_coder.py +8 -0
  20. cecli/coders/editor_whole_coder.py +8 -0
  21. cecli/coders/help_coder.py +15 -0
  22. cecli/coders/patch_coder.py +705 -0
  23. cecli/coders/search_replace.py +757 -0
  24. cecli/coders/shell.py +37 -0
  25. cecli/coders/single_wholefile_func_coder.py +101 -0
  26. cecli/coders/udiff_coder.py +428 -0
  27. cecli/coders/udiff_simple.py +12 -0
  28. cecli/coders/wholefile_coder.py +143 -0
  29. cecli/coders/wholefile_func_coder.py +133 -0
  30. cecli/commands/__init__.py +192 -0
  31. cecli/commands/add.py +226 -0
  32. cecli/commands/agent.py +51 -0
  33. cecli/commands/architect.py +46 -0
  34. cecli/commands/ask.py +44 -0
  35. cecli/commands/chat_mode.py +0 -0
  36. cecli/commands/clear.py +37 -0
  37. cecli/commands/code.py +46 -0
  38. cecli/commands/command_prefix.py +44 -0
  39. cecli/commands/commit.py +52 -0
  40. cecli/commands/context.py +47 -0
  41. cecli/commands/context_blocks.py +124 -0
  42. cecli/commands/context_management.py +51 -0
  43. cecli/commands/copy.py +62 -0
  44. cecli/commands/copy_context.py +81 -0
  45. cecli/commands/core.py +287 -0
  46. cecli/commands/diff.py +68 -0
  47. cecli/commands/drop.py +217 -0
  48. cecli/commands/editor.py +78 -0
  49. cecli/commands/exit.py +55 -0
  50. cecli/commands/git.py +57 -0
  51. cecli/commands/help.py +140 -0
  52. cecli/commands/history_search.py +40 -0
  53. cecli/commands/lint.py +109 -0
  54. cecli/commands/list_sessions.py +56 -0
  55. cecli/commands/load.py +85 -0
  56. cecli/commands/load_session.py +48 -0
  57. cecli/commands/load_skill.py +68 -0
  58. cecli/commands/ls.py +75 -0
  59. cecli/commands/map.py +37 -0
  60. cecli/commands/map_refresh.py +35 -0
  61. cecli/commands/model.py +118 -0
  62. cecli/commands/models.py +41 -0
  63. cecli/commands/multiline_mode.py +38 -0
  64. cecli/commands/paste.py +91 -0
  65. cecli/commands/quit.py +32 -0
  66. cecli/commands/read_only.py +267 -0
  67. cecli/commands/read_only_stub.py +270 -0
  68. cecli/commands/reasoning_effort.py +70 -0
  69. cecli/commands/remove_skill.py +68 -0
  70. cecli/commands/report.py +40 -0
  71. cecli/commands/reset.py +88 -0
  72. cecli/commands/run.py +99 -0
  73. cecli/commands/save.py +49 -0
  74. cecli/commands/save_session.py +43 -0
  75. cecli/commands/settings.py +69 -0
  76. cecli/commands/test.py +58 -0
  77. cecli/commands/think_tokens.py +74 -0
  78. cecli/commands/tokens.py +207 -0
  79. cecli/commands/undo.py +145 -0
  80. cecli/commands/utils/__init__.py +0 -0
  81. cecli/commands/utils/base_command.py +131 -0
  82. cecli/commands/utils/helpers.py +142 -0
  83. cecli/commands/utils/registry.py +53 -0
  84. cecli/commands/utils/save_load_manager.py +98 -0
  85. cecli/commands/voice.py +78 -0
  86. cecli/commands/weak_model.py +123 -0
  87. cecli/commands/web.py +87 -0
  88. cecli/deprecated_args.py +185 -0
  89. cecli/diffs.py +129 -0
  90. cecli/dump.py +29 -0
  91. cecli/editor.py +147 -0
  92. cecli/exceptions.py +115 -0
  93. cecli/format_settings.py +26 -0
  94. cecli/help.py +119 -0
  95. cecli/help_pats.py +19 -0
  96. cecli/helpers/__init__.py +9 -0
  97. cecli/helpers/copypaste.py +123 -0
  98. cecli/helpers/coroutines.py +8 -0
  99. cecli/helpers/file_searcher.py +142 -0
  100. cecli/helpers/model_providers.py +552 -0
  101. cecli/helpers/plugin_manager.py +81 -0
  102. cecli/helpers/profiler.py +162 -0
  103. cecli/helpers/requests.py +77 -0
  104. cecli/helpers/similarity.py +98 -0
  105. cecli/helpers/skills.py +577 -0
  106. cecli/history.py +186 -0
  107. cecli/io.py +1782 -0
  108. cecli/linter.py +304 -0
  109. cecli/llm.py +101 -0
  110. cecli/main.py +1280 -0
  111. cecli/mcp/__init__.py +154 -0
  112. cecli/mcp/oauth.py +250 -0
  113. cecli/mcp/server.py +278 -0
  114. cecli/mdstream.py +243 -0
  115. cecli/models.py +1255 -0
  116. cecli/onboarding.py +301 -0
  117. cecli/prompts/__init__.py +0 -0
  118. cecli/prompts/agent.yml +71 -0
  119. cecli/prompts/architect.yml +35 -0
  120. cecli/prompts/ask.yml +31 -0
  121. cecli/prompts/base.yml +99 -0
  122. cecli/prompts/context.yml +60 -0
  123. cecli/prompts/copypaste.yml +5 -0
  124. cecli/prompts/editblock.yml +143 -0
  125. cecli/prompts/editblock_fenced.yml +106 -0
  126. cecli/prompts/editblock_func.yml +25 -0
  127. cecli/prompts/editor_diff_fenced.yml +115 -0
  128. cecli/prompts/editor_editblock.yml +121 -0
  129. cecli/prompts/editor_whole.yml +46 -0
  130. cecli/prompts/help.yml +37 -0
  131. cecli/prompts/patch.yml +110 -0
  132. cecli/prompts/single_wholefile_func.yml +24 -0
  133. cecli/prompts/udiff.yml +106 -0
  134. cecli/prompts/udiff_simple.yml +13 -0
  135. cecli/prompts/utils/__init__.py +0 -0
  136. cecli/prompts/utils/prompt_registry.py +167 -0
  137. cecli/prompts/utils/system.py +56 -0
  138. cecli/prompts/wholefile.yml +50 -0
  139. cecli/prompts/wholefile_func.yml +24 -0
  140. cecli/queries/tree-sitter-language-pack/README.md +7 -0
  141. cecli/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  142. cecli/queries/tree-sitter-language-pack/c-tags.scm +12 -0
  143. cecli/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  144. cecli/queries/tree-sitter-language-pack/clojure-tags.scm +12 -0
  145. cecli/queries/tree-sitter-language-pack/commonlisp-tags.scm +127 -0
  146. cecli/queries/tree-sitter-language-pack/cpp-tags.scm +18 -0
  147. cecli/queries/tree-sitter-language-pack/csharp-tags.scm +32 -0
  148. cecli/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  149. cecli/queries/tree-sitter-language-pack/dart-tags.scm +97 -0
  150. cecli/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  151. cecli/queries/tree-sitter-language-pack/elixir-tags.scm +59 -0
  152. cecli/queries/tree-sitter-language-pack/elm-tags.scm +22 -0
  153. cecli/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  154. cecli/queries/tree-sitter-language-pack/go-tags.scm +49 -0
  155. cecli/queries/tree-sitter-language-pack/java-tags.scm +26 -0
  156. cecli/queries/tree-sitter-language-pack/javascript-tags.scm +96 -0
  157. cecli/queries/tree-sitter-language-pack/lua-tags.scm +39 -0
  158. cecli/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  159. cecli/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  160. cecli/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +101 -0
  161. cecli/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  162. cecli/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  163. cecli/queries/tree-sitter-language-pack/python-tags.scm +24 -0
  164. cecli/queries/tree-sitter-language-pack/r-tags.scm +27 -0
  165. cecli/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  166. cecli/queries/tree-sitter-language-pack/ruby-tags.scm +69 -0
  167. cecli/queries/tree-sitter-language-pack/rust-tags.scm +63 -0
  168. cecli/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  169. cecli/queries/tree-sitter-language-pack/swift-tags.scm +54 -0
  170. cecli/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  171. cecli/queries/tree-sitter-languages/README.md +24 -0
  172. cecli/queries/tree-sitter-languages/c-tags.scm +12 -0
  173. cecli/queries/tree-sitter-languages/c_sharp-tags.scm +52 -0
  174. cecli/queries/tree-sitter-languages/cpp-tags.scm +18 -0
  175. cecli/queries/tree-sitter-languages/dart-tags.scm +92 -0
  176. cecli/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  177. cecli/queries/tree-sitter-languages/elixir-tags.scm +59 -0
  178. cecli/queries/tree-sitter-languages/elm-tags.scm +22 -0
  179. cecli/queries/tree-sitter-languages/fortran-tags.scm +18 -0
  180. cecli/queries/tree-sitter-languages/go-tags.scm +36 -0
  181. cecli/queries/tree-sitter-languages/haskell-tags.scm +5 -0
  182. cecli/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  183. cecli/queries/tree-sitter-languages/java-tags.scm +26 -0
  184. cecli/queries/tree-sitter-languages/javascript-tags.scm +96 -0
  185. cecli/queries/tree-sitter-languages/julia-tags.scm +60 -0
  186. cecli/queries/tree-sitter-languages/kotlin-tags.scm +30 -0
  187. cecli/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  188. cecli/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  189. cecli/queries/tree-sitter-languages/ocaml_interface-tags.scm +104 -0
  190. cecli/queries/tree-sitter-languages/php-tags.scm +32 -0
  191. cecli/queries/tree-sitter-languages/python-tags.scm +22 -0
  192. cecli/queries/tree-sitter-languages/ql-tags.scm +26 -0
  193. cecli/queries/tree-sitter-languages/ruby-tags.scm +69 -0
  194. cecli/queries/tree-sitter-languages/rust-tags.scm +63 -0
  195. cecli/queries/tree-sitter-languages/scala-tags.scm +64 -0
  196. cecli/queries/tree-sitter-languages/typescript-tags.scm +44 -0
  197. cecli/queries/tree-sitter-languages/zig-tags.scm +20 -0
  198. cecli/reasoning_tags.py +82 -0
  199. cecli/repo.py +626 -0
  200. cecli/repomap.py +1368 -0
  201. cecli/report.py +260 -0
  202. cecli/resources/__init__.py +3 -0
  203. cecli/resources/model-metadata.json +25751 -0
  204. cecli/resources/model-settings.yml +2394 -0
  205. cecli/resources/providers.json +67 -0
  206. cecli/run_cmd.py +143 -0
  207. cecli/scrape.py +295 -0
  208. cecli/sendchat.py +250 -0
  209. cecli/sessions.py +281 -0
  210. cecli/special.py +203 -0
  211. cecli/tools/__init__.py +72 -0
  212. cecli/tools/command.py +103 -0
  213. cecli/tools/command_interactive.py +113 -0
  214. cecli/tools/context_manager.py +175 -0
  215. cecli/tools/delete_block.py +154 -0
  216. cecli/tools/delete_line.py +120 -0
  217. cecli/tools/delete_lines.py +144 -0
  218. cecli/tools/extract_lines.py +281 -0
  219. cecli/tools/finished.py +35 -0
  220. cecli/tools/git_branch.py +132 -0
  221. cecli/tools/git_diff.py +49 -0
  222. cecli/tools/git_log.py +43 -0
  223. cecli/tools/git_remote.py +39 -0
  224. cecli/tools/git_show.py +37 -0
  225. cecli/tools/git_status.py +32 -0
  226. cecli/tools/grep.py +242 -0
  227. cecli/tools/indent_lines.py +195 -0
  228. cecli/tools/insert_block.py +263 -0
  229. cecli/tools/list_changes.py +71 -0
  230. cecli/tools/load_skill.py +51 -0
  231. cecli/tools/ls.py +77 -0
  232. cecli/tools/remove_skill.py +51 -0
  233. cecli/tools/replace_all.py +113 -0
  234. cecli/tools/replace_line.py +135 -0
  235. cecli/tools/replace_lines.py +180 -0
  236. cecli/tools/replace_text.py +186 -0
  237. cecli/tools/show_numbered_context.py +137 -0
  238. cecli/tools/thinking.py +52 -0
  239. cecli/tools/undo_change.py +82 -0
  240. cecli/tools/update_todo_list.py +148 -0
  241. cecli/tools/utils/base_tool.py +64 -0
  242. cecli/tools/utils/helpers.py +359 -0
  243. cecli/tools/utils/output.py +119 -0
  244. cecli/tools/utils/registry.py +145 -0
  245. cecli/tools/view_files_matching.py +138 -0
  246. cecli/tools/view_files_with_symbol.py +117 -0
  247. cecli/tui/__init__.py +83 -0
  248. cecli/tui/app.py +971 -0
  249. cecli/tui/io.py +566 -0
  250. cecli/tui/styles.tcss +117 -0
  251. cecli/tui/widgets/__init__.py +19 -0
  252. cecli/tui/widgets/completion_bar.py +331 -0
  253. cecli/tui/widgets/file_list.py +76 -0
  254. cecli/tui/widgets/footer.py +165 -0
  255. cecli/tui/widgets/input_area.py +320 -0
  256. cecli/tui/widgets/key_hints.py +16 -0
  257. cecli/tui/widgets/output.py +354 -0
  258. cecli/tui/widgets/status_bar.py +279 -0
  259. cecli/tui/worker.py +160 -0
  260. cecli/urls.py +16 -0
  261. cecli/utils.py +499 -0
  262. cecli/versioncheck.py +90 -0
  263. cecli/voice.py +90 -0
  264. cecli/waiting.py +38 -0
  265. cecli/watch.py +316 -0
  266. cecli/watch_prompts.py +12 -0
  267. cecli/website/Gemfile +8 -0
  268. cecli/website/_includes/blame.md +162 -0
  269. cecli/website/_includes/get-started.md +22 -0
  270. cecli/website/_includes/help-tip.md +5 -0
  271. cecli/website/_includes/help.md +24 -0
  272. cecli/website/_includes/install.md +5 -0
  273. cecli/website/_includes/keys.md +4 -0
  274. cecli/website/_includes/model-warnings.md +67 -0
  275. cecli/website/_includes/multi-line.md +22 -0
  276. cecli/website/_includes/python-m-aider.md +5 -0
  277. cecli/website/_includes/recording.css +228 -0
  278. cecli/website/_includes/recording.md +34 -0
  279. cecli/website/_includes/replit-pipx.md +9 -0
  280. cecli/website/_includes/works-best.md +1 -0
  281. cecli/website/_sass/custom/custom.scss +103 -0
  282. cecli/website/docs/config/adv-model-settings.md +2498 -0
  283. cecli/website/docs/config/agent-mode.md +320 -0
  284. cecli/website/docs/config/aider_conf.md +548 -0
  285. cecli/website/docs/config/api-keys.md +90 -0
  286. cecli/website/docs/config/custom-commands.md +187 -0
  287. cecli/website/docs/config/dotenv.md +493 -0
  288. cecli/website/docs/config/editor.md +127 -0
  289. cecli/website/docs/config/mcp.md +210 -0
  290. cecli/website/docs/config/model-aliases.md +173 -0
  291. cecli/website/docs/config/options.md +890 -0
  292. cecli/website/docs/config/reasoning.md +210 -0
  293. cecli/website/docs/config/skills.md +172 -0
  294. cecli/website/docs/config/tui.md +126 -0
  295. cecli/website/docs/config.md +44 -0
  296. cecli/website/docs/faq.md +379 -0
  297. cecli/website/docs/git.md +76 -0
  298. cecli/website/docs/index.md +47 -0
  299. cecli/website/docs/install/codespaces.md +39 -0
  300. cecli/website/docs/install/docker.md +48 -0
  301. cecli/website/docs/install/optional.md +100 -0
  302. cecli/website/docs/install/replit.md +8 -0
  303. cecli/website/docs/install.md +115 -0
  304. cecli/website/docs/languages.md +264 -0
  305. cecli/website/docs/legal/contributor-agreement.md +111 -0
  306. cecli/website/docs/legal/privacy.md +104 -0
  307. cecli/website/docs/llms/anthropic.md +77 -0
  308. cecli/website/docs/llms/azure.md +48 -0
  309. cecli/website/docs/llms/bedrock.md +132 -0
  310. cecli/website/docs/llms/cohere.md +34 -0
  311. cecli/website/docs/llms/deepseek.md +32 -0
  312. cecli/website/docs/llms/gemini.md +49 -0
  313. cecli/website/docs/llms/github.md +111 -0
  314. cecli/website/docs/llms/groq.md +36 -0
  315. cecli/website/docs/llms/lm-studio.md +39 -0
  316. cecli/website/docs/llms/ollama.md +75 -0
  317. cecli/website/docs/llms/openai-compat.md +39 -0
  318. cecli/website/docs/llms/openai.md +58 -0
  319. cecli/website/docs/llms/openrouter.md +78 -0
  320. cecli/website/docs/llms/other.md +117 -0
  321. cecli/website/docs/llms/vertex.md +50 -0
  322. cecli/website/docs/llms/warnings.md +10 -0
  323. cecli/website/docs/llms/xai.md +53 -0
  324. cecli/website/docs/llms.md +54 -0
  325. cecli/website/docs/more/analytics.md +127 -0
  326. cecli/website/docs/more/edit-formats.md +116 -0
  327. cecli/website/docs/more/infinite-output.md +192 -0
  328. cecli/website/docs/more-info.md +8 -0
  329. cecli/website/docs/recordings/auto-accept-architect.md +31 -0
  330. cecli/website/docs/recordings/dont-drop-original-read-files.md +35 -0
  331. cecli/website/docs/recordings/index.md +21 -0
  332. cecli/website/docs/recordings/model-accepts-settings.md +69 -0
  333. cecli/website/docs/recordings/tree-sitter-language-pack.md +80 -0
  334. cecli/website/docs/repomap.md +112 -0
  335. cecli/website/docs/scripting.md +100 -0
  336. cecli/website/docs/sessions.md +213 -0
  337. cecli/website/docs/troubleshooting/aider-not-found.md +24 -0
  338. cecli/website/docs/troubleshooting/edit-errors.md +76 -0
  339. cecli/website/docs/troubleshooting/imports.md +62 -0
  340. cecli/website/docs/troubleshooting/models-and-keys.md +54 -0
  341. cecli/website/docs/troubleshooting/support.md +79 -0
  342. cecli/website/docs/troubleshooting/token-limits.md +96 -0
  343. cecli/website/docs/troubleshooting/warnings.md +12 -0
  344. cecli/website/docs/troubleshooting.md +11 -0
  345. cecli/website/docs/usage/browser.md +57 -0
  346. cecli/website/docs/usage/caching.md +49 -0
  347. cecli/website/docs/usage/commands.md +133 -0
  348. cecli/website/docs/usage/conventions.md +119 -0
  349. cecli/website/docs/usage/copypaste.md +136 -0
  350. cecli/website/docs/usage/images-urls.md +48 -0
  351. cecli/website/docs/usage/lint-test.md +118 -0
  352. cecli/website/docs/usage/modes.md +211 -0
  353. cecli/website/docs/usage/not-code.md +179 -0
  354. cecli/website/docs/usage/notifications.md +87 -0
  355. cecli/website/docs/usage/tips.md +79 -0
  356. cecli/website/docs/usage/tutorials.md +30 -0
  357. cecli/website/docs/usage/voice.md +121 -0
  358. cecli/website/docs/usage/watch.md +294 -0
  359. cecli/website/docs/usage.md +102 -0
  360. cecli/website/share/index.md +101 -0
  361. cecli_dev-0.93.1.dist-info/METADATA +549 -0
  362. cecli_dev-0.93.1.dist-info/RECORD +366 -0
  363. cecli_dev-0.93.1.dist-info/WHEEL +5 -0
  364. cecli_dev-0.93.1.dist-info/entry_points.txt +4 -0
  365. cecli_dev-0.93.1.dist-info/licenses/LICENSE.txt +202 -0
  366. cecli_dev-0.93.1.dist-info/top_level.txt +1 -0
cecli/sendchat.py ADDED
@@ -0,0 +1,250 @@
1
+ from cecli.dump import dump # noqa: F401
2
+ from cecli.utils import format_messages
3
+
4
+
5
+ def sanity_check_messages(messages):
6
+ """Check if messages alternate between user and assistant roles.
7
+ System messages can be interspersed anywhere.
8
+ Also verifies the last non-system message is from the user.
9
+ Validates tool message sequences.
10
+ Returns True if valid, False otherwise."""
11
+ last_role = None
12
+ last_non_system_role = None
13
+ i = 0
14
+ n = len(messages)
15
+
16
+ while i < n:
17
+ msg = messages[i]
18
+ role = msg.get("role")
19
+
20
+ # Handle tool sequences atomically
21
+ if role == "assistant" and "tool_calls" in msg and msg["tool_calls"]:
22
+ # Validate tool sequence
23
+ expected_ids = {call["id"] for call in msg["tool_calls"]}
24
+ i += 1
25
+
26
+ # Check for tool responses
27
+ while i < n and expected_ids:
28
+ next_msg = messages[i]
29
+ if next_msg.get("role") == "tool" and next_msg.get("tool_call_id") in expected_ids:
30
+ expected_ids.discard(next_msg.get("tool_call_id"))
31
+ i += 1
32
+ else:
33
+ break
34
+
35
+ # If we still have expected IDs, the tool sequence is incomplete
36
+ if expected_ids:
37
+ turns = format_messages(messages)
38
+ raise ValueError(
39
+ "Incomplete tool sequence - missing responses for tool calls:\n\n" + turns
40
+ )
41
+
42
+ # Continue to next message after tool sequence
43
+ continue
44
+
45
+ elif role == "tool":
46
+ # Orphaned tool message without preceding assistant tool_calls
47
+ turns = format_messages(messages)
48
+ raise ValueError(
49
+ "Orphaned tool message without preceding assistant tool_calls:\n\n" + turns
50
+ )
51
+
52
+ # Handle normal role alternation
53
+ if role == "system":
54
+ i += 1
55
+ continue
56
+
57
+ if last_role and role == last_role:
58
+ turns = format_messages(messages)
59
+ raise ValueError("Messages don't properly alternate user/assistant:\n\n" + turns)
60
+
61
+ last_role = role
62
+ last_non_system_role = role
63
+ i += 1
64
+
65
+ # Ensure last non-system message is from user
66
+ return last_non_system_role == "user"
67
+
68
+
69
+ def clean_orphaned_tool_messages(messages):
70
+ """Remove orphaned tool messages and incomplete tool sequences.
71
+
72
+ This function removes:
73
+ - Tool messages without a preceding assistant message containing tool_calls
74
+ - Assistant messages with tool_calls that don't have complete tool responses
75
+
76
+ Args:
77
+ messages: List of message dictionaries
78
+
79
+ Returns:
80
+ Cleaned list of messages with orphaned tool sequences removed
81
+ """
82
+ if not messages:
83
+ return messages
84
+
85
+ cleaned = []
86
+ i = 0
87
+ n = len(messages)
88
+
89
+ while i < n:
90
+ msg = messages[i]
91
+ role = msg.get("role")
92
+
93
+ # If it's an assistant message with tool_calls, check if we have complete responses
94
+ if role == "assistant" and "tool_calls" in msg and msg["tool_calls"]:
95
+ # Start of potential tool sequence
96
+ tool_sequence = [msg]
97
+ expected_ids = {call["id"] for call in msg["tool_calls"]}
98
+ j = i + 1
99
+
100
+ # Collect tool responses
101
+ while j < n and expected_ids:
102
+ next_msg = messages[j]
103
+ if next_msg.get("role") == "tool" and next_msg.get("tool_call_id") in expected_ids:
104
+ tool_sequence.append(next_msg)
105
+ expected_ids.discard(next_msg.get("tool_call_id"))
106
+ j += 1
107
+ else:
108
+ break
109
+
110
+ # If we have all tool responses, keep the sequence
111
+ if not expected_ids:
112
+ cleaned.extend(tool_sequence)
113
+ i = j
114
+ else:
115
+ # Incomplete sequence - skip the entire tool sequence
116
+ i = j
117
+ # Don't add anything to cleaned
118
+ continue
119
+
120
+ elif role == "tool":
121
+ # Orphaned tool message without preceding assistant tool_calls - skip it
122
+ i += 1
123
+ continue
124
+ else:
125
+ # Regular message - add it
126
+ cleaned.append(msg)
127
+ i += 1
128
+
129
+ return cleaned
130
+
131
+
132
+ def ensure_alternating_roles(messages):
133
+ """Ensure messages alternate between 'assistant' and 'user' roles.
134
+
135
+ Inserts empty messages of the opposite role when consecutive messages
136
+ of the same 'user' or 'assistant' role are found. Messages with other
137
+ roles (e.g. 'system', 'tool') are ignored by the alternation logic.
138
+
139
+ Also handles tool call sequences properly - when an assistant message
140
+ contains tool_calls, processes the complete tool sequence atomically.
141
+
142
+ Args:
143
+ messages: List of message dictionaries with 'role' and 'content' keys.
144
+
145
+ Returns:
146
+ List of messages with alternating roles.
147
+ """
148
+ if not messages:
149
+ return messages
150
+
151
+ # First clean orphaned tool messages
152
+ messages = clean_orphaned_tool_messages(messages)
153
+
154
+ result = []
155
+ i = 0
156
+ n = len(messages)
157
+ prev_role = None
158
+
159
+ while i < n:
160
+ msg = messages[i]
161
+ role = msg.get("role")
162
+
163
+ if (
164
+ role == "assistant"
165
+ and not msg.get("content", None)
166
+ and not msg.get("tool_calls", None)
167
+ and not msg.get("function_call", None)
168
+ ):
169
+ msg["content"] = "(empty response)"
170
+
171
+ # Handle tool call sequences atomically
172
+ if role == "assistant" and "tool_calls" in msg and msg["tool_calls"]:
173
+ # Start of tool sequence - collect all related messages
174
+ tool_sequence = [msg]
175
+ expected_ids = {call["id"] for call in msg["tool_calls"]}
176
+ i += 1
177
+
178
+ # Collect tool responses
179
+ while i < n and expected_ids:
180
+ next_msg = messages[i]
181
+ if next_msg.get("role") == "tool" and next_msg.get("tool_call_id") in expected_ids:
182
+ tool_sequence.append(next_msg)
183
+ expected_ids.discard(next_msg.get("tool_call_id"))
184
+ i += 1
185
+ else:
186
+ break
187
+
188
+ # Add missing tool responses as empty
189
+ for tool_id in expected_ids:
190
+ tool_sequence.append(
191
+ {"role": "tool", "tool_call_id": tool_id, "content": "(empty response)"}
192
+ )
193
+
194
+ # Add the complete tool sequence to result
195
+ for tool_msg in tool_sequence:
196
+ result.append(tool_msg)
197
+
198
+ # Update prev_role to assistant after processing tool sequence
199
+ prev_role = "assistant"
200
+ continue
201
+
202
+ # Handle normal message alternation
203
+ if role in ("user", "assistant"):
204
+ if role == prev_role:
205
+ # Insert empty message of opposite role
206
+ opposite_role = "user" if role == "assistant" else "assistant"
207
+ result.append(
208
+ {
209
+ "role": opposite_role,
210
+ "content": (
211
+ "(empty response)"
212
+ if opposite_role == "assistant"
213
+ else "(empty request)"
214
+ ),
215
+ }
216
+ )
217
+ prev_role = opposite_role
218
+
219
+ result.append(msg)
220
+ prev_role = role
221
+ else:
222
+ # For non-user/assistant roles, just add them directly
223
+ result.append(msg)
224
+
225
+ i += 1
226
+
227
+ # Consolidate consecutive empty messages in a single pass
228
+ consolidated = []
229
+ for msg in result:
230
+ if not consolidated:
231
+ consolidated.append(msg)
232
+ continue
233
+
234
+ last_msg = consolidated[-1]
235
+ current_role = msg.get("role")
236
+ last_role = last_msg.get("role")
237
+
238
+ # Skip consecutive empty messages with the same role
239
+ if (
240
+ current_role in ("user", "assistant")
241
+ and last_role in ("user", "assistant")
242
+ and current_role == last_role
243
+ and msg.get("content") in ["", "(empty response)", "(empty request)"]
244
+ and last_msg.get("content") in ["", "(empty response)", "(empty request)"]
245
+ ):
246
+ continue
247
+
248
+ consolidated.append(msg)
249
+
250
+ return consolidated
cecli/sessions.py ADDED
@@ -0,0 +1,281 @@
1
+ """Session management utilities for cecli."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ from cecli import models
9
+
10
+
11
+ class SessionManager:
12
+ """Manages chat session saving, listing, and loading."""
13
+
14
+ def __init__(self, coder, io):
15
+ self.coder = coder
16
+ self.io = io
17
+
18
+ def _get_session_directory(self) -> Path:
19
+ """Get the session directory, creating it if necessary."""
20
+ session_dir = Path(self.coder.abs_root_path(".cecli/sessions"))
21
+ os.makedirs(session_dir, exist_ok=True)
22
+ return session_dir
23
+
24
+ def save_session(self, session_name: str, output=True) -> bool:
25
+ """Save the current chat session to a named file."""
26
+ if not session_name:
27
+ if output:
28
+ self.io.tool_error("Please provide a session name.")
29
+ return False
30
+
31
+ session_name = session_name.replace(".json", "")
32
+ session_dir = self._get_session_directory()
33
+ session_file = session_dir / f"{session_name}.json"
34
+
35
+ if session_file.exists():
36
+ if output:
37
+ self.io.tool_warning(f"Session '{session_name}' already exists. Overwriting.")
38
+
39
+ try:
40
+ session_data = self._build_session_data(session_name)
41
+ with open(session_file, "w", encoding="utf-8") as f:
42
+ json.dump(session_data, f, indent=2)
43
+
44
+ if output:
45
+ self.io.tool_output(f"Session saved: {session_file}")
46
+
47
+ return True
48
+
49
+ except Exception as e:
50
+ self.io.tool_error(f"Error saving session: {e}")
51
+ return False
52
+
53
+ def list_sessions(self) -> List[Dict]:
54
+ """List all saved sessions with metadata."""
55
+ session_dir = self._get_session_directory()
56
+ session_files = list(session_dir.glob("*.json"))
57
+
58
+ if not session_files:
59
+ self.io.tool_output("No saved sessions found.")
60
+ return []
61
+
62
+ sessions = []
63
+ for session_file in sorted(session_files, key=lambda x: x.stat().st_mtime, reverse=True):
64
+ try:
65
+ with open(session_file, "r", encoding="utf-8") as f:
66
+ session_data = json.load(f)
67
+
68
+ session_info = {
69
+ "name": session_file.stem,
70
+ "file": session_file,
71
+ "model": session_data.get("model", "unknown"),
72
+ "edit_format": session_data.get("edit_format", "unknown"),
73
+ "num_messages": len(
74
+ session_data.get("chat_history", {}).get("done_messages", [])
75
+ ) + len(session_data.get("chat_history", {}).get("cur_messages", [])),
76
+ "num_files": (
77
+ len(session_data.get("files", {}).get("editable", []))
78
+ + len(session_data.get("files", {}).get("read_only", []))
79
+ + len(session_data.get("files", {}).get("read_only_stubs", []))
80
+ ),
81
+ }
82
+ sessions.append(session_info)
83
+
84
+ except Exception as e:
85
+ self.io.tool_output(f" {session_file.stem} [error reading: {e}]")
86
+
87
+ return sessions
88
+
89
+ def load_session(self, session_identifier: str) -> bool:
90
+ """Load a saved session by name or file path."""
91
+ if not session_identifier:
92
+ self.io.tool_error("Please provide a session name or file path.")
93
+ return False
94
+
95
+ # Try to find the session file
96
+ session_file = self._find_session_file(session_identifier)
97
+ if not session_file:
98
+ return False
99
+
100
+ try:
101
+ with open(session_file, "r", encoding="utf-8") as f:
102
+ session_data = json.load(f)
103
+ except Exception as e:
104
+ self.io.tool_error(f"Error loading session: {e}")
105
+ return False
106
+
107
+ # Verify session format
108
+ if not isinstance(session_data, dict) or "version" not in session_data:
109
+ self.io.tool_error("Invalid session format.")
110
+ return False
111
+
112
+ # Apply session data
113
+ return self._apply_session_data(session_data, session_file)
114
+
115
+ def _build_session_data(self, session_name) -> Dict:
116
+ """Build session data dictionary from current coder state."""
117
+ # Get relative paths for all files
118
+ editable_files = [
119
+ self.coder.get_rel_fname(abs_fname) for abs_fname in self.coder.abs_fnames
120
+ ]
121
+ read_only_files = [
122
+ self.coder.get_rel_fname(abs_fname) for abs_fname in self.coder.abs_read_only_fnames
123
+ ]
124
+ read_only_stubs_files = [
125
+ self.coder.get_rel_fname(abs_fname)
126
+ for abs_fname in self.coder.abs_read_only_stubs_fnames
127
+ ]
128
+
129
+ # Capture todo list content so it can be restored with the session
130
+ todo_content = None
131
+ try:
132
+ todo_path = self.coder.abs_root_path(".cecli.todo.txt")
133
+ if os.path.isfile(todo_path):
134
+ todo_content = self.io.read_text(todo_path)
135
+ if todo_content is None:
136
+ todo_content = ""
137
+ except Exception as e:
138
+ self.io.tool_warning(f"Could not read todo list file: {e}")
139
+
140
+ return {
141
+ "version": 1,
142
+ "session_name": session_name,
143
+ "model": self.coder.main_model.name,
144
+ "weak_model": self.coder.main_model.weak_model.name,
145
+ "editor_model": self.coder.main_model.editor_model.name,
146
+ "editor_edit_format": self.coder.main_model.editor_edit_format,
147
+ "edit_format": self.coder.edit_format,
148
+ "chat_history": {
149
+ "done_messages": self.coder.done_messages,
150
+ "cur_messages": self.coder.cur_messages,
151
+ },
152
+ "files": {
153
+ "editable": editable_files,
154
+ "read_only": read_only_files,
155
+ "read_only_stubs": read_only_stubs_files,
156
+ },
157
+ "settings": {
158
+ "auto_commits": self.coder.auto_commits,
159
+ "auto_lint": self.coder.auto_lint,
160
+ "auto_test": self.coder.auto_test,
161
+ },
162
+ "todo_list": todo_content,
163
+ }
164
+
165
+ def _find_session_file(self, session_identifier: str) -> Optional[Path]:
166
+ """Find session file by name or path."""
167
+ # Check if it's a direct file path
168
+ session_file = Path(session_identifier)
169
+ if session_file.exists():
170
+ return session_file
171
+
172
+ # Check if it's a session name in the sessions directory
173
+ session_dir = self._get_session_directory()
174
+
175
+ # Try with .json extension
176
+ if not session_identifier.endswith(".json"):
177
+ session_file = session_dir / f"{session_identifier}.json"
178
+ if session_file.exists():
179
+ return session_file
180
+
181
+ session_file = session_dir / f"{session_identifier}"
182
+ if session_file.exists():
183
+ return session_file
184
+
185
+ self.io.tool_error(f"Session not found: {session_identifier}")
186
+ self.io.tool_output("Use /list-sessions to see available sessions.")
187
+ return None
188
+
189
+ def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool:
190
+ """Apply session data to current coder state."""
191
+ try:
192
+ # Clear current state
193
+ self.coder.abs_fnames = set()
194
+ self.coder.abs_read_only_fnames = set()
195
+ self.coder.abs_read_only_stubs_fnames = set()
196
+ self.coder.done_messages = []
197
+ self.coder.cur_messages = []
198
+
199
+ # Load chat history
200
+ chat_history = session_data.get("chat_history", {})
201
+ self.coder.done_messages = chat_history.get("done_messages", [])
202
+ self.coder.cur_messages = chat_history.get("cur_messages", [])
203
+
204
+ # Load files
205
+ files = session_data.get("files", {})
206
+ for rel_fname in files.get("editable", []):
207
+ abs_fname = self.coder.abs_root_path(rel_fname)
208
+ if os.path.exists(abs_fname):
209
+ self.coder.abs_fnames.add(abs_fname)
210
+ else:
211
+ self.io.tool_warning(f"File not found, skipping: {rel_fname}")
212
+
213
+ for rel_fname in files.get("read_only", []):
214
+ abs_fname = self.coder.abs_root_path(rel_fname)
215
+ if os.path.exists(abs_fname):
216
+ self.coder.abs_read_only_fnames.add(abs_fname)
217
+ else:
218
+ self.io.tool_warning(f"File not found, skipping: {rel_fname}")
219
+
220
+ for rel_fname in files.get("read_only_stubs", []):
221
+ abs_fname = self.coder.abs_root_path(rel_fname)
222
+ if os.path.exists(abs_fname):
223
+ self.coder.abs_read_only_stubs_fnames.add(abs_fname)
224
+ else:
225
+ self.io.tool_warning(f"File not found, skipping: {rel_fname}")
226
+
227
+ if session_data.get("model"):
228
+ self.coder.main_model = models.Model(
229
+ session_data.get("model", self.coder.args.model),
230
+ weak_model=session_data.get("weak_model", self.coder.args.weak_model),
231
+ editor_model=session_data.get("editor_model", self.coder.args.editor_model),
232
+ editor_edit_format=session_data.get(
233
+ "editor_edit_format", self.coder.args.editor_edit_format
234
+ ),
235
+ verbose=self.coder.args.verbose,
236
+ )
237
+
238
+ # Load settings
239
+ settings = session_data.get("settings", {})
240
+ if "auto_commits" in settings:
241
+ self.coder.auto_commits = settings["auto_commits"]
242
+ if "auto_lint" in settings:
243
+ self.coder.auto_lint = settings["auto_lint"]
244
+ if "auto_test" in settings:
245
+ self.coder.auto_test = settings["auto_test"]
246
+
247
+ # Restore todo list content if present in the session
248
+ if "todo_list" in session_data:
249
+ todo_path = self.coder.abs_root_path(".cecli.todo.txt")
250
+ todo_content = session_data.get("todo_list")
251
+ try:
252
+ if todo_content is None:
253
+ if os.path.exists(todo_path):
254
+ os.remove(todo_path)
255
+ else:
256
+ self.io.write_text(todo_path, todo_content)
257
+ except Exception as e:
258
+ self.io.tool_warning(f"Could not restore todo list: {e}")
259
+
260
+ self.io.tool_output(
261
+ f"Session loaded: {session_data.get('session_name', session_file.stem)}"
262
+ )
263
+ self.io.tool_output(
264
+ f"Model: {session_data.get('model', 'unknown')}, Edit format:"
265
+ f" {session_data.get('edit_format', 'unknown')}"
266
+ )
267
+
268
+ # Show summary
269
+ num_messages = len(self.coder.done_messages) + len(self.coder.cur_messages)
270
+ num_files = (
271
+ len(self.coder.abs_fnames)
272
+ + len(self.coder.abs_read_only_fnames)
273
+ + len(self.coder.abs_read_only_stubs_fnames)
274
+ )
275
+ self.io.tool_output(f"Loaded {num_messages} messages and {num_files} files")
276
+
277
+ return True
278
+
279
+ except Exception as e:
280
+ self.io.tool_error(f"Error applying session data: {e}")
281
+ return False