code-puppy 0.0.165__py3-none-any.whl → 0.0.166__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.
@@ -1,15 +1,15 @@
1
1
  import json
2
2
  import queue
3
- from typing import Any, Dict, List, Set, Tuple
3
+ from typing import Any, List, Set, Tuple
4
4
 
5
5
  import pydantic
6
6
  from pydantic_ai.messages import ModelMessage, ModelRequest, TextPart, ToolCallPart
7
7
 
8
8
  from code_puppy.config import (
9
- get_compaction_strategy,
10
- get_compaction_threshold,
11
9
  get_model_name,
12
10
  get_protected_token_count,
11
+ get_compaction_threshold,
12
+ get_compaction_strategy,
13
13
  )
14
14
  from code_puppy.messaging import emit_error, emit_info, emit_warning
15
15
  from code_puppy.model_factory import ModelFactory
@@ -82,9 +82,7 @@ def estimate_tokens_for_message(message: ModelMessage) -> int:
82
82
 
83
83
 
84
84
  def filter_huge_messages(messages: List[ModelMessage]) -> List[ModelMessage]:
85
- # First deduplicate tool returns to clean up any duplicates
86
- deduplicated = deduplicate_tool_returns(messages)
87
- filtered = [m for m in deduplicated if estimate_tokens_for_message(m) < 50000]
85
+ filtered = [m for m in messages if estimate_tokens_for_message(m) < 50000]
88
86
  pruned = prune_interrupted_tool_calls(filtered)
89
87
  return pruned
90
88
 
