code-context-control 2.38.1__tar.gz → 2.39.0__tar.gz

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 (221) hide show
  1. {code_context_control-2.38.1/code_context_control.egg-info → code_context_control-2.39.0}/PKG-INFO +1 -1
  2. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/_hook_utils.py +39 -2
  3. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/c3.py +60 -35
  4. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_edit_ledger.py +9 -3
  5. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_edit_unlock.py +9 -1
  6. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_pretool_enforce.py +23 -7
  7. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/delegate.py +26 -20
  8. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/edit.py +65 -18
  9. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/memory.py +4 -0
  10. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/read.py +27 -5
  11. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/validate.py +43 -15
  12. {code_context_control-2.38.1 → code_context_control-2.39.0/code_context_control.egg-info}/PKG-INFO +1 -1
  13. {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/SOURCES.txt +3 -0
  14. {code_context_control-2.38.1 → code_context_control-2.39.0}/core/mcp_toml.py +29 -2
  15. {code_context_control-2.38.1 → code_context_control-2.39.0}/core/web_security.py +6 -0
  16. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/mcp_oracle.py +18 -3
  17. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/oracle_server.py +54 -14
  18. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/activity_reporter.py +29 -4
  19. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/c3_bridge.py +29 -2
  20. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/chat_engine.py +6 -0
  21. {code_context_control-2.38.1 → code_context_control-2.39.0}/pyproject.toml +1 -1
  22. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/claude_md.py +26 -11
  23. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/compressor.py +5 -1
  24. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/context_snapshot.py +39 -9
  25. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/conversation_store.py +99 -48
  26. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/edit_ledger.py +58 -27
  27. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/file_memory.py +77 -6
  28. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/parser.py +32 -6
  29. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_activity_reporter.py +15 -0
  30. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_claude_md_merge.py +39 -0
  31. code_context_control-2.39.0/tests/test_edit_ledger_hook.py +88 -0
  32. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_edit_normalization.py +121 -0
  33. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_enforcement_flip.py +67 -2
  34. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_mcp_toml.py +39 -0
  35. code_context_control-2.39.0/tests/test_oracle_security_fixes.py +159 -0
  36. code_context_control-2.39.0/tests/test_service_durability.py +186 -0
  37. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_web_security.py +31 -0
  38. {code_context_control-2.38.1 → code_context_control-2.39.0}/LICENSE +0 -0
  39. {code_context_control-2.38.1 → code_context_control-2.39.0}/README.md +0 -0
  40. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/__init__.py +0 -0
  41. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/commands/__init__.py +0 -0
  42. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/commands/common.py +0 -0
  43. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/commands/parser.py +0 -0
  44. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/docs.html +0 -0
  45. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/edits.html +0 -0
  46. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/bitbucket.html +0 -0
  47. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/getting-started.html +0 -0
  48. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/index.html +0 -0
  49. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/oracle.html +0 -0
  50. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/shared.css +0 -0
  51. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/tools.html +0 -0
  52. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/workflow.html +0 -0
  53. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_auto_snapshot.py +0 -0
  54. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_c3_signal.py +0 -0
  55. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_c3read.py +0 -0
  56. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_filter.py +0 -0
  57. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_ghost_files.py +0 -0
  58. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_read.py +0 -0
  59. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_session_stats.py +0 -0
  60. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_terse_advisor.py +0 -0
  61. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hub.html +0 -0
  62. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hub_server.py +0 -0
  63. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/mcp_proxy.py +0 -0
  64. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/mcp_server.py +0 -0
  65. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/server.py +0 -0
  66. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/__init__.py +0 -0
  67. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/_helpers.py +0 -0
  68. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/agent.py +0 -0
  69. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/bitbucket.py +0 -0
  70. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/compress.py +0 -0
  71. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/edits.py +0 -0
  72. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/filter.py +0 -0
  73. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/impact.py +0 -0
  74. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/project.py +0 -0
  75. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/search.py +0 -0
  76. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/session.py +0 -0
  77. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/shell.py +0 -0
  78. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/status.py +0 -0
  79. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/api.js +0 -0
  80. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/app.js +0 -0
  81. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/bitbucket.js +0 -0
  82. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/chat.js +0 -0
  83. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/dashboard.js +0 -0
  84. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/edits.js +0 -0
  85. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/instructions.js +0 -0
  86. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/memory.js +0 -0
  87. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/sessions.js +0 -0
  88. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/settings.js +0 -0
  89. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/sidebar.js +0 -0
  90. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/icons.js +0 -0
  91. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/shared.js +0 -0
  92. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/theme.js +0 -0
  93. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui.html +0 -0
  94. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui_legacy.html +0 -0
  95. {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui_nano.html +0 -0
  96. {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/dependency_links.txt +0 -0
  97. {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/entry_points.txt +0 -0
  98. {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/requires.txt +0 -0
  99. {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/top_level.txt +0 -0
  100. {code_context_control-2.38.1 → code_context_control-2.39.0}/core/__init__.py +0 -0
  101. {code_context_control-2.38.1 → code_context_control-2.39.0}/core/config.py +0 -0
  102. {code_context_control-2.38.1 → code_context_control-2.39.0}/core/ide.py +0 -0
  103. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/__init__.py +0 -0
  104. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/config.py +0 -0
  105. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/oracle.html +0 -0
  106. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/__init__.py +0 -0
  107. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/api_auth.py +0 -0
  108. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/chat_store.py +0 -0
  109. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/cross_memory.py +0 -0
  110. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/federated_graph.py +0 -0
  111. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/health_checker.py +0 -0
  112. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/insight_engine.py +0 -0
  113. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/memory_reader.py +0 -0
  114. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/memory_writer.py +0 -0
  115. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/ollama_bridge.py +0 -0
  116. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/project_scanner.py +0 -0
  117. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/review_agent.py +0 -0
  118. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/tool_executor.py +0 -0
  119. {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/tool_registry.py +0 -0
  120. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/__init__.py +0 -0
  121. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/activity_log.py +0 -0
  122. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/agent_base.py +0 -0
  123. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/agents.py +0 -0
  124. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/auto_memory.py +0 -0
  125. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/__init__.py +0 -0
  126. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/external/__init__.py +0 -0
  127. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/external/aider_polyglot.py +0 -0
  128. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/external/swe_bench.py +0 -0
  129. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/benchmark_dashboard.py +0 -0
  130. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bitbucket_client.py +0 -0
  131. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bitbucket_credentials.py +0 -0
  132. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/doc_index.py +0 -0
  133. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/e2e_benchmark.py +0 -0
  134. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/e2e_evaluator.py +0 -0
  135. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/e2e_tasks.py +0 -0
  136. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/embedding_index.py +0 -0
  137. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/error_reporting.py +0 -0
  138. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/git_context.py +0 -0
  139. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/hub_service.py +0 -0
  140. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/indexer.py +0 -0
  141. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory.py +0 -0
  142. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_consolidator.py +0 -0
  143. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_graph.py +0 -0
  144. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_grounder.py +0 -0
  145. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_scorer.py +0 -0
  146. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/metrics.py +0 -0
  147. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/notifications.py +0 -0
  148. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/ollama_client.py +0 -0
  149. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/output_filter.py +0 -0
  150. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/project_manager.py +0 -0
  151. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/project_runtime.py +0 -0
  152. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/protocol.py +0 -0
  153. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/proxy_state.py +0 -0
  154. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/retrieval_broker.py +0 -0
  155. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/router.py +0 -0
  156. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/runtime.py +0 -0
  157. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/session_benchmark.py +0 -0
  158. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/session_manager.py +0 -0
  159. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/session_preloader.py +0 -0
  160. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/text_index.py +0 -0
  161. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/tool_classifier.py +0 -0
  162. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/transcript_index.py +0 -0
  163. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/validation_cache.py +0 -0
  164. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/vector_store.py +0 -0
  165. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/version_tracker.py +0 -0
  166. {code_context_control-2.38.1 → code_context_control-2.39.0}/services/watcher.py +0 -0
  167. {code_context_control-2.38.1 → code_context_control-2.39.0}/setup.cfg +0 -0
  168. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_aider_polyglot.py +0 -0
  169. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_cli_smoke.py +0 -0
  170. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_client.py +0 -0
  171. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_credentials.py +0 -0
  172. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_tool.py +0 -0
  173. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_c3_shell.py +0 -0
  174. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_cli_smoke.py +0 -0
  175. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_e2e_benchmark.py +0 -0
  176. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_federated_graph.py +0 -0
  177. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_ghost_files.py +0 -0
  178. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_git_branch_awareness.py +0 -0
  179. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_hub_server_smoke.py +0 -0
  180. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_install_mcp_entrypoint.py +0 -0
  181. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_lazy_store_init.py +0 -0
  182. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_mcp_host_guard.py +0 -0
  183. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_mcp_server_smoke.py +0 -0
  184. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_memory_graph_api.py +0 -0
  185. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_memory_system.py +0 -0
  186. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_notification_discipline.py +0 -0
  187. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_oracle_api_auth.py +0 -0
  188. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_oracle_apikey_api.py +0 -0
  189. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_oracle_discovery_api.py +0 -0
  190. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_output_filter.py +0 -0
  191. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_permissions.py +0 -0
  192. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_project_manager.py +0 -0
  193. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_project_manager_merge.py +0 -0
  194. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_project_tool.py +0 -0
  195. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_read_coercion.py +0 -0
  196. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_session_benchmark.py +0 -0
  197. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_session_budget.py +0 -0
  198. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_shell_robustness.py +0 -0
  199. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_swe_bench.py +0 -0
  200. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_tool_registry.py +0 -0
  201. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_upgrade_and_version.py +0 -0
  202. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_validate.py +0 -0
  203. {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_windows_reliability.py +0 -0
  204. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/__init__.py +0 -0
  205. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/backend.py +0 -0
  206. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/main.py +0 -0
  207. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/__init__.py +0 -0
  208. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/benchmark_view.py +0 -0
  209. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/claudemd_view.py +0 -0
  210. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/compress_view.py +0 -0
  211. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/index_view.py +0 -0
  212. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/init_view.py +0 -0
  213. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/mcp_view.py +0 -0
  214. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/optimize_view.py +0 -0
  215. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/pipe_view.py +0 -0
  216. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/projects_view.py +0 -0
  217. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/search_view.py +0 -0
  218. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/session_view.py +0 -0
  219. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/stats.py +0 -0
  220. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/ui_view.py +0 -0
  221. {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/theme.tcss +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.38.1
3
+ Version: 2.39.0
4
4
  Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -74,9 +74,46 @@ def get_tool_output(data: dict) -> tuple:
74
74
 
75
75
 
76
76
  def get_tool_input_path(data: dict) -> str:
77
- """Extract file path from tool_input, handling both Claude (file_path) and Gemini (path)."""
77
+ """Extract file path from tool_input, handling Claude (file_path),
78
+ Gemini (path), and NotebookEdit (notebook_path)."""
78
79
  tool_input = data.get("tool_input", {})
79
- return tool_input.get("file_path", "") or tool_input.get("path", "")
80
+ return (
81
+ tool_input.get("file_path", "")
82
+ or tool_input.get("path", "")
83
+ or tool_input.get("notebook_path", "")
84
+ )
85
+
86
+
87
+ def record_json_unlocks(editable: list, project_path: Path | None = None) -> None:
88
+ """Record file paths as read+edit unlocked in .c3/unlocked_files.json.
89
+
90
+ This is the map that hook_pretool_enforce.py actually reads (the plain
91
+ .txt unlock list is not consumed by any hook). Mirrors the behaviour of
92
+ cli/hook_c3read._record_json_unlocks so c3_compress/c3_agent sticky
93
+ unlocks reach the enforcer. Fails silently on I/O errors.
94
+ """
95
+ base = project_path if project_path is not None else Path.cwd()
96
+ json_path = base / ".c3" / "unlocked_files.json"
97
+ try:
98
+ existing: dict = {}
99
+ if json_path.exists():
100
+ try:
101
+ existing = json.loads(json_path.read_text(encoding="utf-8"))
102
+ if not isinstance(existing, dict):
103
+ existing = {}
104
+ except Exception:
105
+ existing = {}
106
+ for fp in editable:
107
+ if not fp:
108
+ continue
109
+ normalized = str(Path(fp).resolve())
110
+ cats = set(existing.get(normalized, []))
111
+ cats.update({"read", "edit"})
112
+ existing[normalized] = sorted(cats)
113
+ json_path.parent.mkdir(parents=True, exist_ok=True)
114
+ json_path.write_text(json.dumps(existing), encoding="utf-8")
115
+ except Exception:
116
+ pass
80
117
 
81
118
 
82
119
  def emit_additional_context(text: str, is_gemini: bool) -> None:
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
85
85
  # Config
86
86
  CONFIG_DIR = ".c3"
87
87
  CONFIG_FILE = ".c3/config.json"
88
- __version__ = "2.38.1"
88
+ __version__ = "2.39.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -4084,7 +4084,11 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
4084
4084
  content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
4085
4085
  header = f"[{section}]"
4086
4086
 
4087
- # Strip existing section (header + its key=value lines)
4087
+ # Strip existing section (header + its key=value lines). Also strip any
4088
+ # dotted child subtables (e.g. "[mcp_servers.c3.env]" under
4089
+ # "[mcp_servers.c3]") so they are not orphaned beneath the re-appended
4090
+ # section, which would corrupt the file on re-run.
4091
+ child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3."
4088
4092
  lines = content.splitlines()
4089
4093
  new_lines: list[str] = []
4090
4094
  skip = False
@@ -4094,7 +4098,8 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
4094
4098
  skip = True
4095
4099
  continue
4096
4100
  if skip and stripped.startswith("["):
4097
- skip = False
4101
+ if not stripped.startswith(child_prefix):
4102
+ skip = False
4098
4103
  if not skip:
4099
4104
  new_lines.append(line)
4100
4105
 
@@ -4577,41 +4582,48 @@ def _ensure_global_claude_md() -> None:
4577
4582
 
4578
4583
  existing = global_md.read_text(encoding="utf-8")
4579
4584
 
4580
- if _GLOBAL_CLAUDE_MD_MARKER not in existing:
4581
- # User has their own CLAUDE.md append C3 section
4582
- merged = existing.rstrip() + "\n\n" + _GLOBAL_CLAUDE_MD_CONTENT
4583
- global_md.write_text(merged, encoding="utf-8")
4584
- print(f"Updated {global_md} (appended C3 enforcement)")
4585
- return
4585
+ # The C3-managed region is delimited by explicit BEGIN/END sentinels (the
4586
+ # same ones used for project instruction docs). This is unambiguous, so
4587
+ # user-written content outside the markers — including H1 headings that
4588
+ # happen to mention "C3" or "Tool Discipline" — is never swallowed.
4589
+ from services.claude_md import C3_BLOCK_BEGIN, C3_BLOCK_END, merge_c3_block
4586
4590
 
4587
- # C3 section exists — replace it with the latest version
4588
- # Find the C3 section boundaries: starts at the marker, ends at next # heading or EOF
4589
- start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
4590
- # Find the next top-level heading after the C3 section
4591
- rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
4592
- lines_after = rest.split("\n")
4593
- end_offset = len(rest) # default: to EOF
4594
- running = 0
4595
- for line in lines_after:
4596
- running += len(line) + 1
4597
- # A top-level heading that's NOT part of C3's sub-headings
4598
- if line.startswith("# ") and "C3" not in line and "Tool Discipline" not in line:
4599
- end_offset = running - len(line) - 1
4600
- break
4601
-
4602
- end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
4603
- before = existing[:start].rstrip()
4604
- after = existing[end:].lstrip()
4591
+ wrapped = f"{C3_BLOCK_BEGIN}\n{_GLOBAL_CLAUDE_MD_CONTENT.strip()}\n{C3_BLOCK_END}"
4605
4592
 
4606
- parts = []
4607
- if before:
4608
- parts.append(before)
4609
- parts.append(_GLOBAL_CLAUDE_MD_CONTENT.strip())
4610
- if after:
4611
- parts.append(after)
4593
+ # Markers already present → surgical, marker-bounded replacement.
4594
+ if C3_BLOCK_BEGIN in existing:
4595
+ global_md.write_text(merge_c3_block(existing, wrapped), encoding="utf-8")
4596
+ print(f"Updated {global_md} (refreshed C3 enforcement)")
4597
+ return
4612
4598
 
4613
- global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
4614
- print(f"Updated {global_md} (refreshed C3 enforcement)")
4599
+ # Legacy marker-less C3 region → one-time migration into the marked block.
4600
+ # Bound the region from the legacy heading to the NEXT top-level (``# ``)
4601
+ # heading. C3's own content has exactly one H1 (the legacy heading itself),
4602
+ # so the next H1 reliably marks where user content resumes; we deliberately
4603
+ # do NOT skip H1s containing "C3"/"Tool Discipline" (the old heuristic did,
4604
+ # which is what swallowed user headings).
4605
+ if _GLOBAL_CLAUDE_MD_MARKER in existing:
4606
+ start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
4607
+ rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
4608
+ end_offset = len(rest) # default: to EOF
4609
+ running = 0
4610
+ for line in rest.split("\n"):
4611
+ running += len(line) + 1
4612
+ if line.startswith("# "):
4613
+ end_offset = running - len(line) - 1
4614
+ break
4615
+ end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
4616
+ before = existing[:start].rstrip()
4617
+ after = existing[end:].lstrip()
4618
+ parts = [p for p in (before, wrapped, after) if p]
4619
+ global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
4620
+ print(f"Updated {global_md} (migrated C3 enforcement to markers)")
4621
+ return
4622
+
4623
+ # User has their own CLAUDE.md with no C3 content — append the marked block.
4624
+ merged = existing.rstrip() + "\n\n" + wrapped + "\n"
4625
+ global_md.write_text(merged, encoding="utf-8")
4626
+ print(f"Updated {global_md} (appended C3 enforcement)")
4615
4627
 
4616
4628
 
4617
4629
  def _instruction_documents_for_project() -> list[tuple[str, str]]:
@@ -5019,6 +5031,8 @@ def cmd_install_mcp(args):
5019
5031
  glob_matcher = "find_files"
5020
5032
  edit_matcher = "edit_file"
5021
5033
  write_matcher = "write_file"
5034
+ # Gemini has no MultiEdit / NotebookEdit equivalents.
5035
+ extra_edit_matchers = []
5022
5036
  else:
5023
5037
  shell_matcher = "Bash"
5024
5038
  read_matcher = "Read"
@@ -5026,6 +5040,9 @@ def cmd_install_mcp(args):
5026
5040
  glob_matcher = "Glob"
5027
5041
  edit_matcher = "Edit"
5028
5042
  write_matcher = "Write"
5043
+ # Claude Code also exposes MultiEdit (batch edits) and NotebookEdit;
5044
+ # both bypass enforcement/logging unless their matchers are registered.
5045
+ extra_edit_matchers = ["MultiEdit", "NotebookEdit"]
5029
5046
 
5030
5047
  # ── PostToolUse hooks ──
5031
5048
  desired_post_hooks = [
@@ -5120,6 +5137,10 @@ def cmd_install_mcp(args):
5120
5137
  "matcher": write_matcher,
5121
5138
  "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]
5122
5139
  },
5140
+ *[
5141
+ {"matcher": m, "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]}
5142
+ for m in extra_edit_matchers
5143
+ ],
5123
5144
  ]
5124
5145
 
5125
5146
  # ── PreToolUse hooks (enforcement — blocks native tools without prior c3_*) ──
@@ -5144,6 +5165,10 @@ def cmd_install_mcp(args):
5144
5165
  "matcher": write_matcher,
5145
5166
  "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5146
5167
  },
5168
+ *[
5169
+ {"matcher": m, "hooks": [{"type": "command", "command": hook_enforce_cmd}]}
5170
+ for m in extra_edit_matchers
5171
+ ],
5147
5172
  ]
5148
5173
 
5149
5174
  # Merge: replace existing C3 hooks (so re-running install-mcp updates commands),
@@ -10,6 +10,7 @@ EditLedgerEnricherAgent running in the MCP server background.
10
10
 
11
11
  import json
12
12
  import sys
13
+ import uuid
13
14
  from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
@@ -25,7 +26,7 @@ EDITABLE_EXTS = {
25
26
  ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
26
27
  ".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
27
28
  ".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
28
- ".sh", ".bat", ".ps1", ".r",
29
+ ".sh", ".bat", ".ps1", ".r", ".ipynb",
29
30
  }
30
31
 
31
32
  # How many tail lines to scan for version/seq (avoids full-file parse)
@@ -95,8 +96,11 @@ def _next_seq(ledger_file: Path, now: datetime) -> int:
95
96
  continue
96
97
  eid = entry.get("id", "")
97
98
  if eid.startswith(prefix):
99
+ # ids may carry a random suffix ("..._001_a1b2"); take the leading
100
+ # numeric run after the prefix so same-second seq counting survives.
101
+ seq_part = eid[len(prefix):].split("_", 1)[0]
98
102
  try:
99
- max_seq = max(max_seq, int(eid[len(prefix):]))
103
+ max_seq = max(max_seq, int(seq_part))
100
104
  except ValueError:
101
105
  pass
102
106
  return max_seq + 1
@@ -172,7 +176,9 @@ def main():
172
176
  git_pending = tracking_level != "minimal"
173
177
 
174
178
  entry = {
175
- "id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}",
179
+ # Random suffix prevents id collisions when the hook process and the
180
+ # server process (services/edit_ledger.py) write within the same second.
181
+ "id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}_{uuid.uuid4().hex[:4]}",
176
182
  "timestamp": now.isoformat(),
177
183
  "session_id": "",
178
184
  "file": rel,
@@ -14,7 +14,11 @@ from pathlib import Path
14
14
 
15
15
  sys.path.insert(0, str(Path(__file__).parent.parent))
16
16
 
17
- from cli._hook_utils import emit_additional_context, log_hook_error # noqa: E402
17
+ from cli._hook_utils import ( # noqa: E402
18
+ emit_additional_context,
19
+ log_hook_error,
20
+ record_json_unlocks,
21
+ )
18
22
 
19
23
  EDITABLE_EXTS = {
20
24
  ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
@@ -144,6 +148,10 @@ def main():
144
148
  except Exception:
145
149
  pass
146
150
 
151
+ # Fix 2: also write the .json unlock map — the .txt list above is read
152
+ # by NO hook; hook_pretool_enforce.py only consults unlocked_files.json.
153
+ record_json_unlocks(editable)
154
+
147
155
  # Emit batched nudge with all pending files
148
156
  # Prefer c3_edit (no unlock needed). Native Edit is also unlocked via sticky file set.
149
157
  if len(pending) == 1:
@@ -180,24 +180,30 @@ def _is_file_unlocked(project_path: Path, file_path: str, category: str) -> bool
180
180
  return category in cats or "both" in cats
181
181
 
182
182
 
183
- def _check_signal_file(project_path: Path) -> tuple[bool, bool]:
183
+ def _check_signal_file(project_path: Path) -> tuple[bool, bool, str]:
184
184
  """Read last_c3_call.json written by hook_c3_signal.py.
185
185
 
186
- Returns (recent, read_unlocked):
186
+ Returns (recent, read_unlocked, c3_tool):
187
187
  recent: True if a c3_* tool completed within _SIGNAL_MAX_AGE_SECS
188
188
  read_unlocked: True if that tool was c3_search/c3_compress/c3_filter
189
+ c3_tool: short name of the c3 tool that wrote the signal (e.g.
190
+ "c3_edit"), or "" if recent is False / unparseable.
191
+
192
+ Fails closed: on any parse error, returns (False, False, "").
189
193
  """
190
194
  signal_path = project_path / _SIGNAL_FILE
191
195
  if not signal_path.exists():
192
- return False, False
196
+ return False, False, ""
193
197
  try:
194
198
  data = json.loads(signal_path.read_text(encoding="utf-8"))
195
199
  ts = datetime.fromisoformat(data["timestamp"])
196
200
  age = (datetime.now(timezone.utc) - ts).total_seconds()
197
201
  recent = age <= _SIGNAL_MAX_AGE_SECS
198
- return recent, bool(data.get("read_unlocked", False)) and recent
202
+ if not recent:
203
+ return False, False, ""
204
+ return True, bool(data.get("read_unlocked", False)), str(data.get("tool", ""))
199
205
  except Exception:
200
- return False, False
206
+ return False, False, ""
201
207
 
202
208
 
203
209
  def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tuple[bool, str]:
@@ -223,10 +229,20 @@ def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tupl
223
229
  required_cat = _TOOL_CATEGORY.get(tool_name, "read")
224
230
 
225
231
  # ── Fix 4: signal file — primary, fast, reliable ─────────────────────────
226
- signal_recent, signal_read_unlocked = _check_signal_file(project_path)
232
+ signal_recent, signal_read_unlocked, signal_tool = _check_signal_file(project_path)
227
233
  if signal_recent:
234
+ # Bypass fix: for write-class tools (Edit/Write/MultiEdit), the signal
235
+ # may only unlock them when the c3 tool that wrote it actually satisfies
236
+ # this tool's prereqs (e.g. c3_edit/c3_edits/c3_agent). A read-class
237
+ # signal (c3_status, c3_search, …) must NOT unlock a native write.
238
+ if tool_name in _BLOCKED_TOOLS:
239
+ if signal_tool in allowed:
240
+ if native_target:
241
+ _record_unlock(project_path, native_target, required_cat)
242
+ return True, "signal"
243
+ # Fresh signal exists but it's not a write-prereq tool — fall through
228
244
  # Fix 5: Grep/Glob without file path needs a read-unlocking tool
229
- if not native_target and tool_name in ("Grep", "Glob", "FindFiles", "SearchText"):
245
+ elif not native_target and tool_name in ("Grep", "Glob", "FindFiles", "SearchText"):
230
246
  if signal_read_unlocked:
231
247
  return True, "signal"
232
248
  # Signal exists but not read-unlocking (e.g. c3_memory) — fall through
@@ -40,6 +40,7 @@ def _kill_proc_tree(proc):
40
40
  subprocess.run(
41
41
  ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
42
42
  capture_output=True, stdin=subprocess.DEVNULL,
43
+ creationflags=subprocess.CREATE_NO_WINDOW,
43
44
  )
44
45
  else:
45
46
  proc.kill()
@@ -51,8 +52,10 @@ def _kill_proc_tree(proc):
51
52
  def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
52
53
  """communicate() replacement with idle-activity watchdog.
53
54
 
54
- Monitors stderr for activity. If no stderr output for idle_timeout seconds,
55
- kills the process early (catches MCP startup hangs). Also enforces total timeout.
55
+ Monitors both stdout and stderr for activity. If neither stream produces
56
+ output for idle_timeout seconds, kills the process early (catches MCP startup
57
+ hangs) without killing a backend that streams its answer only on stdout.
58
+ Also enforces total timeout.
56
59
 
57
60
  Returns (stdout, stderr, status) where status is 'ok', 'timeout', or 'idle_timeout'.
58
61
  """
@@ -71,7 +74,7 @@ def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
71
74
  except (ValueError, OSError):
72
75
  pass
73
76
 
74
- t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
77
+ t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
75
78
  t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
76
79
  t_out.start()
77
80
  t_err.start()
@@ -218,10 +221,17 @@ def _run_claude(task: str, context: str, cwd: str | None = None,
218
221
  cmd,
219
222
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
220
223
  stdin=subprocess.DEVNULL,
221
- text=True, cwd=cwd,
224
+ text=True, encoding="utf-8", errors="replace", cwd=cwd,
222
225
  **_popen_kwargs(),
223
226
  )
224
- output, err = _communicate_with_heartbeat(proc, timeout=timeout, idle_timeout=idle_timeout)
227
+ output, err, status = _communicate_with_heartbeat(
228
+ proc, timeout=timeout, idle_timeout=idle_timeout,
229
+ )
230
+ if status == "idle_timeout":
231
+ return (f"[claude:idle_timeout] No stderr activity for {idle_timeout}s "
232
+ f"(likely MCP startup hang)"), False
233
+ if status == "timeout":
234
+ return f"[claude:timeout] No response after {timeout}s", False
225
235
  if proc.returncode == 0 and output.strip():
226
236
  return output.strip(), True
227
237
  return f"[claude:error] {(err or '').strip() or 'no output'}", False
@@ -307,7 +317,7 @@ def _start_gemini_early(model: str, timeout: int = 45, idle_timeout: int = 15,
307
317
  cmd,
308
318
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
309
319
  stdin=subprocess.PIPE,
310
- text=True,
320
+ text=True, encoding="utf-8", errors="replace",
311
321
  cwd=cwd,
312
322
  **_popen_kwargs(),
313
323
  )
@@ -344,7 +354,7 @@ def _finish_gemini_early(proc, task: str, context: str,
344
354
  except (ValueError, OSError):
345
355
  pass
346
356
 
347
- t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
357
+ t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
348
358
  t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
349
359
  t_out.start()
350
360
  t_err.start()
@@ -448,7 +458,7 @@ def _run_gemini(task: str, context: str, model: str,
448
458
  cmd,
449
459
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
450
460
  stdin=subprocess.DEVNULL,
451
- text=True,
461
+ text=True, encoding="utf-8", errors="replace",
452
462
  cwd=cwd,
453
463
  **_popen_kwargs(),
454
464
  )
@@ -563,7 +573,7 @@ def _run_codex(task: str, context: str, model: str, sandbox: str,
563
573
  cmd,
564
574
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
565
575
  stdin=subprocess.DEVNULL,
566
- text=True,
576
+ text=True, encoding="utf-8", errors="replace",
567
577
  cwd=cwd,
568
578
  **_popen_kwargs(),
569
579
  )
@@ -592,25 +602,18 @@ def _run_codex_resume(follow_up: str, timeout: int = 120,
592
602
  """Resume last Codex session with a follow-up prompt."""
593
603
  cmd = ["codex", "exec", "--skip-git-repo-check", "resume", "--last"]
594
604
  try:
595
- import sys
596
605
  proc = subprocess.Popen(
597
606
  cmd,
598
607
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
599
608
  stdin=subprocess.PIPE,
600
- text=True,
609
+ text=True, encoding="utf-8", errors="replace",
601
610
  cwd=cwd,
611
+ **_popen_kwargs(),
602
612
  )
603
613
  try:
604
614
  stdout, stderr = proc.communicate(input=follow_up, timeout=timeout)
605
615
  except subprocess.TimeoutExpired:
606
- if sys.platform == "win32":
607
- subprocess.run(
608
- ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
609
- capture_output=True, stdin=subprocess.DEVNULL,
610
- )
611
- else:
612
- proc.kill()
613
- proc.wait(timeout=5)
616
+ _kill_proc_tree(proc)
614
617
  return f"[codex:timeout] Resume timed out after {timeout}s", False
615
618
 
616
619
  if proc.returncode != 0:
@@ -1166,7 +1169,10 @@ def handle_delegate(task: str, task_type: str, context: str, file_path: str,
1166
1169
  model=fallback, system=tdef["system"],
1167
1170
  temperature=tdef.get("temperature", 0.3),
1168
1171
  max_tokens=int(dcfg.get("max_tokens", 512) or 512),
1169
- )
1172
+ timeout=timeout_s)
1173
+ if retry_resp is None:
1174
+ # Timeout/failure on the fallback — not a valid empty answer.
1175
+ continue
1170
1176
  retry_conf = _estimate_confidence(task_type, retry_resp, count_tokens(retry_resp))
1171
1177
  if retry_conf != "low":
1172
1178
  resp = retry_resp
@@ -25,6 +25,37 @@ def _get_file_lock(path: Path) -> threading.Lock:
25
25
  return _file_locks[key]
26
26
 
27
27
 
28
+ def _read_preserving_newlines(path: Path) -> tuple[str, str]:
29
+ """Read a file's text and detect its dominant newline style.
30
+
31
+ Returns (content, newline) where `content` has all line endings
32
+ normalized to ``\n`` (so existing replace logic is unchanged) and
33
+ `newline` is the EOL to write back: ``\r\n`` if CRLF dominates the
34
+ file, otherwise ``\n``. This avoids Python's text-mode write rewriting
35
+ every line to ``os.linesep`` on Windows.
36
+ """
37
+ raw = path.read_bytes()
38
+ crlf = raw.count(b"\r\n")
39
+ lf_only = raw.count(b"\n") - crlf
40
+ newline = "\r\n" if crlf > lf_only else "\n"
41
+ content = raw.decode("utf-8")
42
+ # Normalize to \n internally so replacement matching is EOL-agnostic.
43
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
44
+ return content, newline
45
+
46
+
47
+ def _write_preserving_newlines(path: Path, content: str, newline: str) -> None:
48
+ """Write `content` (which uses ``\n``) back using the original EOL style.
49
+
50
+ Uses ``newline=""`` so Python performs no translation; we emit the
51
+ detected EOL explicitly so an LF-only file stays LF-only on Windows.
52
+ """
53
+ if newline != "\n":
54
+ content = content.replace("\n", newline)
55
+ with open(path, "w", encoding="utf-8", newline="") as fh:
56
+ fh.write(content)
57
+
58
+
28
59
  # Unicode lookalike substitutions used as a fallback when the literal
29
60
  # old_string is not found. Strictly 1:1 (same-length) substitutions so
30
61
  # positions are preserved — we locate the match on the normalized string
@@ -131,7 +162,10 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
131
162
 
132
163
  try:
133
164
  path.parent.mkdir(parents=True, exist_ok=True)
134
- path.write_text(new_string, encoding="utf-8")
165
+ # newline="" → write content exactly as given; no os.linesep
166
+ # translation, so the caller's line endings are preserved verbatim.
167
+ with open(path, "w", encoding="utf-8", newline="") as fh:
168
+ fh.write(new_string)
135
169
  except Exception as e:
136
170
  return finalize("c3_edit", {"file": file_path},
137
171
  f"Create error: {e}", "create error")
@@ -161,15 +195,22 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
161
195
  return finalize("c3_edit", {"file": file_path},
162
196
  "edits must be a non-empty JSON list", "bad edits param")
163
197
 
198
+ if not all(isinstance(p, dict) for p in edit_list):
199
+ return finalize("c3_edit", {"file": file_path},
200
+ "edits must be a JSON list of objects "
201
+ "({old_string, new_string, ...}); a non-object element was found",
202
+ "bad edits param")
203
+
164
204
  with file_lock:
165
205
  try:
166
- content = path.read_text(encoding="utf-8")
206
+ content, _newline = _read_preserving_newlines(path)
167
207
  except Exception as e:
168
208
  return finalize("c3_edit", {"file": file_path},
169
209
  f"Read error: {e}", "read error")
170
210
 
171
211
  results = []
172
212
  any_normalized = False
213
+ any_applied = False
173
214
  for i, patch in enumerate(edit_list):
174
215
  old = patch.get("old_string", "")
175
216
  new = patch.get("new_string", "")
@@ -190,6 +231,7 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
190
231
  continue
191
232
 
192
233
  content = new_content
234
+ any_applied = True
193
235
  n = count if r_all else 1
194
236
  if used_fallback:
195
237
  any_normalized = True
@@ -202,22 +244,27 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
202
244
  + (" [norm]" if used_fallback else "")
203
245
  + f" | {desc}")
204
246
 
205
- try:
206
- path.write_text(content, encoding="utf-8")
207
- except Exception as e:
208
- return finalize("c3_edit", {"file": file_path},
209
- f"Write error: {e}", "write error")
247
+ # Only touch the file when at least one patch actually changed it —
248
+ # avoids rewriting (and re-EOL-normalizing) an unchanged file and
249
+ # logging a phantom ledger entry when every patch missed.
250
+ if any_applied:
251
+ try:
252
+ _write_preserving_newlines(path, content, _newline)
253
+ except Exception as e:
254
+ return finalize("c3_edit", {"file": file_path},
255
+ f"Write error: {e}", "write error")
210
256
 
211
257
  # Log batch to ledger as one entry (store each patch's old/new for diff view)
212
- batch_detail = {"patches": [
213
- {
214
- "old_string": p.get("old_string", "")[:_DETAIL_CAP],
215
- "new_string": p.get("new_string", "")[:_DETAIL_CAP],
216
- **({"summary": p["summary"]} if p.get("summary") else {}),
217
- }
218
- for p in edit_list if p.get("old_string") is not None
219
- ]}
220
- _log_to_ledger(rel, summary or f"Batch edit: {len(edit_list)} patches", tag_list, svc, detail=batch_detail)
258
+ if any_applied:
259
+ batch_detail = {"patches": [
260
+ {
261
+ "old_string": p.get("old_string", "")[:_DETAIL_CAP],
262
+ "new_string": p.get("new_string", "")[:_DETAIL_CAP],
263
+ **({"summary": p["summary"]} if p.get("summary") else {}),
264
+ }
265
+ for p in edit_list if p.get("old_string") is not None
266
+ ]}
267
+ _log_to_ledger(rel, summary or f"Batch edit: {len(edit_list)} patches", tag_list, svc, detail=batch_detail)
221
268
 
222
269
  applied = sum(1 for r in results if "NOT FOUND" not in r and "AMBIGUOUS" not in r and "skipped" not in r)
223
270
  norm_tag = " [unicode-normalized]" if any_normalized else ""
@@ -234,7 +281,7 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
234
281
 
235
282
  with file_lock:
236
283
  try:
237
- content = path.read_text(encoding="utf-8")
284
+ content, _newline = _read_preserving_newlines(path)
238
285
  except Exception as e:
239
286
  return finalize("c3_edit", {"file": file_path},
240
287
  f"Read error: {e}", "read error")
@@ -260,7 +307,7 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
260
307
  occurrences = count if replace_all else 1
261
308
 
262
309
  try:
263
- path.write_text(new_content, encoding="utf-8")
310
+ _write_preserving_newlines(path, new_content, _newline)
264
311
  except Exception as e:
265
312
  return finalize("c3_edit", {"file": file_path},
266
313
  f"Write error: {e}", "write error")
@@ -5,6 +5,10 @@ from datetime import datetime, timezone
5
5
  def handle_memory(action: str, query: str, fact: str, category: str,
6
6
  top_k: int, svc, finalize, fact_id: str = "") -> str:
7
7
  if action == "add":
8
+ if not fact or not fact.strip():
9
+ return finalize("c3_memory", {"action": action},
10
+ "fact is required to add a memory (got empty/whitespace)",
11
+ "missing fact")
8
12
  sid = (svc.session_mgr.current_session or {}).get("id", "")
9
13
  res = svc.memory.remember(fact, category or "general", sid)
10
14
  return finalize("c3_memory", {"action": action},