cortex-llm 1.0.9__tar.gz → 1.0.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/PKG-INFO +3 -1
  2. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/README.md +2 -0
  3. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/__init__.py +1 -1
  4. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/config.py +46 -10
  5. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/inference_engine.py +69 -32
  6. cortex_llm-1.0.11/cortex/tools/__init__.py +5 -0
  7. cortex_llm-1.0.11/cortex/tools/errors.py +9 -0
  8. cortex_llm-1.0.11/cortex/tools/fs_ops.py +182 -0
  9. cortex_llm-1.0.11/cortex/tools/protocol.py +76 -0
  10. cortex_llm-1.0.11/cortex/tools/search.py +135 -0
  11. cortex_llm-1.0.11/cortex/tools/tool_runner.py +204 -0
  12. cortex_llm-1.0.11/cortex/ui/box_rendering.py +97 -0
  13. cortex_llm-1.0.11/cortex/ui/cli.py +804 -0
  14. cortex_llm-1.0.11/cortex/ui/cli_commands.py +61 -0
  15. cortex_llm-1.0.11/cortex/ui/cli_prompt.py +96 -0
  16. cortex_llm-1.0.11/cortex/ui/help_ui.py +66 -0
  17. cortex_llm-1.0.11/cortex/ui/input_box.py +205 -0
  18. cortex_llm-1.0.11/cortex/ui/model_ui.py +408 -0
  19. cortex_llm-1.0.11/cortex/ui/status_ui.py +78 -0
  20. cortex_llm-1.0.11/cortex/ui/tool_activity.py +82 -0
  21. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/PKG-INFO +3 -1
  22. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/SOURCES.txt +17 -1
  23. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/pyproject.toml +2 -2
  24. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/setup.py +1 -1
  25. cortex_llm-1.0.11/tests/test_stream_normalizer.py +42 -0
  26. cortex_llm-1.0.11/tests/test_tools.py +163 -0
  27. cortex_llm-1.0.9/cortex/ui/cli.py +0 -1810
  28. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/LICENSE +0 -0
  29. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/__main__.py +0 -0
  30. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/conversation_manager.py +0 -0
  31. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/fine_tuning/__init__.py +0 -0
  32. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/fine_tuning/dataset.py +0 -0
  33. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/fine_tuning/mlx_lora_trainer.py +0 -0
  34. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/fine_tuning/trainer.py +0 -0
  35. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/fine_tuning/wizard.py +0 -0
  36. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/gpu_validator.py +0 -0
  37. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/__init__.py +0 -0
  38. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/gpu_validator.py +0 -0
  39. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/memory_pool.py +0 -0
  40. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/mlx_accelerator.py +0 -0
  41. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/mlx_compat.py +0 -0
  42. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/mlx_converter.py +0 -0
  43. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/mps_optimizer.py +0 -0
  44. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/optimizer.py +0 -0
  45. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/metal/performance_profiler.py +0 -0
  46. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/model_downloader.py +0 -0
  47. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/model_manager.py +0 -0
  48. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/quantization/__init__.py +0 -0
  49. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/quantization/dynamic_quantizer.py +0 -0
  50. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/__init__.py +0 -0
  51. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/auto_detector.py +0 -0
  52. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/config_manager.py +0 -0
  53. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/interactive.py +0 -0
  54. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/registry.py +0 -0
  55. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/__init__.py +0 -0
  56. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/base.py +0 -0
  57. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/complex/__init__.py +0 -0
  58. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/complex/reasoning.py +0 -0
  59. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/standard/__init__.py +0 -0
  60. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/standard/alpaca.py +0 -0
  61. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/standard/chatml.py +0 -0
  62. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/standard/gemma.py +0 -0
  63. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/standard/llama.py +0 -0
  64. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/template_registry/template_profiles/standard/simple.py +0 -0
  65. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/ui/__init__.py +0 -0
  66. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/ui/markdown_render.py +0 -0
  67. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex/ui/terminal_app.py +0 -0
  68. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/dependency_links.txt +0 -0
  69. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/entry_points.txt +0 -0
  70. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/not-zip-safe +0 -0
  71. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/requires.txt +0 -0
  72. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/cortex_llm.egg-info/top_level.txt +0 -0
  73. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/setup.cfg +0 -0
  74. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/tests/test_apple_silicon.py +0 -0
  75. {cortex_llm-1.0.9 → cortex_llm-1.0.11}/tests/test_metal_optimization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-llm
