code-puppy 0.0.97__py3-none-any.whl → 0.0.118__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 (81) hide show
  1. code_puppy/__init__.py +2 -5
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agent.py +125 -40
  4. code_puppy/agent_prompts.py +30 -24
  5. code_puppy/callbacks.py +152 -0
  6. code_puppy/command_line/command_handler.py +359 -0
  7. code_puppy/command_line/load_context_completion.py +59 -0
  8. code_puppy/command_line/model_picker_completion.py +14 -21
  9. code_puppy/command_line/motd.py +44 -28
  10. code_puppy/command_line/prompt_toolkit_completion.py +42 -23
  11. code_puppy/config.py +266 -26
  12. code_puppy/http_utils.py +122 -0
  13. code_puppy/main.py +570 -383
  14. code_puppy/message_history_processor.py +195 -104
  15. code_puppy/messaging/__init__.py +46 -0
  16. code_puppy/messaging/message_queue.py +288 -0
  17. code_puppy/messaging/queue_console.py +293 -0
  18. code_puppy/messaging/renderers.py +305 -0
  19. code_puppy/messaging/spinner/__init__.py +55 -0
  20. code_puppy/messaging/spinner/console_spinner.py +200 -0
  21. code_puppy/messaging/spinner/spinner_base.py +66 -0
  22. code_puppy/messaging/spinner/textual_spinner.py +97 -0
  23. code_puppy/model_factory.py +73 -105
  24. code_puppy/plugins/__init__.py +32 -0
  25. code_puppy/reopenable_async_client.py +225 -0
  26. code_puppy/state_management.py +60 -21
  27. code_puppy/summarization_agent.py +56 -35
  28. code_puppy/token_utils.py +7 -9
  29. code_puppy/tools/__init__.py +1 -4
  30. code_puppy/tools/command_runner.py +187 -32
  31. code_puppy/tools/common.py +44 -35
  32. code_puppy/tools/file_modifications.py +335 -118
  33. code_puppy/tools/file_operations.py +368 -95
  34. code_puppy/tools/token_check.py +27 -11
  35. code_puppy/tools/tools_content.py +53 -0
  36. code_puppy/tui/__init__.py +10 -0
  37. code_puppy/tui/app.py +1050 -0
  38. code_puppy/tui/components/__init__.py +21 -0
  39. code_puppy/tui/components/chat_view.py +512 -0
  40. code_puppy/tui/components/command_history_modal.py +218 -0
  41. code_puppy/tui/components/copy_button.py +139 -0
  42. code_puppy/tui/components/custom_widgets.py +58 -0
  43. code_puppy/tui/components/input_area.py +167 -0
  44. code_puppy/tui/components/sidebar.py +309 -0
  45. code_puppy/tui/components/status_bar.py +182 -0
  46. code_puppy/tui/messages.py +27 -0
  47. code_puppy/tui/models/__init__.py +8 -0
  48. code_puppy/tui/models/chat_message.py +25 -0
  49. code_puppy/tui/models/command_history.py +89 -0
  50. code_puppy/tui/models/enums.py +24 -0
  51. code_puppy/tui/screens/__init__.py +13 -0
  52. code_puppy/tui/screens/help.py +130 -0
  53. code_puppy/tui/screens/settings.py +256 -0
  54. code_puppy/tui/screens/tools.py +74 -0
  55. code_puppy/tui/tests/__init__.py +1 -0
  56. code_puppy/tui/tests/test_chat_message.py +28 -0
  57. code_puppy/tui/tests/test_chat_view.py +88 -0
  58. code_puppy/tui/tests/test_command_history.py +89 -0
  59. code_puppy/tui/tests/test_copy_button.py +191 -0
  60. code_puppy/tui/tests/test_custom_widgets.py +27 -0
  61. code_puppy/tui/tests/test_disclaimer.py +27 -0
  62. code_puppy/tui/tests/test_enums.py +15 -0
  63. code_puppy/tui/tests/test_file_browser.py +60 -0
  64. code_puppy/tui/tests/test_help.py +38 -0
  65. code_puppy/tui/tests/test_history_file_reader.py +107 -0
  66. code_puppy/tui/tests/test_input_area.py +33 -0
  67. code_puppy/tui/tests/test_settings.py +44 -0
  68. code_puppy/tui/tests/test_sidebar.py +33 -0
  69. code_puppy/tui/tests/test_sidebar_history.py +153 -0
  70. code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
  71. code_puppy/tui/tests/test_status_bar.py +54 -0
  72. code_puppy/tui/tests/test_timestamped_history.py +52 -0
  73. code_puppy/tui/tests/test_tools.py +82 -0
  74. code_puppy/version_checker.py +26 -3
  75. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.118.dist-info/RECORD +86 -0
  77. code_puppy-0.0.97.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.97.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
