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.
- code_puppy/__init__.py +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +256 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
- code_puppy-0.0.118.dist-info/RECORD +86 -0
- code_puppy-0.0.97.dist-info/RECORD +0 -32
- {code_puppy-0.0.97.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {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
|
|
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 = "
|
|
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 "
|
|
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., "
|
|
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., "
|
|
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., "
|
|
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., ["
|
|
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 "
|
|
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
|
|
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=
|
|
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 = "
|
|
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="
|
|
163
|
-
CDCompleter(trigger="
|
|
164
|
-
SetCompleter(trigger="
|
|
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
|
|
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
|
-
|
|
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 '
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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]")
|
code_puppy/http_utils.py
ADDED
|
@@ -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
|