3
- Version: 1.0.9
3
+ Version: 1.0.11
4
4
  Summary: GPU-Accelerated LLM Terminal for Apple Silicon
5
5
  Home-page: https://github.com/faisalmumtaz/Cortex
6
6
  Author: Cortex Development Team
@@ -60,6 +60,8 @@ Dynamic: requires-python
60
60
 
61
61
  GPU-accelerated local LLMs on Apple Silicon, built for the terminal.
62
62
 
63
+ ![Cortex preview](docs/assets/cortex-llm.png)
64
+
63
65
  Cortex is a fast, native CLI for running and fine-tuning LLMs on Apple Silicon using MLX and Metal. It automatically detects chat templates, supports multiple model formats, and keeps your workflow inside the terminal.
64
66
 
65
67
  ## Highlights
@@ -2,6 +2,8 @@
2
2
 
3
3
  GPU-accelerated local LLMs on Apple Silicon, built for the terminal.
4
4
 
5
+ ![Cortex preview](docs/assets/cortex-llm.png)
6
+
5
7
  Cortex is a fast, native CLI for running and fine-tuning LLMs on Apple Silicon using MLX and Metal. It automatically detects chat templates, supports multiple model formats, and keeps your workflow inside the terminal.
6
8
 
7
9
  ## Highlights
@@ -5,7 +5,7 @@ A high-performance terminal interface for running Hugging Face LLMs locally
5
5
  with exclusive GPU acceleration via Metal Performance Shaders (MPS) and MLX.