@@ -19,17 +19,23 @@ from prompt_toolkit.keys import Keys
19
19
  from prompt_toolkit.styles import Style
20
20
 
21
21
  from code_puppy.command_line.file_path_completion import FilePathCompleter
22
+ from code_puppy.command_line.load_context_completion import LoadContextCompleter
22
23
  from code_puppy.command_line.model_picker_completion import (
23
24
  ModelNameCompleter,
24
25
  get_active_model,
25
26
  update_model_in_input,
26
27
  )
27
28
  from code_puppy.command_line.utils import list_directory
28
- from code_puppy.config import get_config_keys, get_puppy_name, get_value
29
+ from code_puppy.config import (
30
+ COMMAND_HISTORY_FILE,
31
+ get_config_keys,
32
+ get_puppy_name,
33
+ get_value,
34
+ )
29
35
 
30
36
 
31
37
  class SetCompleter(Completer):
32
- def __init__(self, trigger: str = "~set"):
38
+ def __init__(self, trigger: str = "/set"):
33
39
  self.trigger = trigger
34
40
 
35
41
  def get_completions(self, document, complete_event):
@@ -40,15 +46,15 @@ class SetCompleter(Completer):
40
46
  return
41
47
 
42
48
  # Determine the part of the text that is relevant for this completer
43
- # This handles cases like " ~set foo" where the trigger isn't at the start of the string
49
+ # This handles cases like " /set foo" where the trigger isn't at the start of the string
44
50
  actual_trigger_pos = text_before_cursor.find(self.trigger)
45
51
  effective_input = text_before_cursor[
46
52
  actual_trigger_pos:
47
- ] # e.g., "~set keypart" or "~set " or "~set"
53
+ ] # e.g., "/set keypart" or "/set "
48
54
 
49
55
  tokens = effective_input.split()
50
56
 
51
- # Case 1: Input is exactly the trigger (e.g., "~set") and nothing more (not even a trailing space on effective_input).
57
+ # Case 1: Input is exactly the trigger (e.g., "/set") and nothing more (not even a trailing space on effective_input).
52
58
  # Suggest adding a space.
53
59
  if (
54
60
  len(tokens) == 1
@@ -63,11 +69,11 @@ class SetCompleter(Completer):
63
69
  )
64
70
  return
65
71
 
66
- # Case 2: Input is trigger + space (e.g., "~set ") or trigger + partial key (e.g., "~set partial")
72
+ # Case 2: Input is trigger + space (e.g., "/set ") or trigger + partial key (e.g., "/set partial")
67
73
  base_to_complete = ""
68
- if len(tokens) > 1: # e.g., ["~set", "partialkey"]
74
+ if len(tokens) > 1: # e.g., ["/set", "partialkey"]
69
75
  base_to_complete = tokens[1]
70
- # If len(tokens) == 1, it implies effective_input was like "~set ", so base_to_complete remains ""
76
+ # If len(tokens) == 1, it implies effective_input was like "/set ", so base_to_complete remains ""
71
77
  # This means we list all keys.
72
78
 
73
79
  # --- SPECIAL HANDLING FOR 'model' KEY ---
@@ -75,8 +81,8 @@ class SetCompleter(Completer):
75
81
  # Don't return any completions -- let ModelNameCompleter handle it
76
82
  return
77
83
  for key in get_config_keys():
78
- if key == "model":
79
- continue # exclude 'model' from regular ~set completions
84
+ if key == "model" or key == "puppy_token":
85
+ continue # exclude 'model' and 'puppy_token' from regular /set completions
80
86
  if key.startswith(base_to_complete):
81
87
  prev_value = get_value(key)
82
88
  value_part = f" = {prev_value}" if prev_value is not None else " = "
@@ -87,14 +93,12 @@ class SetCompleter(Completer):
87
93
  start_position=-len(
88
94
  base_to_complete
89
95
  ), # Correctly replace only the typed part of the key
