newcode 0.1.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 (289) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +147 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +630 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +122 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +380 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +167 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2145 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +296 -0
  28. code_puppy/agents/pack/husky.py +307 -0
  29. code_puppy/agents/pack/retriever.py +380 -0
  30. code_puppy/agents/pack/shepherd.py +327 -0
  31. code_puppy/agents/pack/terrier.py +281 -0
  32. code_puppy/agents/pack/watchdog.py +357 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +674 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +664 -0
  49. code_puppy/cli_runner.py +1038 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +526 -0
  57. code_puppy/command_line/command_handler.py +283 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +853 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +91 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/skills_completion.py +160 -0
  97. code_puppy/command_line/uc_menu.py +893 -0
  98. code_puppy/command_line/utils.py +93 -0
  99. code_puppy/command_line/wiggum_state.py +78 -0
  100. code_puppy/config.py +1787 -0
  101. code_puppy/error_logging.py +133 -0
  102. code_puppy/gemini_code_assist.py +385 -0
  103. code_puppy/gemini_model.py +754 -0
  104. code_puppy/hook_engine/README.md +105 -0
  105. code_puppy/hook_engine/__init__.py +15 -0
  106. code_puppy/hook_engine/aliases.py +155 -0
  107. code_puppy/hook_engine/engine.py +195 -0
  108. code_puppy/hook_engine/executor.py +293 -0
  109. code_puppy/hook_engine/matcher.py +145 -0
  110. code_puppy/hook_engine/models.py +222 -0
  111. code_puppy/hook_engine/registry.py +106 -0
  112. code_puppy/hook_engine/validator.py +141 -0
  113. code_puppy/http_utils.py +361 -0
  114. code_puppy/keymap.py +128 -0
  115. code_puppy/main.py +10 -0
  116. code_puppy/mcp_/__init__.py +66 -0
  117. code_puppy/mcp_/async_lifecycle.py +286 -0
  118. code_puppy/mcp_/blocking_startup.py +469 -0
  119. code_puppy/mcp_/captured_stdio_server.py +275 -0
  120. code_puppy/mcp_/circuit_breaker.py +290 -0
  121. code_puppy/mcp_/config_wizard.py +507 -0
  122. code_puppy/mcp_/dashboard.py +308 -0
  123. code_puppy/mcp_/error_isolation.py +407 -0
  124. code_puppy/mcp_/examples/retry_example.py +226 -0
  125. code_puppy/mcp_/health_monitor.py +589 -0
  126. code_puppy/mcp_/managed_server.py +428 -0
  127. code_puppy/mcp_/manager.py +807 -0
  128. code_puppy/mcp_/mcp_logs.py +224 -0
  129. code_puppy/mcp_/registry.py +451 -0
  130. code_puppy/mcp_/retry_manager.py +337 -0
  131. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  132. code_puppy/mcp_/status_tracker.py +355 -0
  133. code_puppy/mcp_/system_tools.py +209 -0
  134. code_puppy/mcp_prompts/__init__.py +1 -0
  135. code_puppy/mcp_prompts/hook_creator.py +103 -0
  136. code_puppy/messaging/__init__.py +255 -0
  137. code_puppy/messaging/bus.py +613 -0
  138. code_puppy/messaging/commands.py +167 -0
  139. code_puppy/messaging/markdown_patches.py +57 -0
  140. code_puppy/messaging/message_queue.py +361 -0
  141. code_puppy/messaging/messages.py +569 -0
  142. code_puppy/messaging/queue_console.py +271 -0
  143. code_puppy/messaging/renderers.py +311 -0
  144. code_puppy/messaging/rich_renderer.py +1153 -0
  145. code_puppy/messaging/spinner/__init__.py +83 -0
  146. code_puppy/messaging/spinner/console_spinner.py +240 -0
  147. code_puppy/messaging/spinner/spinner_base.py +96 -0
  148. code_puppy/messaging/subagent_console.py +460 -0
  149. code_puppy/model_factory.py +848 -0
  150. code_puppy/model_switching.py +63 -0
  151. code_puppy/model_utils.py +168 -0
  152. code_puppy/models.json +130 -0
  153. code_puppy/models_dev_api.json +1 -0
  154. code_puppy/models_dev_parser.py +592 -0
  155. code_puppy/plugins/__init__.py +186 -0
  156. code_puppy/plugins/agent_skills/__init__.py +22 -0
  157. code_puppy/plugins/agent_skills/config.py +175 -0
  158. code_puppy/plugins/agent_skills/discovery.py +136 -0
  159. code_puppy/plugins/agent_skills/downloader.py +392 -0
  160. code_puppy/plugins/agent_skills/installer.py +22 -0
  161. code_puppy/plugins/agent_skills/metadata.py +219 -0
  162. code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
  163. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  164. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  165. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  166. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  167. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  168. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  169. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  170. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  171. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  172. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  173. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  174. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  175. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  176. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  177. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  178. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  179. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  180. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  181. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  182. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  183. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  184. code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
  185. code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
  186. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  187. code_puppy/plugins/claude_code_hooks/config.py +131 -0
  188. code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
  189. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  190. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  191. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  192. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  193. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  194. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  195. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  196. code_puppy/plugins/claude_code_oauth/utils.py +601 -0
  197. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  198. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  199. code_puppy/plugins/example_custom_command/README.md +280 -0
  200. code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
  201. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  202. code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
  203. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  204. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  205. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  206. code_puppy/plugins/hook_creator/__init__.py +1 -0
  207. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  208. code_puppy/plugins/hook_manager/__init__.py +1 -0
  209. code_puppy/plugins/hook_manager/config.py +277 -0
  210. code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
  211. code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
  212. code_puppy/plugins/oauth_puppy_html.py +224 -0
  213. code_puppy/plugins/scheduler/__init__.py +1 -0
  214. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  215. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  216. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  217. code_puppy/plugins/shell_safety/__init__.py +6 -0
  218. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  219. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  220. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  221. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  222. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  223. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  224. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  225. code_puppy/plugins/universal_constructor/models.py +138 -0
  226. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  227. code_puppy/plugins/universal_constructor/registry.py +302 -0
  228. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  229. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  230. code_puppy/pydantic_patches.py +317 -0
  231. code_puppy/reopenable_async_client.py +232 -0
  232. code_puppy/round_robin_model.py +150 -0
  233. code_puppy/scheduler/__init__.py +41 -0
  234. code_puppy/scheduler/__main__.py +9 -0
  235. code_puppy/scheduler/cli.py +118 -0
  236. code_puppy/scheduler/config.py +126 -0
  237. code_puppy/scheduler/daemon.py +280 -0
  238. code_puppy/scheduler/executor.py +155 -0
  239. code_puppy/scheduler/platform.py +19 -0
  240. code_puppy/scheduler/platform_unix.py +22 -0
  241. code_puppy/scheduler/platform_win.py +32 -0
  242. code_puppy/session_storage.py +338 -0
  243. code_puppy/status_display.py +257 -0
  244. code_puppy/summarization_agent.py +176 -0
  245. code_puppy/terminal_utils.py +418 -0
  246. code_puppy/tools/__init__.py +470 -0
  247. code_puppy/tools/agent_tools.py +616 -0
  248. code_puppy/tools/ask_user_question/__init__.py +26 -0
  249. code_puppy/tools/ask_user_question/constants.py +73 -0
  250. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  251. code_puppy/tools/ask_user_question/handler.py +232 -0
  252. code_puppy/tools/ask_user_question/models.py +304 -0
  253. code_puppy/tools/ask_user_question/registration.py +36 -0
  254. code_puppy/tools/ask_user_question/renderers.py +309 -0
  255. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  256. code_puppy/tools/ask_user_question/theme.py +155 -0
  257. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  258. code_puppy/tools/browser/__init__.py +37 -0
  259. code_puppy/tools/browser/browser_control.py +289 -0
  260. code_puppy/tools/browser/browser_interactions.py +545 -0
  261. code_puppy/tools/browser/browser_locators.py +640 -0
  262. code_puppy/tools/browser/browser_manager.py +378 -0
  263. code_puppy/tools/browser/browser_navigation.py +251 -0
  264. code_puppy/tools/browser/browser_screenshot.py +179 -0
  265. code_puppy/tools/browser/browser_scripts.py +462 -0
  266. code_puppy/tools/browser/browser_workflows.py +221 -0
  267. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  268. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  269. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  270. code_puppy/tools/browser/terminal_tools.py +525 -0
  271. code_puppy/tools/command_runner.py +1346 -0
  272. code_puppy/tools/common.py +1409 -0
  273. code_puppy/tools/display.py +84 -0
  274. code_puppy/tools/file_modifications.py +739 -0
  275. code_puppy/tools/file_operations.py +802 -0
  276. code_puppy/tools/scheduler_tools.py +412 -0
  277. code_puppy/tools/skills_tools.py +251 -0
  278. code_puppy/tools/subagent_context.py +158 -0
  279. code_puppy/tools/tools_content.py +51 -0
  280. code_puppy/tools/universal_constructor.py +889 -0
  281. code_puppy/uvx_detection.py +242 -0
  282. code_puppy/version_checker.py +82 -0
  283. newcode-0.1.1.data/data/code_puppy/models.json +130 -0
  284. newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
  285. newcode-0.1.1.dist-info/METADATA +154 -0
  286. newcode-0.1.1.dist-info/RECORD +289 -0
  287. newcode-0.1.1.dist-info/WHEEL +4 -0
  288. newcode-0.1.1.dist-info/entry_points.txt +3 -0
  289. newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