6
6
  """
7
7
 
8
- __version__ = "1.0.9"
8
+ __version__ = "1.0.11"
9
9
  __author__ = "Cortex Development Team"
10
10
  __license__ = "MIT"
11
11
 
@@ -146,18 +146,21 @@ class DeveloperConfig(BaseModel):
146
146
 
147
147
  class PathsConfig(BaseModel):
148
148
  """Path configuration."""
149
- claude_md_path: Path = Field(default_factory=lambda: Path("./CLAUDE.md"))
150
149
  templates_dir: Path = Field(default_factory=lambda: Path.home() / ".cortex" / "templates")
151
150
  plugins_dir: Path = Field(default_factory=lambda: Path.home() / ".cortex" / "plugins")
152
151
 
153
152
  class Config:
154
153
  """Main configuration class for Cortex."""
155
-
154
+
155
+ # State file for runtime state (not committed to git)
156
+ STATE_FILE = Path.home() / ".cortex" / "state.yaml"
157
+
156
158
  def __init__(self, config_path: Optional[Path] = None):
157
159
  """Initialize configuration."""
158
160
  self.config_path = config_path or Path("config.yaml")
159
161
  self._raw_config: Dict[str, Any] = {}
160
-
162
+ self._state: Dict[str, Any] = {}
163
+
161
164
  self.gpu: GPUConfig
162
165
  self.memory: MemoryConfig
163
166
  self.performance: PerformanceConfig
@@ -169,8 +172,9 @@ class Config:
169
172
  self.system: SystemConfig
170
173
  self.developer: DeveloperConfig
171
174
  self.paths: PathsConfig
172
-
175
+
173
176
  self.load()
177
+ self._load_state()
174
178
 
175
179
  def load(self) -> None:
176
180
  """Load configuration from YAML file."""
@@ -273,7 +277,7 @@ class Config:
273
277
 
274
278
  self.paths = PathsConfig(**self._get_section({
275
279
  k: v for k, v in self._raw_config.items()
276
- if k in ["claude_md_path", "templates_dir", "plugins_dir"]
280
+ if k in ["templates_dir", "plugins_dir"]
277
281
  }))
278
282
 
279
283
  except Exception as e:
@@ -303,26 +307,58 @@ class Config:
303
307
  def save(self, path: Optional[Path] = None) -> None:
304
308
  """Save configuration to YAML file."""
305
309
  save_path = path or self.config_path
306
-
310
+
311
+ # Keys that belong in state file, not config file
312
+ state_keys = {"last_used_model"}
313
+
307
314
  # Convert Path objects to strings for YAML serialization
308
315
  config_dict = {}
309
316
  for section in [self.gpu, self.memory, self.performance, self.inference,
310
317
  self.model, self.ui, self.logging, self.conversation,
311
318
  self.system, self.developer, self.paths]:
312
319
  section_dict = section.model_dump()
313
- # Convert Path objects to strings
320
+ # Convert Path objects to strings and exclude state keys
314
321
  for key, value in section_dict.items():
322
+ if key in state_keys:
323
+ continue # Skip state keys - they go in state file
315
324
  if isinstance(value, Path):
316
325
  section_dict[key] = str(value)
326
+ # Remove state keys from section_dict
327
+ for key in state_keys:
328
+ section_dict.pop(key, None)
317
329
  config_dict.update(section_dict)
318
-
330
+
319
331
  with open(save_path, 'w') as f:
320
332
  yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False)
321
333
 
334
+ def _load_state(self) -> None:
335
+ """Load runtime state from state file."""
336
+ if self.STATE_FILE.exists():
337
+ try:
338
+ with open(self.STATE_FILE, 'r') as f:
339
+ self._state = yaml.safe_load(f) or {}
340
+ # Apply state to model config
341
+ if "last_used_model" in self._state:
342
+ self.model.last_used_model = self._state["last_used_model"]
343
+ except Exception as e:
344
+ print(f"Warning: Failed to load state from {self.STATE_FILE}: {e}")
345
+ self._state = {}
346
+
347
+ def _save_state(self) -> None:
348
+ """Save runtime state to state file."""
349
+ # Ensure directory exists
350
+ self.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
351
+ try:
352
+ with open(self.STATE_FILE, 'w') as f:
353
+ yaml.dump(self._state, f, default_flow_style=False)
354
+ except Exception as e:
355
+ print(f"Warning: Failed to save state to {self.STATE_FILE}: {e}")
356
+
322
357
  def update_last_used_model(self, model_name: str) -> None:
323
- """Update the last used model and save to config file."""
358
+ """Update the last used model and save to state file."""
324
359
  self.model.last_used_model = model_name
325
- self.save()
360
+ self._state["last_used_model"] = model_name
361
+ self._save_state()
326
362
 
327
363
  def __repr__(self) -> str:
328
364
  """String representation."""
@@ -82,6 +82,62 @@ class GenerationRequest:
82
82
  if self.stop_sequences is None:
83
83
  self.stop_sequences = []
84
84
 
85
+
86
+ class StreamDeltaNormalizer:
87
+ """Normalize streaming chunks to deltas, handling cumulative or overlapping output."""
88
+
89
+ def __init__(self, max_overlap: int = 4096, min_cumulative_length: int = 32) -> None:
90
+ self._total_text = ""
91
+ self._max_overlap = max_overlap
92
+ self._min_cumulative_length = min_cumulative_length
93
+ self._cumulative_mode = False
94
+
95
+ def normalize(self, chunk: Any) -> str:
96
+ if chunk is None:
97
+ return ""
98
+ if not isinstance(chunk, str):
99
+ chunk = str(chunk)
100
+ if not chunk:
101
+ return ""
102
+
103
+ if not self._total_text:
104
+ self._total_text = chunk
105
+ return chunk
106
+
107
+ if not self._cumulative_mode:
108
+ if len(chunk) > len(self._total_text) and chunk.startswith(self._total_text):
109
+ self._cumulative_mode = True
110
+ delta = chunk[len(self._total_text):]
111
+ self._total_text = chunk
112
+ return delta
113
+ if chunk == self._total_text and len(chunk) >= self._min_cumulative_length:
114
+ # Likely a cumulative stream repeating the full text; don't re-emit.
115
+ self._cumulative_mode = True
116
+ return ""
117
+ # Default to delta mode to avoid dropping legitimate repeats.
118
+ self._total_text += chunk
119
+ return chunk
120
+
121
+ # Cumulative mode: emit only new suffix.
122
+ if chunk.startswith(self._total_text):
123
+ delta = chunk[len(self._total_text):]
124
+ self._total_text = chunk
125
+ return delta
126
+
127
+ # Handle partial overlap in cumulative streams.
128
+ max_overlap = min(len(self._total_text), len(chunk), self._max_overlap)
129
+ if max_overlap > 0:
130
+ tail = self._total_text[-max_overlap:]
131
+ for i in range(max_overlap, 0, -1):
132
+ if tail[-i:] == chunk[:i]:
133
+ delta = chunk[i:]
134
+ self._total_text += delta
135
+ return delta
136
+
137
+ # Fallback: treat as fresh delta to avoid loss.
138
+ self._total_text += chunk
139
+ return chunk
140
+
85
141
  class InferenceEngine:
86
142
  """GPU-accelerated inference engine."""
87
143
 
@@ -243,33 +299,7 @@ class InferenceEngine:
243
299
  tokens_generated = 0
244
300
  first_token_time = None
245
301
  last_metrics_update = time.time()
246
- stream_total_text = ""
247
- stream_cumulative = False
248
-
249
- def normalize_stream_chunk(chunk: Any) -> str:
250
- """Normalize streaming output to delta chunks when backend yields cumulative text."""
251
- nonlocal stream_total_text, stream_cumulative
252
- if chunk is None:
253
- return ""
254
- if not isinstance(chunk, str):
255
- chunk = str(chunk)
256
-
257
- if stream_cumulative:
258
- if chunk.startswith(stream_total_text):
259
- delta = chunk[len(stream_total_text):]
260
- stream_total_text = chunk
261
- return delta
262
- stream_total_text += chunk
263
- return chunk
264
-
265
- if stream_total_text and len(chunk) > len(stream_total_text) and chunk.startswith(stream_total_text):
266
- stream_cumulative = True
267
- delta = chunk[len(stream_total_text):]
268
- stream_total_text = chunk
269
- return delta
270
-
271
- stream_total_text += chunk
272
- return chunk
302
+ normalizer = StreamDeltaNormalizer() if request.stream else None
273
303
 
274
304
  try:
275
305
  # Use MLX accelerator's optimized generation if available
@@ -290,7 +320,7 @@ class InferenceEngine:
290
320
  self.status = InferenceStatus.CANCELLED
291
321
  break
292
322
 
293
- delta = normalize_stream_chunk(token) if request.stream else str(token)
323
+ delta = normalizer.normalize(token) if normalizer else str(token)
294
324
  if not delta:
295
325
  continue
296
326
 
@@ -365,7 +395,7 @@ class InferenceEngine:
365
395
  else:
366
396
  token = str(response)
367
397
 
368
- delta = normalize_stream_chunk(token) if request.stream else token
398
+ delta = normalizer.normalize(token) if normalizer else token
369
399
  if request.stream and not delta:
370
400
  continue
371
401
 
@@ -477,6 +507,7 @@ class InferenceEngine:
477
507
  if request.stream:
478
508
  from transformers import TextIteratorStreamer
479
509
 
510
+ normalizer = StreamDeltaNormalizer()
480
511
  streamer = TextIteratorStreamer(
481
512
  tokenizer,
482
513
  skip_prompt=True,
@@ -499,6 +530,10 @@ class InferenceEngine:
499
530
  if self._cancel_event.is_set():
500
531
  self.status = InferenceStatus.CANCELLED
501
532
  break
533
+
534
+ delta = normalizer.normalize(token)
535
+ if not delta:
536
+ continue
502
537
 
503
538
  if first_token_time is None:
504
539
  first_token_time = time.time() - start_time
@@ -523,7 +558,7 @@ class InferenceEngine:
523
558
  )
524
559
  last_metrics_update = current_time
525
560
 
526
- yield token
561
+ yield delta
527
562
 
528
563
  if any(stop in token for stop in request.stop_sequences):
529
564
  break
@@ -603,6 +638,7 @@ class InferenceEngine:
603
638
  )
604
639
 
605
640
  if request.stream:
641
+ normalizer = StreamDeltaNormalizer()
606
642
  # Stream tokens
607
643
  for chunk in response:
608
644
  if self._cancel_event.is_set():
@@ -610,11 +646,12 @@ class InferenceEngine:
610
646
 
611
647
  if 'choices' in chunk and len(chunk['choices']) > 0:
612
648
  token = chunk['choices'][0].get('text', '')
613
- if token:
649
+ delta = normalizer.normalize(token)
650
+ if delta:
614
651
  if first_token_time is None:
615
652
  first_token_time = time.time()
616
653
  tokens_generated += 1
617
- yield token
654
+ yield delta
618
655
  else:
619
656
  # Return full response
620
657
  if 'choices' in response and len(response['choices']) > 0:
@@ -0,0 +1,5 @@
1
+ """Tooling support for Cortex CLI."""
2
+
3
+ from cortex.tools.tool_runner import ToolRunner
4
+
5
+ __all__ = ["ToolRunner"]
@@ -0,0 +1,9 @@
1
+ """Tooling error types."""
2
+
3
+
4
+ class ToolError(Exception):
5
+ """Base error for tool execution failures."""
6
+
7
+
8
+ class ValidationError(ToolError):
9
+ """Raised when tool arguments or inputs are invalid."""
@@ -0,0 +1,182 @@
1
+ """Filesystem operations scoped to a repo root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ from cortex.tools.errors import ToolError, ValidationError
13
+
14
+
15
+ class RepoFS:
16
+ """Filesystem helper constrained to a single repo root."""
17
+
18
+ def __init__(self, root: Path) -> None:
19
+ self.root = Path(root).expanduser().resolve()
20
+
21
+ def resolve_path(self, path: str) -> Path:
22
+ if not path or not isinstance(path, str):
23
+ raise ValidationError("path must be a non-empty string")
24
+ if "\x00" in path:
25
+ raise ValidationError("path contains null byte")
26
+ if path.startswith("~"):
27
+ raise ValidationError("path must be repo-relative (no ~)")
28
+ raw = Path(path)
29
+ if raw.is_absolute():
30
+ raise ValidationError("path must be repo-relative (no absolute paths)")
31
+ resolved = (self.root / raw).resolve()
32
+ if not resolved.is_relative_to(self.root):
33
+ raise ValidationError(f"path escapes repo root ({self.root}); use a relative path like '.'")
34
+ return resolved
35
+
36
+ def _validate_bool(self, name: str, value: object) -> None:
37
+ if not isinstance(value, bool):
38
+ raise ValidationError(f"{name} must be a bool")
39
+
40
+ def _validate_int(self, name: str, value: object, minimum: int = 0) -> int:
41
+ if isinstance(value, bool) or not isinstance(value, int):
42
+ raise ValidationError(f"{name} must be an int")
43
+ if value < minimum:
44
+ raise ValidationError(f"{name} must be >= {minimum}")
45
+ return value
46
+
47
+ def _validate_content(self, content: object) -> None:
48
+ if not isinstance(content, str):
49
+ raise ValidationError("content must be a string")
50
+
51
+ def _validate_sha256(self, value: object) -> str:
52
+ if not isinstance(value, str):
53
+ raise ValidationError("expected_sha256 must be a string")
54
+ normalized = value.lower()
55
+ if not re.fullmatch(r"[0-9a-f]{64}", normalized):
56
+ raise ValidationError("expected_sha256 must be a 64-character hex string")
57
+ return normalized
58
+
59
+ def list_dir(self, path: str = ".", recursive: bool = False, max_depth: int = 2, max_entries: int = 200) -> Dict[str, List[str]]:
60
+ self._validate_bool("recursive", recursive)
61
+ max_depth = self._validate_int("max_depth", max_depth, minimum=0)
62
+ max_entries = self._validate_int("max_entries", max_entries, minimum=1)
63
+ target = self.resolve_path(path)
64
+ if not target.is_dir():
65
+ raise ValidationError("path is not a directory")
66
+ entries: List[str] = []
67
+ if not recursive:
68
+ for item in sorted(target.iterdir()):
69
+ rel = item.relative_to(self.root)
70
+ suffix = "/" if item.is_dir() else ""
71
+ entries.append(f"{rel}{suffix}")
72
+ if len(entries) >= max_entries:
73
+ break
74
+ return {"entries": entries}
75
+
76
+ base_depth = len(target.relative_to(self.root).parts)
77
+ for dirpath, dirnames, filenames in os.walk(target):
78
+ depth = len(Path(dirpath).relative_to(self.root).parts) - base_depth
79
+ if depth > max_depth:
80
+ dirnames[:] = []
81
+ continue
82
+ for name in sorted(dirnames):
83
+ rel = (Path(dirpath) / name).relative_to(self.root)
84
+ entries.append(f"{rel}/")
85
+ if len(entries) >= max_entries:
86
+ return {"entries": entries}
87
+ for name in sorted(filenames):
88
+ rel = (Path(dirpath) / name).relative_to(self.root)
89
+ entries.append(str(rel))
90
+ if len(entries) >= max_entries:
91
+ return {"entries": entries}
92
+ return {"entries": entries}
93
+
94
+ def read_text(self, path: str, start_line: int = 1, end_line: Optional[int] = None, max_bytes: int = 2_000_000) -> Dict[str, object]:
95
+ start_line = self._validate_int("start_line", start_line, minimum=1)
96
+ if end_line is not None:
97
+ end_line = self._validate_int("end_line", end_line, minimum=start_line)
98
+ max_bytes = self._validate_int("max_bytes", max_bytes, minimum=1)
99
+ target = self.resolve_path(path)
100
+ if not target.is_file():
101
+ raise ValidationError("path is not a file")
102
+ size = target.stat().st_size
103
+ if size > max_bytes and start_line == 1 and end_line is None:
104
+ raise ToolError("file too large; specify a line range")
105
+
106
+ lines: List[str] = []
107
+ try:
108
+ with target.open("r", encoding="utf-8") as handle:
109
+ for idx, line in enumerate(handle, start=1):
110
+ if idx < start_line:
111
+ continue
112
+ if end_line is not None and idx > end_line:
113
+ break
114
+ lines.append(line.rstrip("\n"))
115
+ except UnicodeDecodeError as e:
116
+ raise ToolError(f"file is not valid utf-8: {e}") from e
117
+ content = "\n".join(lines)
118
+ return {"path": str(target.relative_to(self.root)), "content": content, "start_line": start_line, "end_line": end_line}
119
+
120
+ def read_full_text(self, path: str) -> str:
121
+ target = self.resolve_path(path)
122
+ if not target.is_file():
123
+ raise ValidationError("path is not a file")
124
+ try:
125
+ return target.read_text(encoding="utf-8")
126
+ except UnicodeDecodeError as e:
127
+ raise ToolError(f"file is not valid utf-8: {e}") from e
128
+
129
+ def write_text(self, path: str, content: str, expected_sha256: Optional[str] = None) -> Dict[str, object]:
130
+ self._validate_content(content)
131
+ if expected_sha256 is not None:
132
+ expected_sha256 = self._validate_sha256(expected_sha256)
133
+ target = self.resolve_path(path)
134
+ if not target.exists() or not target.is_file():
135
+ raise ValidationError("path does not exist or is not a file")
136
+ if expected_sha256:
137
+ current = self.read_full_text(path)
138
+ if self.sha256_text(current) != expected_sha256:
139
+ raise ToolError("file changed; expected hash does not match")
140
+ target.write_text(content, encoding="utf-8")
141
+ return {"path": str(target.relative_to(self.root)), "sha256": self.sha256_text(content)}
142
+
143
+ def create_text(self, path: str, content: str, overwrite: bool = False) -> Dict[str, object]:
144
+ self._validate_content(content)
145
+ self._validate_bool("overwrite", overwrite)
146
+ target = self.resolve_path(path)
147
+ if target.exists() and target.is_dir():
148
+ raise ValidationError("path already exists and is a directory")
149
+ if target.exists() and not overwrite:
150
+ raise ValidationError("path already exists")
151
+ target.parent.mkdir(parents=True, exist_ok=True)
152
+ target.write_text(content, encoding="utf-8")
153
+ return {"path": str(target.relative_to(self.root)), "sha256": self.sha256_text(content)}
154
+
155
+ def delete_file(self, path: str) -> Dict[str, object]:
156
+ target = self.resolve_path(path)
157
+ if not target.exists() or not target.is_file():
158
+ raise ValidationError("path does not exist or is not a file")
159
+ if not self._is_git_tracked(target):
160
+ raise ToolError("delete blocked: file is not tracked by git")
161
+ target.unlink()
162
+ return {"path": str(target.relative_to(self.root)), "deleted": True}
163
+
164
+ def is_git_tracked(self, target: Path) -> bool:
165
+ """Return True if the path is tracked by git."""
166
+ return self._is_git_tracked(target)
167
+
168
+ def sha256_text(self, content: str) -> str:
169
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
170
+
171
+ def _is_git_tracked(self, target: Path) -> bool:
172
+ git_dir = self.root / ".git"
173
+ if not git_dir.exists():
174
+ return False
175
+ rel = str(target.relative_to(self.root))
176
+ result = subprocess.run(
177
+ ["git", "ls-files", "--error-unmatch", rel],
178
+ cwd=self.root,
179
+ capture_output=True,
180
+ text=True,
181
+ )
182
+ return result.returncode == 0
@@ -0,0 +1,76 @@
1
+ """Protocol helpers for tool calling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ TOOL_CALLS_START = "<tool_calls>"
9
+ TOOL_CALLS_END = "</tool_calls>"
10
+ TOOL_RESULTS_START = "<tool_results>"
11
+ TOOL_RESULTS_END = "</tool_results>"
12
+
13
+
14
+ def find_tool_calls_block(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
15
+ """Return (start, end, block) for tool_calls JSON, if present."""
16
+ start = text.find(TOOL_CALLS_START)
17
+ if start == -1:
18
+ return None, None, None
19
+ end = text.find(TOOL_CALLS_END, start + len(TOOL_CALLS_START))
20
+ if end == -1:
21
+ return start, None, None
22
+ block = text[start + len(TOOL_CALLS_START) : end].strip()
23
+ return start, end + len(TOOL_CALLS_END), block
24
+
25
+
26
+ def strip_tool_blocks(text: str) -> str:
27
+ """Remove tool_calls block from text (including incomplete block)."""
28
+ start, end, _ = find_tool_calls_block(text)
29
+ if start is None:
30
+ return text
31
+ if end is None:
32
+ return text[:start]
33
+ return text[:start] + text[end:]
34
+
35
+
36
+ def parse_tool_calls(text: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
37
+ """Parse tool calls from text. Returns (calls, error)."""
38
+ start, end, block = find_tool_calls_block(text)
39
+ if start is None:
40
+ return [], None
41
+ if end is None or block is None:
42
+ return [], "tool_calls block is incomplete"
43
+ try:
44
+ payload = json.loads(block)
45
+ except json.JSONDecodeError as e:
46
+ return [], f"invalid tool_calls JSON: {e}"
47
+
48
+ if not isinstance(payload, dict):
49
+ return [], "tool_calls payload must be a JSON object"
50
+ calls = payload.get("calls")
51
+ if not isinstance(calls, list):
52
+ return [], "tool_calls payload missing 'calls' list"
53
+
54
+ normalized: List[Dict[str, Any]] = []
55
+ for idx, call in enumerate(calls):
56
+ if not isinstance(call, dict):
57
+ return [], f"tool call at index {idx} must be an object"
58
+ name = call.get("name")
59
+ arguments = call.get("arguments")
60
+ call_id = call.get("id") or f"call_{idx + 1}"
61
+ if not isinstance(name, str) or not name.strip():
62
+ return [], f"tool call at index {idx} missing valid name"
63
+ if arguments is None:
64
+ arguments = {}
65
+ if not isinstance(arguments, dict):
66
+ return [], f"tool call '{name}' arguments must be an object"
67
+ normalized.append({"id": str(call_id), "name": name, "arguments": arguments})
68
+
69
+ return normalized, None
70
+
71
+
72
+ def format_tool_results(results: List[Dict[str, Any]]) -> str:
73
+ """Format tool results for model consumption."""
74
+ payload = {"results": results}
75
+ body = json.dumps(payload, ensure_ascii=True)
76
+ return f"{TOOL_RESULTS_START}\n{body}\n{TOOL_RESULTS_END}"