90
- display_meta=f"puppy.cfg key (was: {prev_value})"
91
- if prev_value is not None
92
- else "puppy.cfg key",
96
+ display_meta="",
93
97
  )
94
98
 
95
99
 
96
100
  class CDCompleter(Completer):
97
- def __init__(self, trigger: str = "~cd"):
101
+ def __init__(self, trigger: str = "/cd"):
98
102
  self.trigger = trigger
99
103
 
100
104
  def get_completions(self, document, complete_event):
@@ -159,19 +163,35 @@ async def get_input_with_combined_completion(
159
163
  completer = merge_completers(
160
164
  [
161
165
  FilePathCompleter(symbol="@"),
162
- ModelNameCompleter(trigger="~m"),
163
- CDCompleter(trigger="~cd"),
164
- SetCompleter(trigger="~set"),
166
+ ModelNameCompleter(trigger="/m"),
167
+ CDCompleter(trigger="/cd"),
168
+ SetCompleter(trigger="/set"),
169
+ LoadContextCompleter(trigger="/load_context"),
165
170
  ]
166
171
  )
167
- # Add custom key bindings for Alt+M to insert a new line without submitting
172
+ # Add custom key bindings for multiline input
168
173
  bindings = KeyBindings()
169
174
 
170
- @bindings.add(Keys.Escape, "m") # Alt+M
175
+ @bindings.add(Keys.Escape, "m") # Alt+M (legacy support)
176
+ def _(event):
177
+ event.app.current_buffer.insert_text("\n")
178
+
179
+ # Create a special binding for shift+enter
180
+ @bindings.add("escape", "enter")
171
181
  def _(event):
182
+ """Pressing alt+enter (meta+enter) inserts a newline."""
172
183
  event.app.current_buffer.insert_text("\n")
173
184
 
174
- @bindings.add("c-c")
185
+ # Override the default enter behavior to check for shift
186
+ @bindings.add("enter")
187
+ def _(event):
188
+ """Accept input or insert newline depending on shift key."""
189
+ # Check if shift is pressed - this comes from key press event data
190
+ # Using a key sequence like Alt+Enter is more reliable than detecting shift
191
+ # So we'll use the default behavior for Enter
192
+ event.current_buffer.validate_and_handle()
193
+
194
+ @bindings.add(Keys.Escape)
175
195
  def _(event):
176
196
  """Cancel the current prompt when the user presses the ESC key alone."""
177
197
  event.app.exit(exception=KeyboardInterrupt)
@@ -206,14 +226,13 @@ async def get_input_with_combined_completion(
206
226
 
207
227
 
208
228
  if __name__ == "__main__":
209
- print("Type '@' for path-completion or '~m' to pick a model. Ctrl+D to exit.")
229
+ print("Type '@' for path-completion or '/m' to pick a model. Ctrl+D to exit.")
210
230
 
211
231
  async def main():
212
232
  while True:
213
233
  try:
214
234
  inp = await get_input_with_combined_completion(
215
- get_prompt_with_active_model(),
216
- history_file="~/.path_completion_history.txt",
235
+ get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
217
236
  )
218
237
  print(f"You entered: {inp}")
219
238
  except KeyboardInterrupt:
code_puppy/config.py CHANGED
@@ -1,11 +1,14 @@
1
1
  import configparser
2
- import os
3
2
  import json
3
+ import os
4
4
  import pathlib
5
5
 
6
6
  CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".code_puppy")
7
7
  CONFIG_FILE = os.path.join(CONFIG_DIR, "puppy.cfg")
8
8
  MCP_SERVERS_FILE = os.path.join(CONFIG_DIR, "mcp_servers.json")
9
+ COMMAND_HISTORY_FILE = os.path.join(CONFIG_DIR, "command_history.txt")
10
+ MODELS_FILE = os.path.join(CONFIG_DIR, "models.json")
11
+ EXTRA_MODELS_FILE = os.path.join(CONFIG_DIR, "extra_models.json")
9
12
 
10
13
  DEFAULT_SECTION = "puppy"
11
14
  REQUIRED_KEYS = ["puppy_name", "owner_name"]
@@ -35,7 +38,7 @@ def ensure_config_exists():
35
38
  val = input("What should we name the puppy? ").strip()
36
39
  elif key == "owner_name":
37
40
  val = input(
38
- "What's your name (so Code Puppy knows its master)? "
41
+ "What's your name (so Code Puppy knows its owner)? "
39
42
  ).strip()
40
43
  else:
41
44
  val = input(f"Enter {key}: ").strip()
@@ -60,17 +63,9 @@ def get_owner_name():
60
63
  return get_value("owner_name") or "Master"
61
64
 
62
65
 
63
- def get_message_history_limit():
64
- """
65
- Returns the user-configured message truncation limit (for remembering context),
66
- or 40 if unset or misconfigured.
67
- Configurable by 'message_history_limit' key.
68
- """
69
- val = get_value("message_history_limit")
70
- try:
71
- return max(1, int(val)) if val else 40
72
- except (ValueError, TypeError):
73
- return 40
66
+ # Legacy function removed - message history limit is no longer used
67
+ # Message history is now managed by token-based compaction system
68
+ # using get_protected_token_count() and get_summarization_threshold()
74
69
 
75
70
 
76
71
  # --- CONFIG SETTER STARTS HERE ---
@@ -107,21 +102,102 @@ def load_mcp_server_configs():
107
102
  Returns a dict mapping names to their URL or config dict.
108
103
  If file does not exist, returns an empty dict.
109
104
  """
105
+ from code_puppy.messaging.message_queue import emit_error, emit_system_message
106
+
110
107
  try:
111
108
  if not pathlib.Path(MCP_SERVERS_FILE).exists():
112
- print("No MCP configuration was found")
109
+ emit_system_message("[dim]No MCP configuration was found[/dim]")
113
110
  return {}
114
111
  with open(MCP_SERVERS_FILE, "r") as f:
115
112
  conf = json.loads(f.read())
116
113
  return conf["mcp_servers"]
117
114
  except Exception as e:
118
- print(f"Failed to load MCP servers - {str(e)}")
115
+ emit_error(f"Failed to load MCP servers - {str(e)}")
119
116
  return {}
120
117
 
121
118
 
119
+ # Cache for model validation to prevent hitting ModelFactory on every call
120
+ _model_validation_cache = {}
121
+ _default_model_cache = None
122
+
123
+
124
+ def _default_model_from_models_json():
125
+ """Attempt to load the first model name from models.json.
126
+
127
+ Falls back to the hard-coded default (``claude-4-0-sonnet``) if the file
128
+ cannot be read for any reason or is empty.
129
+ """
130
+ global _default_model_cache
131
+
132
+ # Return cached default if we have one
133
+ if _default_model_cache is not None:
134
+ return _default_model_cache
135
+
136
+ try:
137
+ # Local import to avoid potential circular dependency on module import
138
+ from code_puppy.model_factory import ModelFactory
139
+
140
+ models_config_path = os.path.join(CONFIG_DIR, "models.json")
141
+ models_config = ModelFactory.load_config(models_config_path)
142
+ first_key = next(iter(models_config)) # Raises StopIteration if empty
143
+ _default_model_cache = first_key
144
+ return first_key
145
+ except Exception:
146
+ # Any problem (network, file missing, empty dict, etc.) => fall back
147
+ _default_model_cache = "claude-4-0-sonnet"
148
+ return "claude-4-0-sonnet"
149
+
150
+
151
+ def _validate_model_exists(model_name: str) -> bool:
152
+ """Check if a model exists in models.json with caching to avoid redundant calls."""
153
+ global _model_validation_cache
154
+
155
+ # Check cache first
156
+ if model_name in _model_validation_cache:
157
+ return _model_validation_cache[model_name]
158
+
159
+ try:
160
+ from code_puppy.model_factory import ModelFactory
161
+
162
+ models_config_path = os.path.join(CONFIG_DIR, "models.json")
163
+ models_config = ModelFactory.load_config(models_config_path)
164
+ exists = model_name in models_config
165
+
166
+ # Cache the result
167
+ _model_validation_cache[model_name] = exists
168
+ return exists
169
+ except Exception:
170
+ # If we can't validate, assume it exists to avoid breaking things
171
+ _model_validation_cache[model_name] = True
172
+ return True
173
+
174
+
175
+ def clear_model_cache():
176
+ """Clear the model validation cache. Call this when models.json changes."""
177
+ global _model_validation_cache, _default_model_cache
178
+ _model_validation_cache.clear()
179
+ _default_model_cache = None
180
+
181
+
122
182
  def get_model_name():
123
- """Returns the last used model name stored in config, or None if unset."""
124
- return get_value("model") or "gpt-4.1"
183
+ """Return a valid model name for Code Puppy to use.
184
+
185
+ 1. Look at ``model`` in *puppy.cfg*.
186
+ 2. If that value exists **and** is present in *models.json*, use it.
187
+ 3. Otherwise return the first model listed in *models.json*.
188
+ 4. As a last resort (e.g.
189
+ *models.json* unreadable) fall back to ``claude-4-0-sonnet``.
190
+ """
191
+
192
+ stored_model = get_value("model")
193
+
194
+ if stored_model:
195
+ # Use cached validation to avoid hitting ModelFactory every time
196
+ if _validate_model_exists(stored_model):
197
+ return stored_model
198
+
199
+ # Either no stored model or it's not valid – choose default from models.json
200
+ return _default_model_from_models_json()
125
201
 
126
202
 
127
203
  def set_model_name(model: str):
@@ -134,15 +210,118 @@ def set_model_name(model: str):
134
210
  with open(CONFIG_FILE, "w") as f:
135
211
  config.write(f)
136
212
 
213
+ # Clear model cache when switching models to ensure fresh validation
214
+ clear_model_cache()
215
+
216
+
217
+ def get_puppy_token():
218
+ """Returns the puppy_token from config, or None if not set."""
219
+ return get_value("puppy_token")
220
+
221
+
222
+ def set_puppy_token(token: str):
223
+ """Sets the puppy_token in the persistent config file."""
224
+ set_config_value("puppy_token", token)
225
+
226
+
227
+ def normalize_command_history():
228
+ """
229
+ Normalize the command history file by converting old format timestamps to the new format.
230
+
231
+ Old format example:
232
+ - "# 2025-08-04 12:44:45.469829"
233
+
234
+ New format example:
235
+ - "# 2025-08-05T10:35:33" (ISO)
236
+ """
237
+ import os
238
+ import re
239
+
240
+ # Skip implementation during tests
241
+ import sys
242
+
243
+ if "pytest" in sys.modules:
244
+ return
245
+
246
+ # Skip normalization if file doesn't exist
247
+ command_history_exists = os.path.isfile(COMMAND_HISTORY_FILE)
248
+ if not command_history_exists:
249
+ return
250
+
251
+ try:
252
+ # Read the entire file
253
+ with open(COMMAND_HISTORY_FILE, "r") as f:
254
+ content = f.read()
255
+
256
+ # Skip empty files
257
+ if not content.strip():
258
+ return
259
+
260
+ # Define regex pattern for old timestamp format
261
+ # Format: "# YYYY-MM-DD HH:MM:SS.ffffff"
262
+ old_timestamp_pattern = r"# (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\.(\d+)"
263
+
264
+ # Function to convert matched timestamp to ISO format
265
+ def convert_to_iso(match):
266
+ date = match.group(1)
267
+ time = match.group(2)
268
+ # Create ISO format (YYYY-MM-DDThh:mm:ss)
269
+ return f"# {date}T{time}"
270
+
271
+ # Replace all occurrences of the old timestamp format with the new ISO format
272
+ updated_content = re.sub(old_timestamp_pattern, convert_to_iso, content)
273
+
274
+ # Write the updated content back to the file only if changes were made
275
+ if content != updated_content:
276
+ with open(COMMAND_HISTORY_FILE, "w") as f:
277
+ f.write(updated_content)
278
+ except Exception as e:
279
+ from rich.console import Console
280
+
281
+ direct_console = Console()
282
+ error_msg = f"❌ An unexpected error occurred while normalizing command history: {str(e)}"
283
+ direct_console.print(f"[bold red]{error_msg}[/bold red]")
284
+
285
+
286
+ def initialize_command_history_file():
287
+ """Create the command history file if it doesn't exist.
288
+ Handles migration from the old history file location for backward compatibility.
289
+ Also normalizes the command history format if needed.
290
+ """
291
+ import os
292
+ from pathlib import Path
293
+
294
+ command_history_exists = os.path.isfile(COMMAND_HISTORY_FILE)
295
+ if not command_history_exists:
296
+ try:
297
+ Path(COMMAND_HISTORY_FILE).touch()
298
+
299
+ # For backwards compatibility, copy the old history file, then remove it
300
+ old_history_file = os.path.join(
301
+ os.path.expanduser("~"), ".code_puppy_history.txt"
302
+ )
303
+ old_history_exists = os.path.isfile(old_history_file)
304
+ if old_history_exists:
305
+ import shutil
306
+
307
+ shutil.copy2(Path(old_history_file), Path(COMMAND_HISTORY_FILE))
308
+ Path(old_history_file).unlink(missing_ok=True)
309
+
310
+ # Normalize the command history format if needed
311
+ normalize_command_history()
312
+ except Exception as e:
313
+ from rich.console import Console
314
+
315
+ direct_console = Console()
316
+ error_msg = f"❌ An unexpected error occurred while trying to initialize history file: {str(e)}"
317
+ direct_console.print(f"[bold red]{error_msg}[/bold red]")
318
+
137
319
 
138
320
  def get_yolo_mode():
139
321
  """
140
322
  Checks puppy.cfg for 'yolo_mode' (case-insensitive in value only).
141
- If not set, checks YOLO_MODE env var:
142
- - If found in env, saves that value to puppy.cfg for future use.
143
- - If neither present, defaults to False.
323
+ Defaults to False if not set.
144
324
  Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
145
- Always prioritizes the config once set!
146
325
  """
147
326
  true_vals = {"1", "true", "yes", "on"}
148
327
  cfg_val = get_value("yolo_mode")
@@ -150,11 +329,72 @@ def get_yolo_mode():
150
329
  if str(cfg_val).strip().lower() in true_vals:
151
330
  return True
152
331
  return False
153
- env_val = os.getenv("YOLO_MODE")
154
- if env_val is not None:
155
- # Persist the env value now
156
- set_config_value("yolo_mode", env_val)
157
- if str(env_val).strip().lower() in true_vals:
332
+ return False
333
+
334
+
335
+ def get_mcp_disabled():
336
+ """
337
+ Checks puppy.cfg for 'disable_mcp' (case-insensitive in value only).
338
+ Defaults to False if not set.
339
+ Allowed values for ON: 1, '1', 'true', 'yes', 'on' (all case-insensitive for value).
340
+ When enabled, Code Puppy will skip loading MCP servers entirely.
341
+ """
342
+ true_vals = {"1", "true", "yes", "on"}
343
+ cfg_val = get_value("disable_mcp")
344
+ if cfg_val is not None:
345
+ if str(cfg_val).strip().lower() in true_vals:
158
346
  return True
159
347
  return False
160
348
  return False
349
+
350
+
351
+ def get_protected_token_count():
352
+ """
353
+ Returns the user-configured protected token count for message history compaction.
354
+ This is the number of tokens in recent messages that won't be summarized.
355
+ Defaults to 50000 if unset or misconfigured.
356
+ Configurable by 'protected_token_count' key.
357
+ """
358
+ val = get_value("protected_token_count")
359
+ try:
360
+ return max(1000, int(val)) if val else 50000 # Minimum 1000 tokens
361
+ except (ValueError, TypeError):
362
+ return 50000
363
+
364
+
365
+ def get_summarization_threshold():
366
+ """
367
+ Returns the user-configured summarization threshold as a float between 0.0 and 1.0.
368
+ This is the proportion of model context that triggers summarization.
369
+ Defaults to 0.85 (85%) if unset or misconfigured.
370
+ Configurable by 'summarization_threshold' key.
371
+ """
372
+ val = get_value("summarization_threshold")
373
+ try:
374
+ threshold = float(val) if val else 0.85
375
+ # Clamp between reasonable bounds
376
+ return max(0.1, min(0.95, threshold))
377
+ except (ValueError, TypeError):
378
+ return 0.85
379
+
380
+
381
+ def save_command_to_history(command: str):
382
+ """Save a command to the history file with an ISO format timestamp.
383
+
384
+ Args:
385
+ command: The command to save
386
+ """
387
+ import datetime
388
+
389
+ try:
390
+ timestamp = datetime.datetime.now().isoformat(timespec="seconds")
391
+ with open(COMMAND_HISTORY_FILE, "a") as f:
392
+ f.write(f"\n# {timestamp}\n{command}\n")
393
+ except Exception as e:
394
+ from rich.console import Console
395
+
396
+ direct_console = Console()
397
+ error_msg = (
398
+ f"❌ An unexpected error occurred while saving command history: {str(e)}"
399
+ )
400
+ direct_console.print(f"[bold red]{error_msg}[/bold red]")
@@ -0,0 +1,122 @@
1
+ """
2
+ HTTP utilities module for code-puppy.
3
+
4
+ This module provides functions for creating properly configured HTTP clients.
5
+ """
6
+
7
+ import os
8
+ import socket
9
+ from typing import Dict, Optional, Union
10
+
11
+ import httpx
12
+ import requests
13
+
14
+ try:
15
+ from .reopenable_async_client import ReopenableAsyncClient
16
+ except ImportError:
17
+ ReopenableAsyncClient = None
18
+
19
+
20
+ def get_cert_bundle_path() -> str:
21
+ # First check if SSL_CERT_FILE environment variable is set
22
+ ssl_cert_file = os.environ.get("SSL_CERT_FILE")
23
+ if ssl_cert_file and os.path.exists(ssl_cert_file):
24
+ return ssl_cert_file
25
+
26
+
27
+ def create_client(
28
+ timeout: int = 180,
29
+ verify: Union[bool, str] = None,
30
+ headers: Optional[Dict[str, str]] = None,
31
+ ) -> httpx.Client:
32
+ if verify is None:
33
+ verify = get_cert_bundle_path()
34
+
35
+ return httpx.Client(verify=verify, headers=headers or {}, timeout=timeout)
36
+
37
+
38
+ def create_async_client(
39
+ timeout: int = 180,
40
+ verify: Union[bool, str] = None,
41
+ headers: Optional[Dict[str, str]] = None,
42
+ ) -> httpx.AsyncClient:
43
+ if verify is None:
44
+ verify = get_cert_bundle_path()
45
+
46
+ return httpx.AsyncClient(verify=verify, headers=headers or {}, timeout=timeout)
47
+
48
+
49
+ def create_requests_session(
50
+ timeout: float = 5.0,
51
+ verify: Union[bool, str] = None,
52
+ headers: Optional[Dict[str, str]] = None,
53
+ ) -> requests.Session:
54
+ session = requests.Session()
55
+
56
+ if verify is None:
57
+ verify = get_cert_bundle_path()
58
+
59
+ session.verify = verify
60
+
61
+ if headers:
62
+ session.headers.update(headers or {})
63
+
64
+ return session
65
+
66
+
67
+ def create_auth_headers(
68
+ api_key: str, header_name: str = "Authorization"
69
+ ) -> Dict[str, str]:
70
+ return {header_name: f"Bearer {api_key}"}
71
+
72
+
73
+ def resolve_env_var_in_header(headers: Dict[str, str]) -> Dict[str, str]:
74
+ resolved_headers = {}
75
+
76
+ for key, value in headers.items():
77
+ if isinstance(value, str):
78
+ try:
79
+ expanded = os.path.expandvars(value)
80
+ resolved_headers[key] = expanded
81
+ except Exception:
82
+ resolved_headers[key] = value
83
+ else:
84
+ resolved_headers[key] = value
85
+
86
+ return resolved_headers
87
+
88
+
89
+ def create_reopenable_async_client(
90
+ timeout: int = 180,
91
+ verify: Union[bool, str] = None,
92
+ headers: Optional[Dict[str, str]] = None,
93
+ ) -> Union["ReopenableAsyncClient", httpx.AsyncClient]:
94
+ if verify is None:
95
+ verify = get_cert_bundle_path()
96
+
97
+ if ReopenableAsyncClient is not None:
98
+ return ReopenableAsyncClient(
99
+ verify=verify, headers=headers or {}, timeout=timeout
100
+ )
101
+ else:
102
+ # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
103
+ return httpx.AsyncClient(verify=verify, headers=headers or {}, timeout=timeout)
104
+
105
+
106
+ def is_cert_bundle_available() -> bool:
107
+ cert_path = get_cert_bundle_path()
108
+ return os.path.exists(cert_path) and os.path.isfile(cert_path)
109
+
110
+
111
+ def find_available_port(start_port=8090, end_port=9010, host="127.0.0.1"):
112
+ for port in range(start_port, end_port + 1):
113
+ try:
114
+ # Try to bind to the port to check if it's available
115
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
116
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
117
+ sock.bind((host, port))
118
+ return port
119
+ except OSError:
120
+ # Port is in use, try the next one
121
+ continue
122
+ return None