@@ -150,6 +148,81 @@ def split_messages_for_protected_summarization(
150
148
  return messages_to_summarize, protected_messages
151
149
 
152
150
 
151
+ def deduplicate_tool_returns(messages: List[ModelMessage]) -> List[ModelMessage]:
152
+ """
153
+ Remove duplicate tool returns while preserving the first occurrence for each tool_call_id.
154
+
155
+ This function identifies tool-return parts that share the same tool_call_id and
156
+ removes duplicates, keeping only the first return for each id. This prevents
157
+ conversation corruption from duplicate tool_result blocks.
158
+ """
159
+ if not messages:
160
+ return messages
161
+
162
+ seen_tool_returns: Set[str] = set()
163
+ deduplicated: List[ModelMessage] = []
164
+ removed_count = 0
165
+
166
+ for msg in messages:
167
+ # Check if this message has any parts we need to filter
168
+ if not hasattr(msg, "parts") or not msg.parts:
169
+ deduplicated.append(msg)
170
+ continue
171
+
172
+ # Filter parts within this message
173
+ filtered_parts = []
174
+ msg_had_duplicates = False
175
+
176
+ for part in msg.parts:
177
+ tool_call_id = getattr(part, "tool_call_id", None)
178
+ part_kind = getattr(part, "part_kind", None)
179
+
180
+ # Check if this is a tool-return part
181
+ if tool_call_id and part_kind in {
182
+ "tool-return",
183
+ "tool-result",
184
+ "tool_result",
185
+ }:
186
+ if tool_call_id in seen_tool_returns:
187
+ # This is a duplicate return, skip it
188
+ msg_had_duplicates = True
189
+ removed_count += 1
190
+ continue
191
+ else:
192
+ # First occurrence of this return, keep it
193
+ seen_tool_returns.add(tool_call_id)
194
+ filtered_parts.append(part)
195
+ else:
196
+ # Not a tool return, always keep
197
+ filtered_parts.append(part)
198
+
199
+ # If we filtered out parts, create a new message with filtered parts
200
+ if msg_had_duplicates and filtered_parts:
201
+ # Create a new message with the same attributes but filtered parts
202
+ new_msg = type(msg)(parts=filtered_parts)
203
+ # Copy over other attributes if they exist
204
+ for attr_name in dir(msg):
205
+ if (
206
+ not attr_name.startswith("_")
207
+ and attr_name != "parts"
208
+ and hasattr(msg, attr_name)
209
+ ):
210
+ try:
211
+ setattr(new_msg, attr_name, getattr(msg, attr_name))
212
+ except (AttributeError, TypeError):
213
+ # Skip attributes that can't be set
214
+ pass
215
+ deduplicated.append(new_msg)
216
+ elif filtered_parts: # No duplicates but has parts
217
+ deduplicated.append(msg)
218
+ # If no parts remain after filtering, drop the entire message
219
+
220
+ if removed_count > 0:
221
+ emit_warning(f"Removed {removed_count} duplicate tool-return part(s)")
222
+
223
+ return deduplicated
224
+
225
+
153
226
  def summarize_messages(
154
227
  messages: List[ModelMessage], with_protection=True
155
228
  ) -> Tuple[List[ModelMessage], List[ModelMessage]]:
@@ -236,100 +309,21 @@ def get_model_context_length() -> int:
236
309
  return int(context_length)
237
310
 
238
311
 
239
- def deduplicate_tool_returns(messages: List[ModelMessage]) -> List[ModelMessage]:
240
- """
241
- Remove duplicate tool returns while preserving the first occurrence for each tool_call_id.
242
-
243
- This function identifies tool-return parts that share the same tool_call_id and
244
- removes duplicates, keeping only the first return for each id. This prevents
245
- conversation corruption from duplicate tool_result blocks.
246
- """
247
- if not messages:
248
- return messages
249
-
250
- seen_tool_returns: Set[str] = set()
251
- deduplicated: List[ModelMessage] = []
252
- removed_count = 0
253
-
254
- for msg in messages:
255
- # Check if this message has any parts we need to filter
256
- if not hasattr(msg, "parts") or not msg.parts:
257
- deduplicated.append(msg)
258
- continue
259
-
260
- # Filter parts within this message
261
- filtered_parts = []
262
- msg_had_duplicates = False
263
-
264
- for part in msg.parts:
265
- tool_call_id = getattr(part, "tool_call_id", None)
266
- part_kind = getattr(part, "part_kind", None)
267
-
268
- # Check if this is a tool-return part
269
- if tool_call_id and part_kind in {
270
- "tool-return",
271
- "tool-result",
272
- "tool_result",
273
- }:
274
- if tool_call_id in seen_tool_returns:
275
- # This is a duplicate return, skip it
276
- msg_had_duplicates = True
277
- removed_count += 1
278
- continue
279
- else:
280
- # First occurrence of this return, keep it
281
- seen_tool_returns.add(tool_call_id)
282
- filtered_parts.append(part)
283
- else:
284
- # Not a tool return, always keep
285
- filtered_parts.append(part)
286
-
287
- # If we filtered out parts, create a new message with filtered parts
288
- if msg_had_duplicates and filtered_parts:
289
- # Create a new message with the same attributes but filtered parts
290
- new_msg = type(msg)(parts=filtered_parts)
291
- # Copy over other attributes if they exist
292
- for attr_name in dir(msg):
293
- if (
294
- not attr_name.startswith("_")
295
- and attr_name != "parts"
296
- and hasattr(msg, attr_name)
297
- ):
298
- try:
299
- setattr(new_msg, attr_name, getattr(msg, attr_name))
300
- except (AttributeError, TypeError):
301
- # Skip attributes that can't be set
302
- pass
303
- deduplicated.append(new_msg)
304
- elif filtered_parts: # No duplicates but has parts
305
- deduplicated.append(msg)
306
- # If no parts remain after filtering, drop the entire message
307
-
308
- if removed_count > 0:
309
- emit_warning(f"Removed {removed_count} duplicate tool-return part(s)")
310
-
311
- return deduplicated
312
-
313
-
314
312
  def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMessage]:
