axion-code 1.0.0__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 (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,613 @@
1
+ """File operations: read, write, edit, glob search, grep search.
2
+
3
+ Maps to: rust/crates/runtime/src/file_ops.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import difflib
9
+ import fnmatch
10
+ import os
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ # Limits matching Rust
17
+ MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MiB
18
+ MAX_WRITE_SIZE = 10 * 1024 * 1024 # 10 MiB
19
+ DEFAULT_HEAD_LIMIT = 250
20
+ DEFAULT_GLOB_LIMIT = 100
21
+ BINARY_CHECK_SIZE = 8192 # 8 KiB
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Binary detection
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def is_binary_file(path: Path, check_size: int = BINARY_CHECK_SIZE) -> bool:
29
+ """Check if a file appears to be binary (contains NUL bytes in first 8 KiB)."""
30
+ try:
31
+ with open(path, "rb") as f:
32
+ chunk = f.read(check_size)
33
+ return b"\x00" in chunk
34
+ except OSError:
35
+ return False
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Workspace boundary validation
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def validate_workspace_boundary(file_path: Path, workspace_root: Path | None = None) -> None:
43
+ """Validate that a file path doesn't escape the workspace boundary."""
44
+ if workspace_root is None:
45
+ return
46
+ try:
47
+ resolved = file_path.resolve()
48
+ root_resolved = workspace_root.resolve()
49
+ if not str(resolved).startswith(str(root_resolved)):
50
+ raise PermissionError(
51
+ f"Path {file_path} escapes workspace boundary {workspace_root}"
52
+ )
53
+ except OSError:
54
+ pass
55
+
56
+
57
+ def is_symlink_escape(path: Path, workspace_root: Path) -> bool:
58
+ """Check if a path is a symlink that escapes the workspace."""
59
+ try:
60
+ if path.is_symlink():
61
+ target = path.resolve()
62
+ root = workspace_root.resolve()
63
+ return not str(target).startswith(str(root))
64
+ except OSError:
65
+ pass
66
+ return False
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Patch generation
71
+ # ---------------------------------------------------------------------------
72
+
73
+ @dataclass
74
+ class StructuredPatchHunk:
75
+ old_start: int
76
+ old_lines: int
77
+ new_start: int
78
+ new_lines: int
79
+ lines: list[str]
80
+
81
+
82
+ def make_patch(original: str, modified: str, filename: str = "") -> list[StructuredPatchHunk]:
83
+ """Generate unified diff hunks between original and modified text."""
84
+ orig_lines = original.splitlines(keepends=True)
85
+ mod_lines = modified.splitlines(keepends=True)
86
+
87
+ matcher = difflib.SequenceMatcher(None, orig_lines, mod_lines)
88
+ hunks: list[StructuredPatchHunk] = []
89
+
90
+ for group in matcher.get_grouped_opcodes(3):
91
+ hunk_lines: list[str] = []
92
+ old_start = group[0][1] + 1
93
+ old_end = group[-1][2]
94
+ new_start = group[0][3] + 1
95
+ new_end = group[-1][4]
96
+
97
+ for tag, i1, i2, j1, j2 in group:
98
+ if tag == "equal":
99
+ for line in orig_lines[i1:i2]:
100
+ hunk_lines.append(" " + line.rstrip("\n"))
101
+ elif tag == "delete":
102
+ for line in orig_lines[i1:i2]:
103
+ hunk_lines.append("-" + line.rstrip("\n"))
104
+ elif tag == "insert":
105
+ for line in mod_lines[j1:j2]:
106
+ hunk_lines.append("+" + line.rstrip("\n"))
107
+ elif tag == "replace":
108
+ for line in orig_lines[i1:i2]:
109
+ hunk_lines.append("-" + line.rstrip("\n"))
110
+ for line in mod_lines[j1:j2]:
111
+ hunk_lines.append("+" + line.rstrip("\n"))
112
+
113
+ hunks.append(StructuredPatchHunk(
114
+ old_start=old_start,
115
+ old_lines=old_end - old_start + 1,
116
+ new_start=new_start,
117
+ new_lines=new_end - new_start + 1,
118
+ lines=hunk_lines,
119
+ ))
120
+
121
+ return hunks
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Read file
126
+ # ---------------------------------------------------------------------------
127
+
128
+ @dataclass
129
+ class ReadFileOutput:
130
+ file_path: str
131
+ content: str
132
+ num_lines: int
133
+ start_line: int = 1
134
+ total_lines: int = 0
135
+ kind: str = "text"
136
+
137
+
138
+ def read_file(
139
+ file_path: str,
140
+ start_line: int | None = None,
141
+ end_line: int | None = None,
142
+ max_size: int = MAX_READ_SIZE,
143
+ ) -> ReadFileOutput:
144
+ """Read a file, optionally returning a line range.
145
+
146
+ Lines are 1-indexed. Output includes cat -n style line numbers.
147
+ Rejects binary files and files exceeding size limit.
148
+ """
149
+ path = Path(file_path)
150
+ if not path.exists():
151
+ raise FileNotFoundError(f"File not found: {file_path}")
152
+ if path.is_dir():
153
+ raise IsADirectoryError(f"Path is a directory: {file_path}")
154
+
155
+ # Size check
156
+ file_size = path.stat().st_size
157
+ if file_size > max_size:
158
+ raise ValueError(
159
+ f"File too large: {file_size:,} bytes (max: {max_size:,} bytes). "
160
+ f"Use offset/limit to read portions."
161
+ )
162
+
163
+ # Image files — return base64 encoded content description
164
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico"}
165
+ if path.suffix.lower() in IMAGE_EXTENSIONS:
166
+ import base64
167
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
168
+ content = (
169
+ f"[Image file: {path.name} ({file_size:,} bytes)]\n"
170
+ f"Format: {path.suffix.lower()}\n"
171
+ f"Base64 ({len(encoded)} chars): {encoded[:200]}...\n\n"
172
+ f"This is an image file. The full base64 content is available for the model to interpret."
173
+ )
174
+ return ReadFileOutput(
175
+ file_path=file_path, content=content,
176
+ num_lines=4, total_lines=4, kind="image",
177
+ )
178
+
179
+ # PDF files — extract text content
180
+ PDF_EXTENSIONS = {".pdf"}
181
+ if path.suffix.lower() in PDF_EXTENSIONS:
182
+ try:
183
+ import subprocess as _sp
184
+ # Try pdftotext if available
185
+ result = _sp.run(
186
+ ["pdftotext", "-layout", file_path, "-"],
187
+ capture_output=True, text=True, timeout=10,
188
+ )
189
+ if result.returncode == 0 and result.stdout.strip():
190
+ text = result.stdout
191
+ lines = text.splitlines()
192
+ content = "\n".join(f"{i}\t{line}" for i, line in enumerate(lines, 1))
193
+ return ReadFileOutput(
194
+ file_path=file_path, content=content,
195
+ num_lines=len(lines), total_lines=len(lines), kind="pdf",
196
+ )
197
+ except (FileNotFoundError, Exception):
198
+ pass
199
+ # Fallback: read raw bytes and report metadata
200
+ content = (
201
+ f"[PDF file: {path.name} ({file_size:,} bytes)]\n"
202
+ f"Install 'pdftotext' (poppler-utils) for text extraction.\n"
203
+ f"Alternatively, use WebFetch to fetch an online version."
204
+ )
205
+ return ReadFileOutput(
206
+ file_path=file_path, content=content,
207
+ num_lines=3, total_lines=3, kind="pdf",
208
+ )
209
+
210
+ # Binary check
211
+ if is_binary_file(path):
212
+ raise ValueError(f"Binary file detected: {file_path}. Cannot read binary files as text.")
213
+
214
+ text = path.read_text(encoding="utf-8", errors="replace")
215
+ all_lines = text.splitlines(keepends=True)
216
+ total = len(all_lines)
217
+
218
+ start = (start_line or 1) - 1 # Convert to 0-indexed
219
+ end = end_line or total
220
+
221
+ start = max(0, start)
222
+ end = min(total, end)
223
+
224
+ selected = all_lines[start:end]
225
+
226
+ # Format with line numbers (cat -n style)
227
+ numbered_lines = []
228
+ for i, line in enumerate(selected, start=start + 1):
229
+ numbered_lines.append(f"{i}\t{line}")
230
+
231
+ content = "".join(numbered_lines)
232
+
233
+ return ReadFileOutput(
234
+ file_path=file_path,
235
+ content=content,
236
+ num_lines=len(selected),
237
+ start_line=start + 1,
238
+ total_lines=total,
239
+ )
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Write file
244
+ # ---------------------------------------------------------------------------
245
+
246
+ @dataclass
247
+ class WriteFileOutput:
248
+ file_path: str
249
+ content: str
250
+ kind: str = "create"
251
+ structured_patch: list[StructuredPatchHunk] = field(default_factory=list)
252
+ original_file: str | None = None
253
+
254
+
255
+ def write_file(
256
+ file_path: str,
257
+ content: str,
258
+ max_size: int = MAX_WRITE_SIZE,
259
+ ) -> WriteFileOutput:
260
+ """Write content to a file, creating parent directories as needed.
261
+
262
+ Validates content size and generates patch if updating existing file.
263
+ """
264
+ if len(content.encode("utf-8")) > max_size:
265
+ raise ValueError(
266
+ f"Content too large: {len(content.encode('utf-8')):,} bytes "
267
+ f"(max: {max_size:,} bytes)"
268
+ )
269
+
270
+ path = Path(file_path)
271
+ original = None
272
+ kind = "create"
273
+
274
+ if path.exists():
275
+ kind = "update"
276
+ original = path.read_text(encoding="utf-8", errors="replace")
277
+
278
+ path.parent.mkdir(parents=True, exist_ok=True)
279
+ path.write_text(content, encoding="utf-8")
280
+
281
+ # Generate patch
282
+ patch = []
283
+ if original is not None:
284
+ patch = make_patch(original, content, file_path)
285
+
286
+ return WriteFileOutput(
287
+ file_path=file_path,
288
+ content=content,
289
+ kind=kind,
290
+ structured_patch=patch,
291
+ original_file=original,
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Edit file
297
+ # ---------------------------------------------------------------------------
298
+
299
+ @dataclass
300
+ class EditFileOutput:
301
+ file_path: str
302
+ old_string: str
303
+ new_string: str
304
+ replacements: int = 0
305
+ structured_patch: list[StructuredPatchHunk] = field(default_factory=list)
306
+ original_file: str = ""
307
+ replace_all: bool = False
308
+
309
+
310
+ def edit_file(
311
+ file_path: str,
312
+ old_string: str,
313
+ new_string: str,
314
+ replace_all: bool = False,
315
+ ) -> EditFileOutput:
316
+ """Replace text in a file.
317
+
318
+ If replace_all is False, old_string must be unique in the file.
319
+ Validates old_string != new_string and generates patch.
320
+ """
321
+ path = Path(file_path)
322
+ if not path.exists():
323
+ raise FileNotFoundError(f"File not found: {file_path}")
324
+
325
+ if old_string == new_string:
326
+ raise ValueError("old_string and new_string are identical — no change needed")
327
+
328
+ content = path.read_text(encoding="utf-8")
329
+ count = content.count(old_string)
330
+
331
+ if count == 0:
332
+ raise ValueError(f"old_string not found in {file_path}")
333
+
334
+ if not replace_all and count > 1:
335
+ raise ValueError(
336
+ f"old_string appears {count} times in {file_path}. "
337
+ f"Use replace_all=True or provide more context to make it unique."
338
+ )
339
+
340
+ if replace_all:
341
+ new_content = content.replace(old_string, new_string)
342
+ replacements = count
343
+ else:
344
+ new_content = content.replace(old_string, new_string, 1)
345
+ replacements = 1
346
+
347
+ path.write_text(new_content, encoding="utf-8")
348
+
349
+ # Generate patch
350
+ patch = make_patch(content, new_content, file_path)
351
+
352
+ return EditFileOutput(
353
+ file_path=file_path,
354
+ old_string=old_string,
355
+ new_string=new_string,
356
+ replacements=replacements,
357
+ structured_patch=patch,
358
+ original_file=content,
359
+ replace_all=replace_all,
360
+ )
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Glob search
365
+ # ---------------------------------------------------------------------------
366
+
367
+ @dataclass
368
+ class GlobSearchOutput:
369
+ pattern: str
370
+ filenames: list[str]
371
+ num_files: int
372
+ duration_ms: float
373
+ truncated: bool = False
374
+
375
+
376
+ def glob_search(
377
+ pattern: str,
378
+ path: str | None = None,
379
+ max_results: int = DEFAULT_GLOB_LIMIT,
380
+ ) -> GlobSearchOutput:
381
+ """Search for files matching a glob pattern.
382
+
383
+ Results sorted by modification time (newest first). Truncates at max_results.
384
+ """
385
+ start = time.monotonic()
386
+ search_root = Path(path) if path else Path.cwd()
387
+
388
+ matches: list[str] = []
389
+ truncated = False
390
+
391
+ # Normalize pattern for rglob
392
+ glob_pattern = pattern
393
+ if glob_pattern.startswith("**/"):
394
+ glob_pattern = glob_pattern[3:]
395
+
396
+ try:
397
+ for p in search_root.rglob(glob_pattern):
398
+ if p.is_file():
399
+ # Skip hidden dirs and common non-code dirs
400
+ parts = p.relative_to(search_root).parts
401
+ if any(
402
+ part.startswith(".") or part in (
403
+ "node_modules", "__pycache__", "target", "dist",
404
+ "build", "venv", ".venv",
405
+ )
406
+ for part in parts
407
+ ):
408
+ continue
409
+
410
+ matches.append(str(p))
411
+ if len(matches) >= max_results:
412
+ truncated = True
413
+ break
414
+ except (OSError, ValueError):
415
+ pass
416
+
417
+ duration_ms = (time.monotonic() - start) * 1000
418
+
419
+ # Sort by modification time (most recent first)
420
+ try:
421
+ matches.sort(key=lambda f: os.path.getmtime(f), reverse=True)
422
+ except OSError:
423
+ pass
424
+
425
+ return GlobSearchOutput(
426
+ pattern=pattern,
427
+ filenames=matches,
428
+ num_files=len(matches),
429
+ duration_ms=duration_ms,
430
+ truncated=truncated,
431
+ )
432
+
433
+
434
+ # ---------------------------------------------------------------------------
435
+ # Grep search
436
+ # ---------------------------------------------------------------------------
437
+
438
+ @dataclass
439
+ class GrepMatch:
440
+ file: str
441
+ line_number: int
442
+ content: str
443
+
444
+
445
+ @dataclass
446
+ class GrepSearchOutput:
447
+ pattern: str
448
+ matches: list[GrepMatch] = field(default_factory=list)
449
+ filenames: list[str] = field(default_factory=list)
450
+ duration_ms: float = 0.0
451
+ truncated: bool = False
452
+ num_matches: int = 0
453
+ mode: str = "files_with_matches"
454
+ content: str | None = None
455
+
456
+
457
+ def grep_search(
458
+ pattern: str,
459
+ path: str | None = None,
460
+ glob_filter: str | None = None,
461
+ output_mode: str = "files_with_matches",
462
+ max_results: int = DEFAULT_HEAD_LIMIT,
463
+ offset: int = 0,
464
+ case_insensitive: bool = False,
465
+ context_lines: int = 0,
466
+ before_context: int = 0,
467
+ after_context: int = 0,
468
+ multiline: bool = False,
469
+ file_type: str | None = None,
470
+ line_numbers: bool = True,
471
+ ) -> GrepSearchOutput:
472
+ """Search file contents with regex pattern.
473
+
474
+ Supports three output modes: files_with_matches, content, count.
475
+ Filters by glob pattern and file type.
476
+ """
477
+ start = time.monotonic()
478
+ search_root = Path(path) if path else Path.cwd()
479
+
480
+ # Determine context
481
+ ctx_before = before_context or context_lines
482
+ ctx_after = after_context or context_lines
483
+
484
+ flags = re.IGNORECASE if case_insensitive else 0
485
+ if multiline:
486
+ flags |= re.DOTALL | re.MULTILINE
487
+
488
+ try:
489
+ compiled = re.compile(pattern, flags)
490
+ except re.error as exc:
491
+ raise ValueError(f"Invalid regex pattern: {exc}") from exc
492
+
493
+ # Build file type filter
494
+ type_globs: list[str] = []
495
+ if file_type:
496
+ type_map = {
497
+ "py": "*.py", "python": "*.py",
498
+ "js": "*.js", "javascript": "*.js",
499
+ "ts": "*.ts", "typescript": "*.ts",
500
+ "tsx": "*.tsx",
501
+ "rs": "*.rs", "rust": "*.rs",
502
+ "go": "*.go",
503
+ "java": "*.java",
504
+ "c": "*.c", "cpp": "*.cpp", "cc": "*.cc",
505
+ "h": "*.h", "hpp": "*.hpp",
506
+ "rb": "*.rb", "ruby": "*.rb",
507
+ "md": "*.md", "markdown": "*.md",
508
+ "json": "*.json",
509
+ "yaml": "*.yaml", "yml": "*.yml",
510
+ "toml": "*.toml",
511
+ "html": "*.html",
512
+ "css": "*.css",
513
+ "sql": "*.sql",
514
+ "sh": "*.sh", "bash": "*.sh",
515
+ }
516
+ if file_type in type_map:
517
+ type_globs = [type_map[file_type]]
518
+
519
+ matches: list[GrepMatch] = []
520
+ matched_files: set[str] = set()
521
+ match_count = 0
522
+ truncated = False
523
+ content_lines: list[str] = []
524
+ skipped = 0
525
+
526
+ # Skip directories
527
+ skip_dirs = {
528
+ ".git", "node_modules", "__pycache__", "target", "dist",
529
+ "build", "venv", ".venv", "env", ".tox", ".mypy_cache",
530
+ }
531
+
532
+ for root, dirs, files in os.walk(search_root):
533
+ dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
534
+
535
+ for filename in files:
536
+ # Filter by glob/type
537
+ if glob_filter and not fnmatch.fnmatch(filename, glob_filter):
538
+ continue
539
+ if type_globs and not any(fnmatch.fnmatch(filename, g) for g in type_globs):
540
+ continue
541
+
542
+ filepath = os.path.join(root, filename)
543
+
544
+ try:
545
+ with open(filepath, encoding="utf-8", errors="ignore") as f:
546
+ file_lines = f.readlines()
547
+ except (OSError, PermissionError):
548
+ continue
549
+
550
+ file_matched = False
551
+ for line_num, line in enumerate(file_lines, 1):
552
+ if compiled.search(line):
553
+ file_matched = True
554
+ match_count += 1
555
+
556
+ # Apply offset
557
+ if skipped < offset:
558
+ skipped += 1
559
+ continue
560
+
561
+ if output_mode == "content":
562
+ # Add context lines
563
+ if ctx_before > 0:
564
+ start_ctx = max(0, line_num - 1 - ctx_before)
565
+ for ctx_i in range(start_ctx, line_num - 1):
566
+ ctx_line = file_lines[ctx_i].rstrip("\n")
567
+ prefix = f"{filepath}:{ctx_i + 1}:" if line_numbers else ""
568
+ content_lines.append(f"{prefix}{ctx_line}")
569
+
570
+ prefix = f"{filepath}:{line_num}:" if line_numbers else ""
571
+ content_lines.append(f"{prefix}{line.rstrip(chr(10))}")
572
+
573
+ if ctx_after > 0:
574
+ end_ctx = min(len(file_lines), line_num + ctx_after)
575
+ for ctx_i in range(line_num, end_ctx):
576
+ ctx_line = file_lines[ctx_i].rstrip("\n")
577
+ prefix = f"{filepath}:{ctx_i + 1}:" if line_numbers else ""
578
+ content_lines.append(f"{prefix}{ctx_line}")
579
+
580
+ matches.append(GrepMatch(
581
+ file=filepath,
582
+ line_number=line_num,
583
+ content=line.rstrip("\n"),
584
+ ))
585
+
586
+ if len(matches) >= max_results:
587
+ truncated = True
588
+ break
589
+
590
+ if file_matched:
591
+ matched_files.add(filepath)
592
+
593
+ if truncated:
594
+ break
595
+ if truncated:
596
+ break
597
+
598
+ duration_ms = (time.monotonic() - start) * 1000
599
+
600
+ result = GrepSearchOutput(
601
+ pattern=pattern,
602
+ matches=matches,
603
+ filenames=sorted(matched_files),
604
+ duration_ms=duration_ms,
605
+ truncated=truncated,
606
+ num_matches=match_count,
607
+ mode=output_mode,
608
+ )
609
+
610
+ if output_mode == "content" and content_lines:
611
+ result.content = "\n".join(content_lines[:max_results])
612
+
613
+ return result