code_puppy/config.py ADDED
@@ -0,0 +1,1787 @@
1
+ import configparser
2
+ import datetime
3
+ import json
4
+ import os
5
+ import pathlib
6
+ from typing import Optional
7
+
8
+ from code_puppy.session_storage import save_session
9
+
10
+
11
+ def _get_xdg_dir(env_var: str, fallback: str) -> str:
12
+ """
13
+ Get directory for code_puppy files, defaulting to ~/.code_puppy.
14
+
15
+ XDG paths are only used when the corresponding environment variable
16
+ is explicitly set by the user. Otherwise, we use the legacy ~/.code_puppy
17
+ directory for all file types (config, data, cache, state).
18
+
19
+ Args:
20
+ env_var: XDG environment variable name (e.g., "XDG_CONFIG_HOME")
21
+ fallback: Fallback path relative to home (e.g., ".config") - unused unless XDG var is set
22
+
23
+ Returns:
24
+ Path to the directory for code_puppy files
25
+ """
26
+ # Use XDG directory ONLY if environment variable is explicitly set
27
+ xdg_base = os.getenv(env_var)
28
+ if xdg_base:
29
+ return os.path.join(xdg_base, "code_puppy")
30
+
31
+ # Default to legacy ~/.code_puppy for all file types
32
+ return os.path.join(os.path.expanduser("~"), ".code_puppy")
33
+
34
+
35
+ # XDG Base Directory paths
36
+ CONFIG_DIR = _get_xdg_dir("XDG_CONFIG_HOME", ".config")
37
+ DATA_DIR = _get_xdg_dir("XDG_DATA_HOME", ".local/share")
38
+ CACHE_DIR = _get_xdg_dir("XDG_CACHE_HOME", ".cache")
39
+ STATE_DIR = _get_xdg_dir("XDG_STATE_HOME", ".local/state")
40
+
41
+ # Configuration files (XDG_CONFIG_HOME)
42
+ # Note: "puppy.cfg" is a legacy filename kept for backward compatibility
43
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "puppy.cfg")
44
+ MCP_SERVERS_FILE = os.path.join(CONFIG_DIR, "mcp_servers.json")
45
+
46
+ # Data files (XDG_DATA_HOME)
47
+ MODELS_FILE = os.path.join(DATA_DIR, "models.json")
48
+ EXTRA_MODELS_FILE = os.path.join(DATA_DIR, "extra_models.json")
49
+ AGENTS_DIR = os.path.join(DATA_DIR, "agents")
50
+ SKILLS_DIR = os.path.join(DATA_DIR, "skills")
51
+ CONTEXTS_DIR = os.path.join(DATA_DIR, "contexts")
52
+ _DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
53
+
54
+ # OAuth plugin model files (XDG_DATA_HOME)
55
+ GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
56
+ CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
57
+ CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
58
+ ANTIGRAVITY_MODELS_FILE = os.path.join(DATA_DIR, "antigravity_models.json")
59
+
60
+ # Cache files (XDG_CACHE_HOME)
61
+ AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
62
+
63
+ # State files (XDG_STATE_HOME)
64
+ COMMAND_HISTORY_FILE = os.path.join(STATE_DIR, "command_history.txt")
65
+ DBOS_DATABASE_URL = os.environ.get(
66
+ "DBOS_SYSTEM_DATABASE_URL", f"sqlite:///{_DEFAULT_SQLITE_FILE}"
67
+ )
68
+ # DBOS enable switch is controlled solely via puppy.cfg using key 'enable_dbos'.
69
+ # Default: True (DBOS enabled) unless explicitly disabled.
70
+
71
+
72
+ def get_use_dbos() -> bool:
73
+ """Return True if DBOS should be used based on 'enable_dbos' (default True)."""
74
+ cfg_val = get_value("enable_dbos")
75
+ if cfg_val is None:
76
+ return True
77
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
78
+
79
+
80
+ def get_subagent_verbose() -> bool:
81
+ """Return True if sub-agent verbose output is enabled (default False).
82
+
83
+ When False (default), sub-agents produce quiet, sparse output suitable
84
+ for parallel execution. When True, sub-agents produce full verbose output
85
+ like the main agent (useful for debugging).
86
+ """
87
+ cfg_val = get_value("subagent_verbose")
88
+ if cfg_val is None:
89
+ return False
90
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
91
+
92
+
93
+ # Pack agents - the specialized sub-agents coordinated by Pack Leader
94
+ PACK_AGENT_NAMES = frozenset(
95
+ [
96
+ "pack-leader",
97
+ "bloodhound",
98
+ "husky",
99
+ "shepherd",
100
+ "terrier",
101
+ "watchdog",
102
+ "retriever",
103
+ ]
104
+ )
105
+
106
+ # Agents that require Universal Constructor to be enabled
107
+ UC_AGENT_NAMES = frozenset(["helios"])
108
+
109
+
110
+ def get_pack_agents_enabled() -> bool:
111
+ """Return True if pack agents are enabled (default False).
112
+
113
+ When False (default), pack agents (pack-leader, bloodhound, husky, shepherd,
114
+ terrier, watchdog, retriever) are hidden from `list_agents` tool and `/agents`
115
+ command. They cannot be invoked by other agents or selected by users.
116
+
117
+ When True, pack agents are available for use.
118
+ """
119
+ cfg_val = get_value("enable_pack_agents")
120
+ if cfg_val is None:
121
+ return False
122
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
123
+
124
+
125
+ def get_universal_constructor_enabled() -> bool:
126
+ """Return True if the Universal Constructor is enabled (default True).
127
+
128
+ The Universal Constructor allows agents to dynamically create, manage,
129
+ and execute custom tools at runtime. When enabled, agents can extend
130
+ their capabilities by writing Python code that becomes callable tools.
131
+
132
+ When False, the universal_constructor tool is not registered with agents.
133
+ """
134
+ cfg_val = get_value("enable_universal_constructor")
135
+ if cfg_val is None:
136
+ return True # Enabled by default
137
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
138
+
139
+
140
+ def set_universal_constructor_enabled(enabled: bool) -> None:
141
+ """Enable or disable the Universal Constructor.
142
+
143
+ Args:
144
+ enabled: True to enable, False to disable
145
+ """
146
+ set_value("enable_universal_constructor", "true" if enabled else "false")
147
+
148
+
149
+ def get_enable_streaming() -> bool:
150
+ """
151
+ Get the enable_streaming configuration value.
152
+ Controls whether streaming (SSE) is used for model responses.
153
+ Returns True if streaming is enabled, False otherwise.
154
+ Defaults to True.
155
+ """
156
+ val = get_value("enable_streaming")
157
+ if val is None:
158
+ return True # Default to True for better UX
159
+ return str(val).lower() in ("1", "true", "yes", "on")
160
+
161
+
162
+ DEFAULT_SECTION = "agent"
163
+ REQUIRED_KEYS = ["agent_name", "user_name"]
164
+
165
+ # Runtime-only autosave session ID (per-process)
166
+ _CURRENT_AUTOSAVE_ID: Optional[str] = None
167
+
168
+ # Session-local model name (initialized from file on first access, then cached)
169
+ _SESSION_MODEL: Optional[str] = None
170
+
171
+ # Cache containers for model validation and defaults
172
+ _model_validation_cache = {}
173
+ _default_model_cache = None
174
+ _default_vision_model_cache = None
175
+
176
+
177
+ def ensure_config_exists():
178
+ """
179
+ Ensure that XDG directories and config file exist, prompting if needed.
180
+ Migrates legacy "puppy" section and key names to new format.
181
+ Returns configparser.ConfigParser for reading.
182
+ """
183
+ # Create all XDG directories with 0700 permissions per XDG spec
184
+ for directory in [CONFIG_DIR, DATA_DIR, CACHE_DIR, STATE_DIR, SKILLS_DIR]:
185
+ if not os.path.exists(directory):
186
+ os.makedirs(directory, mode=0o700, exist_ok=True)
187
+ exists = os.path.isfile(CONFIG_FILE)
188
+ config = configparser.ConfigParser()
189
+ if exists:
190
+ config.read(CONFIG_FILE)
191
+
192
+ # Migrate legacy "puppy" section to "agent" if needed
193
+ if DEFAULT_SECTION not in config and "puppy" in config:
194
+ config[DEFAULT_SECTION] = {}
195
+ for k, v in config["puppy"].items():
196
+ config[DEFAULT_SECTION][k] = v
197
+ # Migrate legacy key names within the section
198
+ if DEFAULT_SECTION in config:
199
+ section = config[DEFAULT_SECTION]
200
+ if not section.get("agent_name") and section.get("puppy_name"):
201
+ section["agent_name"] = section["puppy_name"]
202
+ if not section.get("user_name") and section.get("owner_name"):
203
+ section["user_name"] = section["owner_name"]
204
+
205
+ missing = []
206
+ if DEFAULT_SECTION not in config:
207
+ config[DEFAULT_SECTION] = {}
208
+ for key in REQUIRED_KEYS:
209
+ if not config[DEFAULT_SECTION].get(key):
210
+ missing.append(key)
211
+ if missing:
212
+ # Note: Using sys.stdout here for initial setup before messaging system is available
213
+ import sys
214
+
215
+ sys.stdout.write("Let's configure your agent.\n")
216
+ sys.stdout.flush()
217
+ for key in missing:
218
+ if key == "agent_name":
219
+ val = input("Enter a name for the agent: ").strip()
220
+ elif key == "user_name":
221
+ val = input("Enter your name: ").strip()
222
+ else:
223
+ val = input(f"Enter {key}: ").strip()
224
+ config[DEFAULT_SECTION][key] = val
225
+
226
+ # Set default values for important config keys if they don't exist
227
+ if not config[DEFAULT_SECTION].get("auto_save_session"):
228
+ config[DEFAULT_SECTION]["auto_save_session"] = "true"
229
+
230
+ # Write the config if we made any changes
231
+ if missing or not exists:
232
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
233
+ config.write(f)
234
+ return config
235
+
236
+
237
+ def get_value(key: str):
238
+ config = configparser.ConfigParser()
239
+ config.read(CONFIG_FILE)
240
+ val = config.get(DEFAULT_SECTION, key, fallback=None)
241
+ # Fallback to legacy "puppy" section for unmigrated configs
242
+ if val is None and DEFAULT_SECTION != "puppy":
243
+ val = config.get("puppy", key, fallback=None)
244
+ return val
245
+
246
+
247
+ def get_agent_name():
248
+ return get_value("agent_name") or get_value("puppy_name") or "Agent"
249
+
250
+
251
+ def get_user_name():
252
+ return get_value("user_name") or get_value("owner_name") or "User"
253
+
254
+
255
+ # Backward compatibility aliases
256
+ get_puppy_name = get_agent_name
257
+ get_owner_name = get_user_name
258
+
259
+
260
+ # Legacy function removed - message history limit is no longer used
261
+ # Message history is now managed by token-based compaction system
262
+ # using get_protected_token_count() and get_summarization_threshold()
263
+
264
+
265
+ def get_allow_recursion() -> bool:
266
+ """
267
+ Get the allow_recursion configuration value.
268
+ Returns True if recursion is allowed, False otherwise.
269
+ """
270
+ val = get_value("allow_recursion")
271
+ if val is None:
272
+ return True # Default to False for safety
273
+ return str(val).lower() in ("1", "true", "yes", "on")
274
+
275
+
276
+ def get_model_context_length() -> int:
277
+ """
278
+ Get the context length for the currently configured model from models.json
279
+ """
280
+ try:
281
+ from code_puppy.model_factory import ModelFactory
282
+
283
+ model_configs = ModelFactory.load_config()
284
+ model_name = get_global_model_name()
285
+
286
+ # Get context length from model config
287
+ model_config = model_configs.get(model_name, {})
288
+ context_length = model_config.get("context_length", 128000) # Default value
289
+
290
+ return int(context_length)
291
+ except Exception:
292
+ # Fallback to default context length if anything goes wrong
293
+ return 128000
294
+
295
+
296
+ # --- CONFIG SETTER STARTS HERE ---
297
+ def get_config_keys():
298
+ """
299
+ Returns the list of all config keys currently in puppy.cfg,
300
+ plus certain preset expected keys (e.g. "yolo_mode", "model", "compaction_strategy", "message_limit", "allow_recursion").
301
+ """
302
+ default_keys = [
303
+ "yolo_mode",
304
+ "model",
305
+ "compaction_strategy",
306
+ "protected_token_count",
307
+ "compaction_threshold",
308
+ "message_limit",
309
+ "allow_recursion",
310
+ "openai_reasoning_effort",
311
+ "openai_verbosity",
312
+ "auto_save_session",
313
+ "max_saved_sessions",
314
+ "http2",
315
+ "diff_context_lines",
316
+ "default_agent",
317
+ "temperature",
318
+ "frontend_emitter_enabled",
319
+ "frontend_emitter_max_recent_events",
320
+ "frontend_emitter_queue_size",
321
+ ]
322
+ # Add DBOS control key
323
+ default_keys.append("enable_dbos")
324
+ # Add pack agents control key
325
+ default_keys.append("enable_pack_agents")
326
+ # Add universal constructor control key
327
+ default_keys.append("enable_universal_constructor")
328
+ # Add streaming control key
329
+ default_keys.append("enable_streaming")
330
+ # Add cancel agent key configuration
331
+ default_keys.append("cancel_agent_key")
332
+ # Add banner color keys
333
+ for banner_name in DEFAULT_BANNER_COLORS:
334
+ default_keys.append(f"banner_color_{banner_name}")
335
+ # Add resume message count configuration
336
+ default_keys.append("resume_message_count")
337
+
338
+ config = configparser.ConfigParser()
339
+ config.read(CONFIG_FILE)
340
+ keys = set(config[DEFAULT_SECTION].keys()) if DEFAULT_SECTION in config else set()
341
+ keys.update(default_keys)
342
+ return sorted(keys)
343
+
344
+
345
+ def set_config_value(key: str, value: str):
346
+ """
347
+ Sets a config value in the persistent config file.
348
+ """
349
+ config = configparser.ConfigParser()
350
+ config.read(CONFIG_FILE)
351
+ if DEFAULT_SECTION not in config:
352
+ config[DEFAULT_SECTION] = {}
353
+ config[DEFAULT_SECTION][key] = value
354
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
355
+ config.write(f)
356
+
357
+
358
+ # Alias for API compatibility
359
+ def set_value(key: str, value: str) -> None:
360
+ """Set a config value. Alias for set_config_value."""
361
+ set_config_value(key, value)
362
+
363
+
364
+ def reset_value(key: str) -> None:
365
+ """Remove a key from the config file, resetting it to default."""
366
+ config = configparser.ConfigParser()
367
+ config.read(CONFIG_FILE)
368
+ if DEFAULT_SECTION in config and key in config[DEFAULT_SECTION]:
369
+ del config[DEFAULT_SECTION][key]
370
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
371
+ config.write(f)
372
+
373
+
374
+ # --- MODEL STICKY EXTENSION STARTS HERE ---
375
+ def load_mcp_server_configs():
376
+ """
377
+ Loads the MCP server configurations from XDG_CONFIG_HOME/code_puppy/mcp_servers.json.
378
+ Returns a dict mapping names to their URL or config dict.
379
+ If file does not exist, returns an empty dict.
380
+ """
381
+ from code_puppy.messaging.message_queue import emit_error
382
+
383
+ try:
384
+ if not pathlib.Path(MCP_SERVERS_FILE).exists():
385
+ return {}
386
+ with open(MCP_SERVERS_FILE, "r", encoding="utf-8") as f:
387
+ conf = json.loads(f.read())
388
+ return conf["mcp_servers"]
389
+ except Exception as e:
390
+ emit_error(f"Failed to load MCP servers - {str(e)}")
391
+ return {}
392
+
393
+
394
+ def _default_model_from_models_json():
395
+ """Load the default model name from models.json.
396
+
397
+ Returns the first model in models.json as the default.
398
+ Falls back to ``gpt-5`` if the file cannot be read.
399
+ """
400
+ global _default_model_cache
401
+
402
+ if _default_model_cache is not None:
403
+ return _default_model_cache
404
+
405
+ try:
406
+ from code_puppy.model_factory import ModelFactory
407
+
408
+ models_config = ModelFactory.load_config()
409
+ if models_config:
410
+ # Use first model in models.json as default
411
+ first_key = next(iter(models_config))
412
+ _default_model_cache = first_key
413
+ return first_key
414
+ _default_model_cache = "gpt-5"
415
+ return "gpt-5"
416
+ except Exception:
417
+ _default_model_cache = "gpt-5"
418
+ return "gpt-5"
419
+
420
+
421
+ def _default_vision_model_from_models_json() -> str:
422
+ """Select a default vision-capable model from models.json with caching."""
423
+ global _default_vision_model_cache
424
+
425
+ if _default_vision_model_cache is not None:
426
+ return _default_vision_model_cache
427
+
428
+ try:
429
+ from code_puppy.model_factory import ModelFactory
430
+
431
+ models_config = ModelFactory.load_config()
432
+ if models_config:
433
+ # Prefer explicitly tagged vision models
434
+ for name, config in models_config.items():
435
+ if config.get("supports_vision"):
436
+ _default_vision_model_cache = name
437
+ return name
438
+
439
+ # Fallback heuristic: common multimodal models
440
+ preferred_candidates = (
441
+ "gpt-4.1",
442
+ "gpt-4.1-mini",
443
+ "gpt-4.1-nano",
444
+ "claude-4-0-sonnet",
445
+ "gemini-2.5-flash-preview-05-20",
446
+ )
447
+ for candidate in preferred_candidates:
448
+ if candidate in models_config:
449
+ _default_vision_model_cache = candidate
450
+ return candidate
451
+
452
+ # Last resort: use the general default model
453
+ _default_vision_model_cache = _default_model_from_models_json()
454
+ return _default_vision_model_cache
455
+
456
+ _default_vision_model_cache = "gpt-4.1"
457
+ return "gpt-4.1"
458
+ except Exception:
459
+ _default_vision_model_cache = "gpt-4.1"
460
+ return "gpt-4.1"
461
+
462
+
463
+ def _validate_model_exists(model_name: str) -> bool:
464
+ """Check if a model exists in models.json with caching to avoid redundant calls."""
465
+ global _model_validation_cache
466
+
467
+ # Check cache first
468
+ if model_name in _model_validation_cache:
469
+ return _model_validation_cache[model_name]
470
+
471
+ try:
472
+ from code_puppy.model_factory import ModelFactory
473
+
474
+ models_config = ModelFactory.load_config()
475
+ exists = model_name in models_config
476
+
477
+ # Cache the result
478
+ _model_validation_cache[model_name] = exists
479
+ return exists
480
+ except Exception:
481
+ # If we can't validate, assume it exists to avoid breaking things
482
+ _model_validation_cache[model_name] = True
483
+ return True
484
+
485
+
486
+ def clear_model_cache():
487
+ """Clear the model validation cache. Call this when models.json changes."""
488
+ global _model_validation_cache, _default_model_cache, _default_vision_model_cache
489
+ _model_validation_cache.clear()
490
+ _default_model_cache = None
491
+ _default_vision_model_cache = None
492
+
493
+
494
+ def reset_session_model():
495
+ """Reset the session-local model cache.
496
+
497
+ This is primarily for testing purposes. In normal operation, the session
498
+ model is set once at startup and only changes via set_model_name().
499
+ """
500
+ global _SESSION_MODEL
501
+ _SESSION_MODEL = None
502
+
503
+
504
+ def model_supports_setting(model_name: str, setting: str) -> bool:
505
+ """Check if a model supports a particular setting (e.g., 'temperature', 'seed').
506
+
507
+ Args:
508
+ model_name: The name of the model to check.
509
+ setting: The setting name to check for (e.g., 'temperature', 'seed', 'top_p').
510
+
511
+ Returns:
512
+ True if the model supports the setting, False otherwise.
513
+ Defaults to True for backwards compatibility if model config doesn't specify.
514
+ """
515
+ # GLM-4.7 and GLM-5 models always support clear_thinking setting
516
+ if setting == "clear_thinking" and (
517
+ "glm-4.7" in model_name.lower() or "glm-5" in model_name.lower()
518
+ ):
519
+ return True
520
+
521
+ try:
522
+ from code_puppy.model_factory import ModelFactory
523
+
524
+ models_config = ModelFactory.load_config()
525
+ model_config = models_config.get(model_name, {})
526
+
527
+ # Get supported_settings list, default to supporting common settings
528
+ supported_settings = model_config.get("supported_settings")
529
+
530
+ if supported_settings is None:
531
+ # Default: assume common settings are supported for backwards compatibility
532
+ # For Anthropic/Claude models, include extended thinking settings
533
+ if model_name.startswith("claude-") or model_name.startswith("anthropic-"):
534
+ base = ["temperature", "extended_thinking", "budget_tokens"]
535
+ # Opus 4-6 models also support the effort setting
536
+ lower = model_name.lower()
537
+ if "opus-4-6" in lower or "4-6-opus" in lower:
538
+ base.append("effort")
539
+ return setting in base
540
+ return setting in ["temperature", "seed"]
541
+
542
+ return setting in supported_settings
543
+ except Exception:
544
+ # If we can't check, assume supported for safety
545
+ return True
546
+
547
+
548
+ def get_global_model_name():
549
+ """Return a valid model name for the application to use.
550
+
551
+ Uses session-local caching so that model changes in other terminals
552
+ don't affect this running instance. The file is only read once at startup.
553
+
554
+ 1. If _SESSION_MODEL is set, return it (session cache)
555
+ 2. Otherwise, look at ``model`` in *puppy.cfg*
556
+ 3. If that value exists **and** is present in *models.json*, use it
557
+ 4. Otherwise return the first model listed in *models.json*
558
+ 5. As a last resort fall back to ``claude-4-0-sonnet``
559
+
560
+ The result is cached in _SESSION_MODEL for subsequent calls.
561
+ """
562
+ global _SESSION_MODEL
563
+
564
+ # Return cached session model if already initialized
565
+ if _SESSION_MODEL is not None:
566
+ return _SESSION_MODEL
567
+
568
+ # First access - initialize from file
569
+ stored_model = get_value("model")
570
+
571
+ if stored_model:
572
+ # Use cached validation to avoid hitting ModelFactory every time
573
+ if _validate_model_exists(stored_model):
574
+ _SESSION_MODEL = stored_model
575
+ return _SESSION_MODEL
576
+
577
+ # Either no stored model or it's not valid – choose default from models.json
578
+ _SESSION_MODEL = _default_model_from_models_json()
579
+ return _SESSION_MODEL
580
+
581
+
582
+ def set_model_name(model: str):
583
+ """Sets the model name in both the session cache and persistent config file.
584
+
585
+ Updates _SESSION_MODEL immediately for this process, and writes to the
586
+ config file so new terminals will pick up this model as their default.
587
+ """
588
+ global _SESSION_MODEL
589
+
590
+ # Update session cache immediately
591
+ _SESSION_MODEL = model
592
+
593
+ # Also persist to file for new terminal sessions
594
+ config = configparser.ConfigParser()
595
+ config.read(CONFIG_FILE)
596
+ if DEFAULT_SECTION not in config:
597
+ config[DEFAULT_SECTION] = {}
598
+ config[DEFAULT_SECTION]["model"] = model or ""
599
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
600
+ config.write(f)
601
+
602
+ # Clear model cache when switching models to ensure fresh validation
603
+ clear_model_cache()
604
+
605
+
606
+ def get_puppy_token():
607
+ """Returns the puppy_token from config, or None if not set."""
608
+ return get_value("puppy_token")
609
+
610
+
611
+ def set_puppy_token(token: str):
612
+ """Sets the puppy_token in the persistent config file."""
613
+ set_config_value("puppy_token", token)
614
+
615
+
616
+ def get_openai_reasoning_effort() -> str:
617
+ """Return the configured OpenAI reasoning effort (minimal, low, medium, high, xhigh)."""
618
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
619
+ configured = (get_value("openai_reasoning_effort") or "medium").strip().lower()
620
+ if configured not in allowed_values:
621
+ return "medium"
622
+ return configured
623
+
624
+
625
+ def set_openai_reasoning_effort(value: str) -> None:
626
+ """Persist the OpenAI reasoning effort ensuring it remains within allowed values."""
627
+ allowed_values = {"minimal", "low", "medium", "high", "xhigh"}
628
+ normalized = (value or "").strip().lower()
629
+ if normalized not in allowed_values:
630
+ raise ValueError(
631
+ f"Invalid reasoning effort '{value}'. Allowed: {', '.join(sorted(allowed_values))}"
632
+ )
633
+ set_config_value("openai_reasoning_effort", normalized)
634
+
635
+
636
+ def get_openai_verbosity() -> str:
637
+ """Return the configured OpenAI verbosity (low, medium, high).
638
+
639
+ Controls how concise vs. verbose the model's responses are:
640
+ - low: more concise responses
641
+ - medium: balanced (default)
642
+ - high: more verbose responses
643
+ """
644
+ allowed_values = {"low", "medium", "high"}
645
+ configured = (get_value("openai_verbosity") or "medium").strip().lower()
646
+ if configured not in allowed_values:
647
+ return "medium"
648
+ return configured
649
+
650
+
651
+ def set_openai_verbosity(value: str) -> None:
652
+ """Persist the OpenAI verbosity ensuring it remains within allowed values."""
653
+ allowed_values = {"low", "medium", "high"}
654
+ normalized = (value or "").strip().lower()
655
+ if normalized not in allowed_values:
656
+ raise ValueError(
657
+ f"Invalid verbosity '{value}'. Allowed: {', '.join(sorted(allowed_values))}"
658
+ )
659
+ set_config_value("openai_verbosity", normalized)
660
+
661
+
662
+ def get_temperature() -> Optional[float]:
663
+ """Return the configured model temperature (0.0 to 2.0).
664
+
665
+ Returns:
666
+ Float between 0.0 and 2.0 if set, None if not configured.
667
+ This allows each model to use its own default when not overridden.
668
+ """
669
+ val = get_value("temperature")
670
+ if val is None or val.strip() == "":
671
+ return None
672
+ try:
673
+ temp = float(val)
674
+ # Clamp to valid range (most APIs accept 0-2)
675
+ return max(0.0, min(2.0, temp))
676
+ except (ValueError, TypeError):
677
+ return None
678
+
679
+
680
+ def set_temperature(value: Optional[float]) -> None:
681
+ """Set the global model temperature in config.
682
+
683
+ Args:
684
+ value: Temperature between 0.0 and 2.0, or None to clear.
685
+ Lower values = more deterministic, higher = more creative.
686
+
687
+ Note: Consider using set_model_setting() for per-model temperature.
688
+ """
689
+ if value is None:
690
+ set_config_value("temperature", "")
691
+ else:
692
+ # Validate and clamp
693
+ temp = max(0.0, min(2.0, float(value)))
694
+ set_config_value("temperature", str(temp))
695
+
696
+
697
+ # --- PER-MODEL SETTINGS ---
698
+
699
+
700
+ def _sanitize_model_name_for_key(model_name: str) -> str:
701
+ """Sanitize model name for use in config keys.
702
+
703
+ Replaces characters that might cause issues in config keys.
704
+ """
705
+ # Replace problematic characters with underscores
706
+ sanitized = model_name.replace(".", "_").replace("-", "_").replace("/", "_")
707
+ return sanitized.lower()
708
+
709
+
710
+ def get_model_setting(
711
+ model_name: str, setting: str, default: Optional[float] = None
712
+ ) -> Optional[float]:
713
+ """Get a specific setting for a model.
714
+
715
+ Args:
716
+ model_name: The model name (e.g., 'gpt-5', 'claude-4-5-sonnet')
717
+ setting: The setting name (e.g., 'temperature', 'top_p', 'seed')
718
+ default: Default value if not set
719
+
720
+ Returns:
721
+ The setting value as a float, or default if not set.
722
+ """
723
+ sanitized_name = _sanitize_model_name_for_key(model_name)
724
+ key = f"model_settings_{sanitized_name}_{setting}"
725
+ val = get_value(key)
726
+
727
+ if val is None or val.strip() == "":
728
+ return default
729
+
730
+ try:
731
+ return float(val)
732
+ except (ValueError, TypeError):
733
+ return default
734
+
735
+
736
+ def set_model_setting(model_name: str, setting: str, value: Optional[float]) -> None:
737
+ """Set a specific setting for a model.
738
+
739
+ Args:
740
+ model_name: The model name (e.g., 'gpt-5', 'claude-4-5-sonnet')
741
+ setting: The setting name (e.g., 'temperature', 'seed')
742
+ value: The value to set, or None to clear
743
+ """
744
+ sanitized_name = _sanitize_model_name_for_key(model_name)
745
+ key = f"model_settings_{sanitized_name}_{setting}"
746
+
747
+ if value is None:
748
+ set_config_value(key, "")
749
+ elif isinstance(value, float):
750
+ # Round floats to nearest hundredth to avoid floating point weirdness
751
+ # (allows 0.05 step increments for temperature/top_p)
752
+ set_config_value(key, str(round(value, 2)))
753
+ else:
754
+ set_config_value(key, str(value))
755
+
756
+
757
+ def get_all_model_settings(model_name: str) -> dict:
758
+ """Get all settings for a specific model.
759
+
760
+ Args:
761
+ model_name: The model name
762
+
763
+ Returns:
764
+ Dictionary of setting_name -> value for all configured settings.
765
+ """
766
+ import configparser
767
+
768
+ sanitized_name = _sanitize_model_name_for_key(model_name)
769
+ prefix = f"model_settings_{sanitized_name}_"
770
+
771
+ config = configparser.ConfigParser()
772
+ config.read(CONFIG_FILE)
773
+
774
+ settings = {}
775
+ if DEFAULT_SECTION in config:
776
+ for key, val in config[DEFAULT_SECTION].items():
777
+ if key.startswith(prefix) and val.strip():
778
+ setting_name = key[len(prefix) :]
779
+ # Handle different value types
780
+ val_stripped = val.strip()
781
+ # Check for boolean values first
782
+ if val_stripped.lower() in ("true", "false"):
783
+ settings[setting_name] = val_stripped.lower() == "true"
784
+ else:
785
+ # Try to parse as number (int first, then float)
786
+ try:
787
+ # Try int first for cleaner values like budget_tokens
788
+ if "." not in val_stripped:
789
+ settings[setting_name] = int(val_stripped)
790
+ else:
791
+ settings[setting_name] = float(val_stripped)
792
+ except (ValueError, TypeError):
793
+ # Keep as string if not a number
794
+ settings[setting_name] = val_stripped
795
+
796
+ return settings
797
+
798
+
799
+ def clear_model_settings(model_name: str) -> None:
800
+ """Clear all settings for a specific model.
801
+
802
+ Args:
803
+ model_name: The model name
804
+ """
805
+ import configparser
806
+
807
+ sanitized_name = _sanitize_model_name_for_key(model_name)
808
+ prefix = f"model_settings_{sanitized_name}_"
809
+
810
+ config = configparser.ConfigParser()
811
+ config.read(CONFIG_FILE)
812
+
813
+ if DEFAULT_SECTION in config:
814
+ keys_to_remove = [
815
+ key for key in config[DEFAULT_SECTION] if key.startswith(prefix)
816
+ ]
817
+ for key in keys_to_remove:
818
+ del config[DEFAULT_SECTION][key]
819
+
820
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
821
+ config.write(f)
822
+
823
+
824
+ def get_effective_model_settings(model_name: Optional[str] = None) -> dict:
825
+ """Get all effective settings for a model, filtered by what the model supports.
826
+
827
+ This is the generalized way to get model settings. It:
828
+ 1. Gets all per-model settings from config
829
+ 2. Falls back to global temperature if not set per-model
830
+ 3. Filters to only include settings the model actually supports
831
+ 4. Converts seed to int (other settings stay as float)
832
+
833
+ Args:
834
+ model_name: The model name. If None, uses the current global model.
835
+
836
+ Returns:
837
+ Dictionary of setting_name -> value for all applicable settings.
838
+ Ready to be unpacked into ModelSettings.
839
+ """
840
+ if model_name is None:
841
+ model_name = get_global_model_name()
842
+
843
+ # Start with all per-model settings
844
+ settings = get_all_model_settings(model_name)
845
+
846
+ # Fall back to global temperature if not set per-model
847
+ if "temperature" not in settings:
848
+ global_temp = get_temperature()
849
+ if global_temp is not None:
850
+ settings["temperature"] = global_temp
851
+
852
+ # Filter to only settings the model supports
853
+ effective_settings = {}
854
+ for setting_name, value in settings.items():
855
+ if model_supports_setting(model_name, setting_name):
856
+ # Convert seed to int, keep others as float
857
+ if setting_name == "seed" and value is not None:
858
+ effective_settings[setting_name] = int(value)
859
+ else:
860
+ effective_settings[setting_name] = value
861
+
862
+ return effective_settings
863
+
864
+
865
+ # Legacy functions for backward compatibility
866
+ def get_effective_temperature(model_name: Optional[str] = None) -> Optional[float]:
867
+ """Get the effective temperature for a model.
868
+
869
+ Checks per-model settings first, then falls back to global temperature.
870
+
871
+ Args:
872
+ model_name: The model name. If None, uses the current global model.
873
+
874
+ Returns:
875
+ Temperature value, or None if not configured.
876
+ """
877
+ settings = get_effective_model_settings(model_name)
878
+ return settings.get("temperature")
879
+
880
+
881
+ def get_effective_top_p(model_name: Optional[str] = None) -> Optional[float]:
882
+ """Get the effective top_p for a model.
883
+
884
+ Args:
885
+ model_name: The model name. If None, uses the current global model.
886
+
887
+ Returns:
888
+ top_p value, or None if not configured.
889
+ """
890
+ settings = get_effective_model_settings(model_name)
891
+ return settings.get("top_p")
892
+
893
+
894
+ def get_effective_seed(model_name: Optional[str] = None) -> Optional[int]:
895
+ """Get the effective seed for a model.
896
+
897
+ Args:
898
+ model_name: The model name. If None, uses the current global model.
899
+
900
+ Returns:
901
+ seed value as int, or None if not configured.
902
+ """
903
+ settings = get_effective_model_settings(model_name)
904
+ return settings.get("seed")
905
+
906
+
907
+ def normalize_command_history():
908
+ """
909
+ Normalize the command history file by converting old format timestamps to the new format.
910
+
911
+ Old format example:
912
+ - "# 2025-08-04 12:44:45.469829"
913
+
914
+ New format example:
915
+ - "# 2025-08-05T10:35:33" (ISO)
916
+ """
917
+ import os
918
+ import re
919
+
920
+ # Skip implementation during tests
921
+ import sys
922
+
923
+ if "pytest" in sys.modules:
924
+ return
925
+
926
+ # Skip normalization if file doesn't exist
927
+ command_history_exists = os.path.isfile(COMMAND_HISTORY_FILE)
928
+ if not command_history_exists:
929
+ return
930
+
931
+ try:
932
+ # Read the entire file with encoding error handling for Windows
933
+ with open(
934
+ COMMAND_HISTORY_FILE, "r", encoding="utf-8", errors="surrogateescape"
935
+ ) as f:
936
+ content = f.read()
937
+
938
+ # Sanitize any surrogate characters that might have slipped in
939
+ try:
940
+ content = content.encode("utf-8", errors="surrogatepass").decode(
941
+ "utf-8", errors="replace"
942
+ )
943
+ except (UnicodeEncodeError, UnicodeDecodeError):
944
+ pass # Keep original if sanitization fails
945
+
946
+ # Skip empty files
947
+ if not content.strip():
948
+ return
949
+
950
+ # Define regex pattern for old timestamp format
951
+ # Format: "# YYYY-MM-DD HH:MM:SS.ffffff"
952
+ old_timestamp_pattern = r"# (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\.(\d+)"
953
+
954
+ # Function to convert matched timestamp to ISO format
955
+ def convert_to_iso(match):
956
+ date = match.group(1)
957
+ time = match.group(2)
958
+ # Create ISO format (YYYY-MM-DDThh:mm:ss)
959
+ return f"# {date}T{time}"
960
+
961
+ # Replace all occurrences of the old timestamp format with the new ISO format
962
+ updated_content = re.sub(old_timestamp_pattern, convert_to_iso, content)
963
+
964
+ # Write the updated content back to the file only if changes were made
965
+ if content != updated_content:
966
+ import tempfile
967
+
968
+ fd, tmp_path = tempfile.mkstemp(
969
+ dir=os.path.dirname(COMMAND_HISTORY_FILE), suffix=".tmp"
970
+ )
971
+ try:
972
+ with os.fdopen(
973
+ fd, "w", encoding="utf-8", errors="surrogateescape"
974
+ ) as f:
975
+ f.write(updated_content)
976
+ os.replace(tmp_path, COMMAND_HISTORY_FILE)
977
+ except BaseException:
978
+ try:
979
+ os.unlink(tmp_path)
980
+ except OSError:
981
+ pass
982
+ raise
983
+ except Exception as e:
984
+ from code_puppy.messaging import emit_error
985
+
986
+ emit_error(
987
+ f"An unexpected error occurred while normalizing command history: {str(e)}"
988
+ )
989
+
990
+
991
+ def get_user_agents_directory() -> str:
992
+ """Get the user's agents directory path.
993
+
994
+ Returns:
995
+ Path to the user's agents directory.
996
+ """
997
+ # Ensure the agents directory exists
998
+ os.makedirs(AGENTS_DIR, exist_ok=True)
999
+ return AGENTS_DIR
1000
+
1001
+
1002
+ def get_project_agents_directory() -> Optional[str]:
1003
+ """Get the project-local agents directory path.
1004
+
1005
+ Looks for a .code_puppy/agents/ directory in the current working directory.
1006
+ Unlike get_user_agents_directory(), this does NOT create the directory
1007
+ if it doesn't exist -- the team must create it intentionally.
1008
+
1009
+ Returns:
1010
+ Path to the project's agents directory if it exists, or None.
1011
+ """
1012
+ project_agents_dir = os.path.join(os.getcwd(), ".code_puppy", "agents")
1013
+ if os.path.isdir(project_agents_dir):
1014
+ return project_agents_dir
1015
+ return None
1016
+
1017
+
1018
+ def initialize_command_history_file():
1019
+ """Create the command history file if it doesn't exist.
1020
+ Handles migration from the old history file location for backward compatibility.
1021
+ Also normalizes the command history format if needed.
1022
+ """
1023
+ import os
1024
+ from pathlib import Path
1025
+
1026
+ # Ensure the state directory exists before trying to create the history file
1027
+ if not os.path.exists(STATE_DIR):
1028
+ os.makedirs(STATE_DIR, exist_ok=True)
1029
+
1030
+ command_history_exists = os.path.isfile(COMMAND_HISTORY_FILE)
1031
+ if not command_history_exists:
1032
+ try:
1033
+ Path(COMMAND_HISTORY_FILE).touch()
1034
+
1035
+ # For backwards compatibility, copy the old history file, then remove it
1036
+ old_history_file = os.path.join(
1037
+ os.path.expanduser("~"), ".code_puppy_history.txt"
1038
+ )
1039
+ old_history_exists = os.path.isfile(old_history_file)
1040
+ if old_history_exists:
1041
+ import shutil
1042
+
1043
+ shutil.copy2(Path(old_history_file), Path(COMMAND_HISTORY_FILE))
1044
+ Path(old_history_file).unlink(missing_ok=True)
1045
+
1046
+ # Normalize the command history format if needed
1047
+ normalize_command_history()
1048
+ except Exception as e:
1049
+ from code_puppy.messaging import emit_error
1050
+
1051
+ emit_error(
1052
+ f"An unexpected error occurred while trying to initialize history file: {str(e)}"
1053
+ )
1054
+
1055
+
1056
+ def get_yolo_mode():
1057
+ """
1058
+ Checks puppy.cfg for 'yolo_mode' (case-insensitive in value only).
1059
+ Defaults to True if not set.
1060
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1061
+ """
1062
+ true_vals = {"1", "true", "yes", "on"}
1063
+ cfg_val = get_value("yolo_mode")
1064
+ if cfg_val is not None:
1065
+ if str(cfg_val).strip().lower() in true_vals:
1066
+ return True
1067
+ return False
1068
+ return True
1069
+
1070
+
1071
+ def get_safety_permission_level():
1072
+ """
1073
+ Checks puppy.cfg for 'safety_permission_level' (case-insensitive in value only).
1074
+ Defaults to 'medium' if not set.
1075
+ Allowed values: 'none', 'low', 'medium', 'high', 'critical' (all case-insensitive for value).
1076
+ Returns the normalized lowercase string.
1077
+ """
1078
+ valid_levels = {"none", "low", "medium", "high", "critical"}
1079
+ cfg_val = get_value("safety_permission_level")
1080
+ if cfg_val is not None:
1081
+ normalized = str(cfg_val).strip().lower()
1082
+ if normalized in valid_levels:
1083
+ return normalized
1084
+ return "medium" # Default to medium risk threshold
1085
+
1086
+
1087
+ def get_mcp_disabled():
1088
+ """
1089
+ Checks puppy.cfg for 'disable_mcp' (case-insensitive in value only).
1090
+ Defaults to False if not set.
1091
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1092
+ When enabled, the agent will skip loading MCP servers entirely.
1093
+ """
1094
+ true_vals = {"1", "true", "yes", "on"}
1095
+ cfg_val = get_value("disable_mcp")
1096
+ if cfg_val is not None:
1097
+ if str(cfg_val).strip().lower() in true_vals:
1098
+ return True
1099
+ return False
1100
+ return False
1101
+
1102
+
1103
+ def get_grep_output_verbose():
1104
+ """
1105
+ Checks puppy.cfg for 'grep_output_verbose' (case-insensitive in value only).
1106
+ Defaults to False (concise output) if not set.
1107
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1108
+
1109
+ When False (default): Shows only file names with match counts
1110
+ When True: Shows full output with line numbers and content
1111
+ """
1112
+ true_vals = {"1", "true", "yes", "on"}
1113
+ cfg_val = get_value("grep_output_verbose")
1114
+ if cfg_val is not None:
1115
+ if str(cfg_val).strip().lower() in true_vals:
1116
+ return True
1117
+ return False
1118
+ return False
1119
+
1120
+
1121
+ def get_protected_token_count():
1122
+ """
1123
+ Returns the user-configured protected token count for message history compaction.
1124
+ This is the number of tokens in recent messages that won't be summarized.
1125
+ Defaults to 50000 if unset or misconfigured.
1126
+ Configurable by 'protected_token_count' key.
1127
+ Enforces that protected tokens don't exceed 75% of model context length.
1128
+ """
1129
+ val = get_value("protected_token_count")
1130
+ try:
1131
+ # Get the model context length to enforce the 75% limit
1132
+ model_context_length = get_model_context_length()
1133
+ max_protected_tokens = int(model_context_length * 0.75)
1134
+
1135
+ # Parse the configured value
1136
+ configured_value = int(val) if val else 50000
1137
+
1138
+ # Apply constraints: minimum 1000, maximum 75% of context length
1139
+ return max(1000, min(configured_value, max_protected_tokens))
1140
+ except (ValueError, TypeError):
1141
+ # If parsing fails, return a reasonable default that respects the 75% limit
1142
+ model_context_length = get_model_context_length()
1143
+ max_protected_tokens = int(model_context_length * 0.75)
1144
+ return min(50000, max_protected_tokens)
1145
+
1146
+
1147
+ def get_resume_message_count() -> int:
1148
+ """
1149
+ Returns the number of messages to display when resuming a session.
1150
+ Defaults to 50 if unset or misconfigured.
1151
+ Configurable by 'resume_message_count' key via /set command.
1152
+
1153
+ Example: /set resume_message_count=30
1154
+ """
1155
+ val = get_value("resume_message_count")
1156
+ try:
1157
+ configured_value = int(val) if val else 50
1158
+ # Enforce reasonable bounds: minimum 1, maximum 100
1159
+ return max(1, min(configured_value, 100))
1160
+ except (ValueError, TypeError):
1161
+ return 50
1162
+
1163
+
1164
+ def get_compaction_threshold():
1165
+ """
1166
+ Returns the user-configured compaction threshold as a float between 0.0 and 1.0.
1167
+ This is the proportion of model context that triggers compaction.
1168
+ Defaults to 0.85 (85%) if unset or misconfigured.
1169
+ Configurable by 'compaction_threshold' key.
1170
+ """
1171
+ val = get_value("compaction_threshold")
1172
+ try:
1173
+ threshold = float(val) if val else 0.85
1174
+ # Clamp between reasonable bounds
1175
+ return max(0.5, min(0.95, threshold))
1176
+ except (ValueError, TypeError):
1177
+ return 0.85
1178
+
1179
+
1180
+ def get_compaction_strategy() -> str:
1181
+ """
1182
+ Returns the user-configured compaction strategy.
1183
+ Options are 'summarization' or 'truncation'.
1184
+ Defaults to 'summarization' if not set or misconfigured.
1185
+ Configurable by 'compaction_strategy' key.
1186
+ """
1187
+ val = get_value("compaction_strategy")
1188
+ if val and val.lower() in ["summarization", "truncation"]:
1189
+ return val.lower()
1190
+ # Default to summarization
1191
+ return "truncation"
1192
+
1193
+
1194
+ def get_http2() -> bool:
1195
+ """
1196
+ Get the http2 configuration value.
1197
+ Returns False if not set (default).
1198
+ """
1199
+ val = get_value("http2")
1200
+ if val is None:
1201
+ return False
1202
+ return str(val).lower() in ("1", "true", "yes", "on")
1203
+
1204
+
1205
+ def set_http2(enabled: bool) -> None:
1206
+ """
1207
+ Sets the http2 configuration value.
1208
+
1209
+ Args:
1210
+ enabled: Whether to enable HTTP/2 for httpx clients
1211
+ """
1212
+ set_config_value("http2", "true" if enabled else "false")
1213
+
1214
+
1215
+ def set_enable_dbos(enabled: bool) -> None:
1216
+ """Enable DBOS via config (true enables, default false)."""
1217
+ set_config_value("enable_dbos", "true" if enabled else "false")
1218
+
1219
+
1220
+ def get_message_limit(default: int = 1000) -> int:
1221
+ """
1222
+ Returns the user-configured message/request limit for the agent.
1223
+ This controls how many steps/requests the agent can take.
1224
+ Defaults to 1000 if unset or misconfigured.
1225
+ Configurable by 'message_limit' key.
1226
+ """
1227
+ val = get_value("message_limit")
1228
+ try:
1229
+ return int(val) if val else default
1230
+ except (ValueError, TypeError):
1231
+ return default
1232
+
1233
+
1234
+ def save_command_to_history(command: str):
1235
+ """Save a command to the history file with an ISO format timestamp.
1236
+
1237
+ Args:
1238
+ command: The command to save
1239
+ """
1240
+ import datetime
1241
+
1242
+ try:
1243
+ timestamp = datetime.datetime.now().isoformat(timespec="seconds")
1244
+
1245
+ # Sanitize command to remove any invalid surrogate characters
1246
+ # that could cause encoding errors on Windows
1247
+ try:
1248
+ command = command.encode("utf-8", errors="surrogatepass").decode(
1249
+ "utf-8", errors="replace"
1250
+ )
1251
+ except (UnicodeEncodeError, UnicodeDecodeError):
1252
+ # If that fails, do a more aggressive cleanup
1253
+ command = "".join(
1254
+ char if ord(char) < 0xD800 or ord(char) > 0xDFFF else "\ufffd"
1255
+ for char in command
1256
+ )
1257
+
1258
+ with open(
1259
+ COMMAND_HISTORY_FILE, "a", encoding="utf-8", errors="surrogateescape"
1260
+ ) as f:
1261
+ f.write(f"\n# {timestamp}\n{command}\n")
1262
+ except Exception as e:
1263
+ from code_puppy.messaging import emit_error
1264
+
1265
+ emit_error(
1266
+ f"An unexpected error occurred while saving command history: {str(e)}"
1267
+ )
1268
+
1269
+
1270
+ def get_agent_pinned_model(agent_name: str) -> str:
1271
+ """Get the pinned model for a specific agent.
1272
+
1273
+ Args:
1274
+ agent_name: Name of the agent to get the pinned model for.
1275
+
1276
+ Returns:
1277
+ Pinned model name, or None if no model is pinned for this agent.
1278
+ """
1279
+ return get_value(f"agent_model_{agent_name}")
1280
+
1281
+
1282
+ def set_agent_pinned_model(agent_name: str, model_name: str):
1283
+ """Set the pinned model for a specific agent.
1284
+
1285
+ Args:
1286
+ agent_name: Name of the agent to pin the model for.
1287
+ model_name: Model name to pin to this agent.
1288
+ """
1289
+ set_config_value(f"agent_model_{agent_name}", model_name)
1290
+
1291
+
1292
+ def clear_agent_pinned_model(agent_name: str):
1293
+ """Clear the pinned model for a specific agent.
1294
+
1295
+ Args:
1296
+ agent_name: Name of the agent to clear the pinned model for.
1297
+ """
1298
+ # We can't easily delete keys from configparser, so set to empty string
1299
+ # which will be treated as None by get_agent_pinned_model
1300
+ set_config_value(f"agent_model_{agent_name}", "")
1301
+
1302
+
1303
+ def get_all_agent_pinned_models() -> dict:
1304
+ """Get all agent-to-model pinnings from config.
1305
+
1306
+ Returns:
1307
+ Dict mapping agent names to their pinned model names.
1308
+ Only includes agents that have a pinned model (non-empty value).
1309
+ """
1310
+ config = configparser.ConfigParser()
1311
+ config.read(CONFIG_FILE)
1312
+
1313
+ pinnings = {}
1314
+ if DEFAULT_SECTION in config:
1315
+ for key, value in config[DEFAULT_SECTION].items():
1316
+ if key.startswith("agent_model_") and value:
1317
+ agent_name = key[len("agent_model_") :]
1318
+ pinnings[agent_name] = value
1319
+ return pinnings
1320
+
1321
+
1322
+ def get_agents_pinned_to_model(model_name: str) -> list:
1323
+ """Get all agents that are pinned to a specific model.
1324
+
1325
+ Args:
1326
+ model_name: The model name to look up.
1327
+
1328
+ Returns:
1329
+ List of agent names pinned to this model.
1330
+ """
1331
+ all_pinnings = get_all_agent_pinned_models()
1332
+ return [agent for agent, model in all_pinnings.items() if model == model_name]
1333
+
1334
+
1335
+ def get_auto_save_session() -> bool:
1336
+ """
1337
+ Checks puppy.cfg for 'auto_save_session' (case-insensitive in value only).
1338
+ Defaults to True if not set.
1339
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1340
+ """
1341
+ true_vals = {"1", "true", "yes", "on"}
1342
+ cfg_val = get_value("auto_save_session")
1343
+ if cfg_val is not None:
1344
+ if str(cfg_val).strip().lower() in true_vals:
1345
+ return True
1346
+ return False
1347
+ return True
1348
+
1349
+
1350
+ def set_auto_save_session(enabled: bool):
1351
+ """Sets the auto_save_session configuration value.
1352
+
1353
+ Args:
1354
+ enabled: Whether to enable auto-saving of sessions
1355
+ """
1356
+ set_config_value("auto_save_session", "true" if enabled else "false")
1357
+
1358
+
1359
+ def get_max_saved_sessions() -> int:
1360
+ """
1361
+ Gets the maximum number of sessions to keep.
1362
+ Defaults to 20 if not set.
1363
+ """
1364
+ cfg_val = get_value("max_saved_sessions")
1365
+ if cfg_val is not None:
1366
+ try:
1367
+ val = int(cfg_val)
1368
+ return max(0, val) # Ensure non-negative
1369
+ except (ValueError, TypeError):
1370
+ pass
1371
+ return 20
1372
+
1373
+
1374
+ def set_max_saved_sessions(max_sessions: int):
1375
+ """Sets the max_saved_sessions configuration value.
1376
+
1377
+ Args:
1378
+ max_sessions: Maximum number of sessions to keep (0 for unlimited)
1379
+ """
1380
+ set_config_value("max_saved_sessions", str(max_sessions))
1381
+
1382
+
1383
+ def set_diff_highlight_style(style: str):
1384
+ """Set the diff highlight style.
1385
+
1386
+ Note: Text mode has been removed. This function is kept for backwards compatibility
1387
+ but does nothing. All diffs use beautiful syntax highlighting now!
1388
+
1389
+ Args:
1390
+ style: Ignored (always uses 'highlight' mode)
1391
+ """
1392
+ # Do nothing - we always use highlight mode now!
1393
+ pass
1394
+
1395
+
1396
+ def get_diff_addition_color() -> str:
1397
+ """
1398
+ Get the base color for diff additions.
1399
+ Default: darker green
1400
+ """
1401
+ val = get_value("highlight_addition_color")
1402
+ if val:
1403
+ return val
1404
+ return "#0b1f0b" # Default to darker green
1405
+
1406
+
1407
+ def set_diff_addition_color(color: str):
1408
+ """Set the color for diff additions.
1409
+
1410
+ Args:
1411
+ color: Rich color markup (e.g., 'green', 'on_green', 'bright_green')
1412
+ """
1413
+ set_config_value("highlight_addition_color", color)
1414
+
1415
+
1416
+ def get_diff_deletion_color() -> str:
1417
+ """
1418
+ Get the base color for diff deletions.
1419
+ Default: wine
1420
+ """
1421
+ val = get_value("highlight_deletion_color")
1422
+ if val:
1423
+ return val
1424
+ return "#390e1a" # Default to wine
1425
+
1426
+
1427
+ def set_diff_deletion_color(color: str):
1428
+ """Set the color for diff deletions.
1429
+
1430
+ Args:
1431
+ color: Rich color markup (e.g., 'orange1', 'on_bright_yellow', 'red')
1432
+ """
1433
+ set_config_value("highlight_deletion_color", color)
1434
+
1435
+
1436
+ # =============================================================================
1437
+ # Banner Color Configuration
1438
+ # =============================================================================
1439
+
1440
+ # Default banner colors (Rich color names)
1441
+ # A beautiful jewel-tone palette with semantic meaning:
1442
+ # - Blues/Teals: Reading & navigation (calm, informational)
1443
+ # - Warm tones: Actions & changes (edits, shell commands)
1444
+ # - Purples: AI thinking & reasoning (the "brain" colors)
1445
+ # - Greens: Completions & success
1446
+ # - Neutrals: Search & listings
1447
+ DEFAULT_BANNER_COLORS = {
1448
+ "thinking": "deep_sky_blue4", # Sapphire - contemplation
1449
+ "agent_response": "medium_purple4", # Amethyst - main AI output
1450
+ "shell_command": "dark_orange3", # Amber - system commands
1451
+ "read_file": "steel_blue", # Steel - reading files
1452
+ "edit_file": "dark_goldenrod", # Gold - modifications
1453
+ "grep": "grey37", # Silver - search results
1454
+ "directory_listing": "dodger_blue2", # Sky - navigation
1455
+ "agent_reasoning": "dark_violet", # Violet - deep thought
1456
+ "invoke_agent": "deep_pink4", # Ruby - agent invocation
1457
+ "subagent_response": "sea_green3", # Emerald - sub-agent success
1458
+ "list_agents": "dark_slate_gray3", # Slate - neutral listing
1459
+ "universal_constructor": "dark_cyan", # Teal - constructing tools
1460
+ # Browser/Terminal tools - same color as edit_file (gold)
1461
+ "terminal_tool": "dark_goldenrod", # Gold - browser terminal operations
1462
+ # MCP tools - distinct from builtin tools
1463
+ "mcp_tool_call": "dark_cyan", # Teal - external MCP tool calls
1464
+ }
1465
+
1466
+
1467
+ def get_banner_color(banner_name: str) -> str:
1468
+ """Get the background color for a specific banner.
1469
+
1470
+ Args:
1471
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1472
+
1473
+ Returns:
1474
+ Rich color name or hex code for the banner background
1475
+ """
1476
+ config_key = f"banner_color_{banner_name}"
1477
+ val = get_value(config_key)
1478
+ if val:
1479
+ return val
1480
+ return DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1481
+
1482
+
1483
+ def set_banner_color(banner_name: str, color: str):
1484
+ """Set the background color for a specific banner.
1485
+
1486
+ Args:
1487
+ banner_name: The banner identifier (e.g., 'thinking', 'agent_response')
1488
+ color: Rich color name or hex code
1489
+ """
1490
+ config_key = f"banner_color_{banner_name}"
1491
+ set_config_value(config_key, color)
1492
+
1493
+
1494
+ def get_all_banner_colors() -> dict:
1495
+ """Get all banner colors (configured or default).
1496
+
1497
+ Returns:
1498
+ Dict mapping banner names to their colors
1499
+ """
1500
+ return {name: get_banner_color(name) for name in DEFAULT_BANNER_COLORS}
1501
+
1502
+
1503
+ def reset_banner_color(banner_name: str):
1504
+ """Reset a banner color to its default.
1505
+
1506
+ Args:
1507
+ banner_name: The banner identifier to reset
1508
+ """
1509
+ default_color = DEFAULT_BANNER_COLORS.get(banner_name, "blue")
1510
+ set_banner_color(banner_name, default_color)
1511
+
1512
+
1513
+ def reset_all_banner_colors():
1514
+ """Reset all banner colors to their defaults."""
1515
+ for name, color in DEFAULT_BANNER_COLORS.items():
1516
+ set_banner_color(name, color)
1517
+
1518
+
1519
+ def get_current_autosave_id() -> str:
1520
+ """Get or create the current autosave session ID for this process."""
1521
+ global _CURRENT_AUTOSAVE_ID
1522
+ if not _CURRENT_AUTOSAVE_ID:
1523
+ # Use a full timestamp so tests and UX can predict the name if needed
1524
+ _CURRENT_AUTOSAVE_ID = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
1525
+ return _CURRENT_AUTOSAVE_ID
1526
+
1527
+
1528
+ def rotate_autosave_id() -> str:
1529
+ """Force a new autosave session ID and return it."""
1530
+ global _CURRENT_AUTOSAVE_ID
1531
+ _CURRENT_AUTOSAVE_ID = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
1532
+ return _CURRENT_AUTOSAVE_ID
1533
+
1534
+
1535
+ def get_current_autosave_session_name() -> str:
1536
+ """Return the full session name used for autosaves (no file extension)."""
1537
+ return f"auto_session_{get_current_autosave_id()}"
1538
+
1539
+
1540
+ def set_current_autosave_from_session_name(session_name: str) -> str:
1541
+ """Set the current autosave ID based on a full session name.
1542
+
1543
+ Accepts names like 'auto_session_YYYYMMDD_HHMMSS' and extracts the ID part.
1544
+ Returns the ID that was set.
1545
+ """
1546
+ global _CURRENT_AUTOSAVE_ID
1547
+ prefix = "auto_session_"
1548
+ if session_name.startswith(prefix):
1549
+ _CURRENT_AUTOSAVE_ID = session_name[len(prefix) :]
1550
+ else:
1551
+ _CURRENT_AUTOSAVE_ID = session_name
1552
+ return _CURRENT_AUTOSAVE_ID
1553
+
1554
+
1555
+ def auto_save_session_if_enabled() -> bool:
1556
+ """Automatically save the current session if auto_save_session is enabled."""
1557
+ if not get_auto_save_session():
1558
+ return False
1559
+
1560
+ try:
1561
+ import pathlib
1562
+
1563
+ from code_puppy.agents.agent_manager import get_current_agent
1564
+ from code_puppy.messaging import emit_info
1565
+
1566
+ current_agent = get_current_agent()
1567
+ history = current_agent.get_message_history()
1568
+ if not history:
1569
+ return False
1570
+
1571
+ now = datetime.datetime.now()
1572
+ session_name = get_current_autosave_session_name()
1573
+ autosave_dir = pathlib.Path(AUTOSAVE_DIR)
1574
+
1575
+ metadata = save_session(
1576
+ history=history,
1577
+ session_name=session_name,
1578
+ base_dir=autosave_dir,
1579
+ timestamp=now.isoformat(),
1580
+ token_estimator=current_agent.estimate_tokens_for_message,
1581
+ auto_saved=True,
1582
+ )
1583
+
1584
+ emit_info(
1585
+ f"Auto-saved session: {metadata.message_count} messages ({metadata.total_tokens} tokens)"
1586
+ )
1587
+
1588
+ return True
1589
+
1590
+ except Exception as exc: # pragma: no cover - defensive logging
1591
+ from code_puppy.messaging import emit_error
1592
+
1593
+ emit_error(f"Failed to auto-save session: {exc}")
1594
+ return False
1595
+
1596
+
1597
+ def get_diff_context_lines() -> int:
1598
+ """
1599
+ Returns the user-configured number of context lines for diff display.
1600
+ This controls how many lines of surrounding context are shown in diffs.
1601
+ Defaults to 6 if unset or misconfigured.
1602
+ Configurable by 'diff_context_lines' key.
1603
+ """
1604
+ val = get_value("diff_context_lines")
1605
+ try:
1606
+ context_lines = int(val) if val else 6
1607
+ # Apply reasonable bounds: minimum 0, maximum 50
1608
+ return max(0, min(context_lines, 50))
1609
+ except (ValueError, TypeError):
1610
+ return 6
1611
+
1612
+
1613
+ def finalize_autosave_session() -> str:
1614
+ """Persist the current autosave snapshot and rotate to a fresh session."""
1615
+ auto_save_session_if_enabled()
1616
+ return rotate_autosave_id()
1617
+
1618
+
1619
+ def get_suppress_thinking_messages() -> bool:
1620
+ """
1621
+ Checks puppy.cfg for 'suppress_thinking_messages' (case-insensitive in value only).
1622
+ Defaults to False if not set.
1623
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1624
+ When enabled, thinking messages (agent_reasoning, planned_next_steps) will be hidden.
1625
+ """
1626
+ true_vals = {"1", "true", "yes", "on"}
1627
+ cfg_val = get_value("suppress_thinking_messages")
1628
+ if cfg_val is not None:
1629
+ if str(cfg_val).strip().lower() in true_vals:
1630
+ return True
1631
+ return False
1632
+ return False
1633
+
1634
+
1635
+ def set_suppress_thinking_messages(enabled: bool):
1636
+ """Sets the suppress_thinking_messages configuration value.
1637
+
1638
+ Args:
1639
+ enabled: Whether to suppress thinking messages
1640
+ """
1641
+ set_config_value("suppress_thinking_messages", "true" if enabled else "false")
1642
+
1643
+
1644
+ def get_suppress_informational_messages() -> bool:
1645
+ """
1646
+ Checks puppy.cfg for 'suppress_informational_messages' (case-insensitive in value only).
1647
+ Defaults to False if not set.
1648
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
1649
+ When enabled, informational messages (info, success, warning) will be hidden.
1650
+ """
1651
+ true_vals = {"1", "true", "yes", "on"}
1652
+ cfg_val = get_value("suppress_informational_messages")
1653
+ if cfg_val is not None:
1654
+ if str(cfg_val).strip().lower() in true_vals:
1655
+ return True
1656
+ return False
1657
+ return False
1658
+
1659
+
1660
+ def set_suppress_informational_messages(enabled: bool):
1661
+ """Sets the suppress_informational_messages configuration value.
1662
+
1663
+ Args:
1664
+ enabled: Whether to suppress informational messages
1665
+ """
1666
+ set_config_value("suppress_informational_messages", "true" if enabled else "false")
1667
+
1668
+
1669
+ # API Key management functions
1670
+ def get_api_key(key_name: str) -> str:
1671
+ """Get an API key from puppy.cfg.
1672
+
1673
+ Args:
1674
+ key_name: The name of the API key (e.g., 'OPENAI_API_KEY')
1675
+
1676
+ Returns:
1677
+ The API key value, or empty string if not set
1678
+ """
1679
+ return get_value(key_name) or ""
1680
+
1681
+
1682
+ def set_api_key(key_name: str, value: str):
1683
+ """Set an API key in puppy.cfg.
1684
+
1685
+ Args:
1686
+ key_name: The name of the API key (e.g., 'OPENAI_API_KEY')
1687
+ value: The API key value (empty string to remove)
1688
+ """
1689
+ set_config_value(key_name, value)
1690
+
1691
+
1692
+ def load_api_keys_to_environment():
1693
+ """Load all API keys from .env and puppy.cfg into environment variables.
1694
+
1695
+ Priority order:
1696
+ 1. .env file (highest priority) - if present in current directory
1697
+ 2. puppy.cfg - fallback if not in .env
1698
+ 3. Existing environment variables - preserved if already set
1699
+
1700
+ This should be called on startup to ensure API keys are available.
1701
+ """
1702
+ from pathlib import Path
1703
+
1704
+ api_key_names = [
1705
+ "OPENAI_API_KEY",
1706
+ "GEMINI_API_KEY",
1707
+ "ANTHROPIC_API_KEY",
1708
+ "CEREBRAS_API_KEY",
1709
+ "SYN_API_KEY",
1710
+ "AZURE_OPENAI_API_KEY",
1711
+ "AZURE_OPENAI_ENDPOINT",
1712
+ "OPENROUTER_API_KEY",
1713
+ "ZAI_API_KEY",
1714
+ ]
1715
+
1716
+ # Step 1: Load from .env file if it exists (highest priority)
1717
+ # Look for .env in current working directory
1718
+ env_file = Path.cwd() / ".env"
1719
+ if env_file.exists():
1720
+ try:
1721
+ from dotenv import load_dotenv
1722
+
1723
+ # override=True means .env values take precedence over existing env vars
1724
+ load_dotenv(env_file, override=True)
1725
+ except ImportError:
1726
+ # python-dotenv not installed, skip .env loading
1727
+ pass
1728
+
1729
+ # Step 2: Load from puppy.cfg, but only if not already set
1730
+ # This ensures .env has priority over puppy.cfg
1731
+ for key_name in api_key_names:
1732
+ # Only load from config if not already in environment
1733
+ if key_name not in os.environ or not os.environ[key_name]:
1734
+ value = get_api_key(key_name)
1735
+ if value:
1736
+ os.environ[key_name] = value
1737
+
1738
+
1739
+ def get_default_agent() -> str:
1740
+ """
1741
+ Get the default agent name from puppy.cfg.
1742
+
1743
+ Returns:
1744
+ str: The default agent name, or "code-puppy" if not set.
1745
+ """
1746
+ return get_value("default_agent") or "code-puppy"
1747
+
1748
+
1749
+ def set_default_agent(agent_name: str) -> None:
1750
+ """
1751
+ Set the default agent name in puppy.cfg.
1752
+
1753
+ Args:
1754
+ agent_name: The name of the agent to set as default.
1755
+ """
1756
+ set_config_value("default_agent", agent_name)
1757
+
1758
+
1759
+ # --- FRONTEND EMITTER CONFIGURATION ---
1760
+ def get_frontend_emitter_enabled() -> bool:
1761
+ """Check if frontend emitter is enabled."""
1762
+ val = get_value("frontend_emitter_enabled")
1763
+ if val is None:
1764
+ return True # Enabled by default
1765
+ return str(val).lower() in ("1", "true", "yes", "on")
1766
+
1767
+
1768
+ def get_frontend_emitter_max_recent_events() -> int:
1769
+ """Get max number of recent events to buffer."""
1770
+ val = get_value("frontend_emitter_max_recent_events")
1771
+ if val is None:
1772
+ return 100
1773
+ try:
1774
+ return int(val)
1775
+ except ValueError:
1776
+ return 100
1777
+
1778
+
1779
+ def get_frontend_emitter_queue_size() -> int:
1780
+ """Get max subscriber queue size."""
1781
+ val = get_value("frontend_emitter_queue_size")
1782
+ if val is None:
1783
+ return 100
1784
+ try:
1785
+ return int(val)
1786
+ except ValueError:
1787
+ return 100