315
313
  """
316
314
  Remove any messages that participate in mismatched tool call sequences.
317
315
 
318
316
  A mismatched tool call id is one that appears in a ToolCall (model/tool request)
319
- without a corresponding tool return, or vice versa. We enforce a strict 1:1 ratio
320
- between tool calls and tool returns. We preserve original order and only drop
321
- messages that contain parts referencing mismatched tool_call_ids.
317
+ without a corresponding tool return, or vice versa. We preserve original order
318
+ and only drop messages that contain parts referencing mismatched tool_call_ids.
322
319
  """
323
320
  if not messages:
324
321
  return messages
325
322
 
326
- # First deduplicate tool returns to clean up any duplicate returns
327
- messages = deduplicate_tool_returns(messages)
323
+ tool_call_ids: Set[str] = set()
324
+ tool_return_ids: Set[str] = set()
328
325
 
329
- tool_call_counts: Dict[str, int] = {}
330
- tool_return_counts: Dict[str, int] = {}
331
-
332
- # First pass: count occurrences of each tool_call_id for calls vs returns
326
+ # First pass: collect ids for calls vs returns
333
327
  for msg in messages:
334
328
  for part in getattr(msg, "parts", []) or []:
335
329
  tool_call_id = getattr(part, "tool_call_id", None)
@@ -338,25 +332,11 @@ def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMess
338
332
  # Heuristic: if it's an explicit ToolCallPart or has a tool_name/args,
339
333
  # consider it a call; otherwise it's a return/result.
340
334
  if part.part_kind == "tool-call":
341
- tool_call_counts[tool_call_id] = (
342
- tool_call_counts.get(tool_call_id, 0) + 1
343
- )
335
+ tool_call_ids.add(tool_call_id)
344
336
  else:
345
- tool_return_counts[tool_call_id] = (
346
- tool_return_counts.get(tool_call_id, 0) + 1
347
- )
348
-
349
- # Find mismatched tool_call_ids (not exactly 1:1 ratio)
350
- all_tool_ids = set(tool_call_counts.keys()) | set(tool_return_counts.keys())
351
- mismatched: Set[str] = set()
352
-
353
- for tool_id in all_tool_ids:
354
- call_count = tool_call_counts.get(tool_id, 0)
355
- return_count = tool_return_counts.get(tool_id, 0)
356
- # Enforce strict 1:1 ratio - both must be exactly 1
357
- if call_count != 1 or return_count != 1:
358
- mismatched.add(tool_id)
337
+ tool_return_ids.add(tool_call_id)
359
338
 
339
+ mismatched: Set[str] = tool_call_ids.symmetric_difference(tool_return_ids)
360
340
  if not mismatched:
361
341
  return messages
362
342
 
@@ -382,10 +362,7 @@ def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMess
382
362
 
383
363
 
384
364
  def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage]:
385
- # First, deduplicate tool returns to clean up any duplicates
386
- messages = deduplicate_tool_returns(messages)
387
-
388
- # Then, prune any interrupted/mismatched tool-call conversations
365
+ # First, prune any interrupted/mismatched tool-call conversations
389
366
  total_current_tokens = sum(estimate_tokens_for_message(msg) for msg in messages)
390
367
 
391
368
  model_max = get_model_context_length()
@@ -477,8 +454,6 @@ def truncation(
477
454
  messages: List[ModelMessage], protected_tokens: int
478
455
  ) -> List[ModelMessage]:
479
456
  emit_info("Truncating message history to manage token usage")
480
- # First deduplicate tool returns to clean up any duplicates
481
- messages = deduplicate_tool_returns(messages)
482
457
  result = [messages[0]] # Always keep the first message (system prompt)
483
458
  num_tokens = 0
484
459
  stack = queue.LifoQueue()
@@ -501,10 +476,6 @@ def truncation(
501
476
 
502
477
  def message_history_accumulator(messages: List[Any]):
503
478
  _message_history = get_message_history()
504
-
505
- # Deduplicate tool returns in current history before processing new messages
506
- _message_history = deduplicate_tool_returns(_message_history)
507
-
508
479
  message_history_hashes = set([hash_message(m) for m in _message_history])
509
480
  for msg in messages:
510
481
  if (
@@ -513,12 +484,6 @@ def message_history_accumulator(messages: List[Any]):
513
484
  ):
514
485
  _message_history.append(msg)
515
486
 
516
- # Deduplicate tool returns again after adding new messages to ensure no duplicates
517
- _message_history = deduplicate_tool_returns(_message_history)
518
-
519
- # Update the message history with deduplicated messages
520
- set_message_history(_message_history)
521
-
522
487
  # Apply message history trimming using the main processor
523
488
  # This ensures we maintain global state while still managing context limits
524
489
  message_history_processor(_message_history)
@@ -208,17 +208,22 @@ def _list_files(
208
208
  files = result.stdout.strip().split("\n") if result.stdout.strip() else []
209
209
 
210
210
  # Create ListedFile objects with metadata
211
- for file_path in files:
212
- if not file_path: # Skip empty lines
211
+ for full_path in files:
212
+ if not full_path: # Skip empty lines
213
213
  continue
214
214
 
215
- full_path = os.path.join(directory, file_path)
216
-
217
215
  # Skip if file doesn't exist (though it should)
218
216
  if not os.path.exists(full_path):
219
217
  continue
220
218
 
219
+ # Extract relative path from the full path
220
+ if full_path.startswith(directory):
221
+ file_path = full_path[len(directory):].lstrip(os.sep)
222
+ else:
223
+ file_path = full_path
224
+
221
225
  # For non-recursive mode, skip files in subdirectories
226
+ # Only check the relative path, not the full path
222
227
  if not recursive and os.sep in file_path:
223
228
  continue
224
229
 
@@ -242,7 +247,7 @@ def _list_files(
242
247
  if entry_type == "file":
243
248
  size = actual_size
244
249
 
245
- # Calculate depth
250
+ # Calculate depth based on the relative path
246
251
  depth = file_path.count(os.sep)
247
252
 
248
253
  # Add directory entries if needed for files
@@ -281,6 +286,33 @@ def _list_files(
281
286
  except (FileNotFoundError, PermissionError, OSError):
282
287
  # Skip files we can't access
283
288
  continue
289
+
290
+ # In non-recursive mode, we also need to explicitly list directories in the target directory
291
+ # ripgrep's --files option only returns files, not directories
292
+ if not recursive:
293
+ try:
294
+ entries = os.listdir(directory)
295
+ for entry in entries:
296
+ full_entry_path = os.path.join(directory, entry)
297
+ # Skip if it doesn't exist or if it's a file (since files are already listed by ripgrep)
298
+ if not os.path.exists(full_entry_path) or os.path.isfile(full_entry_path):
299
+ continue
300
+
301
+ # For non-recursive mode, only include directories that are directly in the target directory
302
+ if os.path.isdir(full_entry_path):
303
+ # Create a ListedFile for the directory
304
+ results.append(
305
+ ListedFile(
306
+ path=entry,
307
+ type="directory",
308
+ size=0,
309
+ full_path=full_entry_path,
310
+ depth=0,
311
+ )
312
+ )
313
+ except (FileNotFoundError, PermissionError, OSError):
314
+ # Skip directories we can't access
315
+ pass
284
316
  except subprocess.TimeoutExpired:
285
317
  error_msg = (
286
318
  "[red bold]Error:[/red bold] List files command timed out after 30 seconds"
@@ -337,9 +369,12 @@ def _list_files(
337
369
  else:
338
370
  return "\U0001f4c4"
339
371
 
372
+ # Count items in results
340
373
  dir_count = sum(1 for item in results if item.type == "directory")
341
374
  file_count = sum(1 for item in results if item.type == "file")
342
375
  total_size = sum(item.size for item in results if item.type == "file")
376
+
377
+
343
378
 
344
379
  # Build the directory header section
345
380
  dir_name = os.path.basename(directory) or directory
@@ -393,8 +428,8 @@ def _list_files(
393
428
  final_divider = "[dim]" + "─" * 100 + "\n" + "[/dim]"
394
429
  output_lines.append(final_divider)
395
430
 
396
- # Return both the content string and the list of ListedFile objects
397
- return ListFileOutput(content="\n".join(output_lines), files=results)
431
+ # Return the content string
432
+ return ListFileOutput(content="\n".join(output_lines))
398
433
 
399
434
 
400
435
  def _read_file(
@@ -21,8 +21,13 @@ class CustomTextArea(TextArea):
21
21
 
22
22
  def on_key(self, event):
23
23
  """Handle key events before they reach the internal _on_key handler."""
24
- # Explicitly handle escape+enter/alt+enter sequences
25
- if event.key == "escape+enter" or event.key == "alt+enter":
24
+ # Let the binding system handle alt+enter
25
+ if event.key == "alt+enter":
26
+ # Don't prevent default - let the binding system handle it
27
+ return
28
+
29
+ # Handle escape+enter manually
30
+ if event.key == "escape+enter":
26
31
  self.action_insert_newline()
27
32
  event.prevent_default()
28
33
  event.stop()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.165
3
+ Version: 0.0.166
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -5,7 +5,7 @@ code_puppy/callbacks.py,sha256=6wYB6K_fGSCkKKEFaYOYkJT45WaV5W_NhUIzcvVH_nU,5060
5
5
  code_puppy/config.py,sha256=J8XU0iOPtfzrkDD49b4TdrdHoQmW2kaP-25PPbGGdKU,16386
6
6
  code_puppy/http_utils.py,sha256=BAvt4hed7fVMXglA7eS9gOb08h2YTuOyai6VmQq09fg,3432
7
7
  code_puppy/main.py,sha256=tYLfhUjPTJ-4S1r-pr-jSbn6kIU1iYvt2Z8lxI7zDFY,22220
8
- code_puppy/message_history_processor.py,sha256=ZlEMGMGiBdlP_xBmipfPtD9zjp0bt1SbRhlHn89nlPE,20375
8
+ code_puppy/message_history_processor.py,sha256=zzeOSUC1Wpsry-z2MD6pQWk2wt1axGSOKaGR2v22_qQ,18825
9
9
  code_puppy/model_factory.py,sha256=z9vQbcGllgMwU0On8rPvzYxkygW2Uyd3NJmRzbKv-is,13759
10
10
  code_puppy/models.json,sha256=iXmLZGflnQcu2DRh4WUlgAhoXdvoxUc7KBhB8YxawXM,3088
11
11
  code_puppy/reopenable_async_client.py,sha256=4UJRaMp5np8cbef9F0zKQ7TPKOfyf5U-Kv-0zYUWDho,8274
@@ -81,7 +81,7 @@ code_puppy/tools/agent_tools.py,sha256=bHMrFIbYRhuubR41G_XdLsk3cUKWfIPl2O4bVzo2p
81
81
  code_puppy/tools/command_runner.py,sha256=6mGO90xks2OwJCEvwrsrNf3lRfcKXrMeLbkJEpX5bMw,20857
82
82
  code_puppy/tools/common.py,sha256=pL-9xcRs3rxU7Fl9X9EUgbDp2-csh2LLJ5DHH_KAHKY,10596
83
83
  code_puppy/tools/file_modifications.py,sha256=EaDWcv6gi8wAvpgyeJdKSKPWg9fTpZoEkxQiLCE6rn4,23218
84
- code_puppy/tools/file_operations.py,sha256=Cxa5bqbVNwgViSdBjwGOrqWmtxBPhQRjDBepgt2Xuxk,30852
84
+ code_puppy/tools/file_operations.py,sha256=dEnsGCbDF12ctegCm9Kiu-mgNCrvopf64ij_CQbikW4,32460
85
85
  code_puppy/tools/tools_content.py,sha256=bsBqW-ppd1XNAS_g50B3UHDQBWEALC1UneH6-afz1zo,2365
86
86
  code_puppy/tui/__init__.py,sha256=XesAxIn32zLPOmvpR2wIDxDAnnJr81a5pBJB4cZp1Xs,321
87
87
  code_puppy/tui/app.py,sha256=nPOzwlusjdWzBfu__EbC3Q0etkPrqRq-2g-mk4IcfG4,39378
@@ -90,7 +90,7 @@ code_puppy/tui/components/__init__.py,sha256=uj5pnk3s6SEN3SbFI0ZnzaA2KK1NNg8TfUj
90
90
  code_puppy/tui/components/chat_view.py,sha256=NfyNXuN2idPht1rKJB4YhHVXb1AIRNO5q_nLdt8Ocug,19913
91
91
  code_puppy/tui/components/command_history_modal.py,sha256=pUPEQvoCWa2iUnuMgNwO22y8eUbyw0HpcPH3wAosHvU,7097
92
92
  code_puppy/tui/components/copy_button.py,sha256=E4-OJYk5YNzDf-E81NyiVGKsTRPrUX-RnQ8qFuVnabw,4375
93
- code_puppy/tui/components/custom_widgets.py,sha256=pnjkB3ZNa5lwSrAXUFlhN9AHNh4uMTpSap8AdbpecKw,1986
93
+ code_puppy/tui/components/custom_widgets.py,sha256=y-ZodibwL_GNaWntmFMn7eeC93wJNss590D43rGMO6A,2122
94
94
  code_puppy/tui/components/human_input_modal.py,sha256=isj-zrSIcK5iy3L7HJNgDFWN1zhxY4f3zvp4krbs07E,5424
95
95
  code_puppy/tui/components/input_area.py,sha256=R4R32eXPZ2R8KFisIbldNGq60KMk7kCxWrdbeTgJUr8,4395
96
96
  code_puppy/tui/components/sidebar.py,sha256=nGtCiYzZalPmiFaJ4dwj2S4EJBu5wQZVzhoigYYY7U4,10369
@@ -104,9 +104,9 @@ code_puppy/tui/screens/help.py,sha256=eJuPaOOCp7ZSUlecearqsuX6caxWv7NQszUh0tZJjB
104
104
  code_puppy/tui/screens/mcp_install_wizard.py,sha256=xqwN5omltMkfxWZwXj3D2PbXbtrxUi1dT0XT77oxOKk,27685
105
105
  code_puppy/tui/screens/settings.py,sha256=GMpv-qa08rorAE9mj3AjmqjZFPhmeJ_GWd-DBHG6iAA,10671
106
106
  code_puppy/tui/screens/tools.py,sha256=3pr2Xkpa9Js6Yhf1A3_wQVRzFOui-KDB82LwrsdBtyk,1715
107
- code_puppy-0.0.165.data/data/code_puppy/models.json,sha256=iXmLZGflnQcu2DRh4WUlgAhoXdvoxUc7KBhB8YxawXM,3088
108
- code_puppy-0.0.165.dist-info/METADATA,sha256=IHbmXg1BoiexNSYBnNpjLyNCmPVYopMvIKwMZfaiCVk,19567
109
- code_puppy-0.0.165.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
110
- code_puppy-0.0.165.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
111
- code_puppy-0.0.165.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
112
- code_puppy-0.0.165.dist-info/RECORD,,
107
+ code_puppy-0.0.166.data/data/code_puppy/models.json,sha256=iXmLZGflnQcu2DRh4WUlgAhoXdvoxUc7KBhB8YxawXM,3088
108
+ code_puppy-0.0.166.dist-info/METADATA,sha256=c29vfRCx1k-2GYwUHULhuw1AGoJGNCAF5sDSxVUdWbk,19567
109
+ code_puppy-0.0.166.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
110
+ code_puppy-0.0.166.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
111
+ code_puppy-0.0.166.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
112
+ code_puppy-0.0.166.dist-info/RECORD,,