ida-code 0.2.1__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.
@@ -0,0 +1,570 @@
1
+ """Index and search official IDAPython example scripts.
2
+
3
+ Parses metadata from two sources:
4
+ 1. ``index.md`` — structured markdown with titles, descriptions, keywords,
5
+ difficulty levels, categories, and curated "APIs Used" lists.
6
+ 2. Each ``.py`` file — ``ast`` module extracts the structured docstring,
7
+ imports, top-level definitions, and ``ida_*`` attribute accesses.
8
+
9
+ The index is built lazily on first search and cached in a module global.
10
+ """
11
+
12
+ import ast
13
+ import logging
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from ida_code._search_utils import term_matches
19
+ from ida_code.config import IDA_EXAMPLES_DIR
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ _index: list["ExampleEntry"] | None = None
24
+
25
+
26
+ @dataclass
27
+ class ExampleEntry:
28
+ """A single indexed example script."""
29
+
30
+ # Identity
31
+ id: str
32
+ filename: str
33
+ rel_path: str
34
+ abs_path: str
35
+
36
+ # From index.md
37
+ title: str = ""
38
+ summary: str = ""
39
+ description: str = ""
40
+ level: str = ""
41
+ category: str = ""
42
+ keywords: list[str] = field(default_factory=list)
43
+ apis_used: list[str] = field(default_factory=list)
44
+
45
+ # From AST parsing
46
+ imports: list[str] = field(default_factory=list)
47
+ definitions: list[str] = field(default_factory=list)
48
+ api_calls: list[str] = field(default_factory=list)
49
+
50
+ # Full source text
51
+ source: str = ""
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # index.md parsing
56
+ # ---------------------------------------------------------------------------
57
+
58
+ # Matches section headers like: ## User interface {#ui}
59
+ _CATEGORY_RE = re.compile(r"^##\s+.+?\{#(\w+)\}")
60
+
61
+ # Matches example headers like: ### Some title {#some_id}
62
+ _EXAMPLE_RE = re.compile(r"^###\s+(.+?)\s*\{#(\w+)\}")
63
+
64
+ # Matches source code table row:
65
+ # | [file.py](url) | kw1 kw2 | Level |
66
+ _SOURCE_ROW_RE = re.compile(
67
+ r"\|\s*\[([^\]]+)\]\([^)]*\)\s*\|\s*(.*?)\s*\|\s*(\w+)\s*\|"
68
+ )
69
+
70
+ # Matches API lines like: * `ida_kernwin.add_hotkey`
71
+ _API_RE = re.compile(r"^\*\s+`([^`]+)`")
72
+
73
+ # Matches TOC links like: <a href='#add_hotkey'>...</a>
74
+ _TOC_LINK_RE = re.compile(r"<a\s+href=['\"]#(\w+)['\"]>")
75
+
76
+
77
+ def _parse_toc_categories(text: str) -> dict[str, str]:
78
+ """Extract id -> category mapping from the TOC tables.
79
+
80
+ The TOC section has ``## Category {#cat}`` headers followed by HTML tables
81
+ containing ``<a href='#example_id'>`` links. The detail section later
82
+ (``## Examples list``) has no category headers.
83
+ """
84
+ id_to_cat: dict[str, str] = {}
85
+ current_category = ""
86
+
87
+ for line in text.splitlines():
88
+ m = _CATEGORY_RE.match(line)
89
+ if m:
90
+ current_category = m.group(1)
91
+ continue
92
+ # Stop at the flat listing section — everything after is details
93
+ if line.strip() == "## Examples list":
94
+ break
95
+ if current_category:
96
+ for link_match in _TOC_LINK_RE.finditer(line):
97
+ id_to_cat[link_match.group(1)] = current_category
98
+
99
+ return id_to_cat
100
+
101
+
102
+ def parse_index_md(text: str) -> dict[str, dict]:
103
+ """Parse index.md into a dict keyed by example id.
104
+
105
+ Returns a dict mapping id -> {title, description, keywords, level,
106
+ category, apis_used, source_file}.
107
+ """
108
+ # First pass: build id -> category from the TOC tables
109
+ toc_categories = _parse_toc_categories(text)
110
+
111
+ entries: dict[str, dict] = {}
112
+ current_id = ""
113
+ current: dict | None = None
114
+ in_apis = False
115
+
116
+ for line in text.splitlines():
117
+ # Detect example block start
118
+ m = _EXAMPLE_RE.match(line)
119
+ if m:
120
+ if current:
121
+ entries[current_id] = current
122
+ title = m.group(1)
123
+ current_id = m.group(2)
124
+ current = {
125
+ "title": title,
126
+ "description": "",
127
+ "keywords": [],
128
+ "level": "",
129
+ "category": toc_categories.get(current_id, ""),
130
+ "apis_used": [],
131
+ "source_file": "",
132
+ }
133
+ in_apis = False
134
+ continue
135
+
136
+ if current is None:
137
+ continue
138
+
139
+ # Detect APIs Used section
140
+ if line.strip().startswith("**APIs Used:**"):
141
+ in_apis = True
142
+ continue
143
+
144
+ # Detect end of block (horizontal rule)
145
+ if line.strip() == "***":
146
+ in_apis = False
147
+ if current:
148
+ entries[current_id] = current
149
+ current = None
150
+ continue
151
+
152
+ # Parse API entries
153
+ if in_apis:
154
+ m = _API_RE.match(line.strip())
155
+ if m:
156
+ current["apis_used"].append(m.group(1))
157
+ continue
158
+
159
+ # Parse source code table row
160
+ m = _SOURCE_ROW_RE.search(line)
161
+ if m:
162
+ current["source_file"] = m.group(1)
163
+ kw_str = m.group(2).strip()
164
+ if kw_str:
165
+ current["keywords"] = kw_str.split()
166
+ current["level"] = m.group(3).lower()
167
+ continue
168
+
169
+ # Accumulate description text (skip table headers and empty lines)
170
+ stripped = line.strip()
171
+ if (
172
+ stripped
173
+ and not stripped.startswith("|")
174
+ and not stripped.startswith("<")
175
+ ):
176
+ if current["description"]:
177
+ current["description"] += " " + stripped
178
+ else:
179
+ current["description"] = stripped
180
+
181
+ # Flush last entry
182
+ if current:
183
+ entries[current_id] = current
184
+
185
+ return entries
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Docstring parsing
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def parse_docstring(docstring: str) -> dict[str, str]:
194
+ """Parse a structured IDAPython example docstring.
195
+
196
+ Expected format::
197
+
198
+ summary: one-line summary
199
+ description:
200
+ multi-line description
201
+ level: beginner
202
+ """
203
+ result: dict[str, str] = {}
204
+ if not docstring:
205
+ return result
206
+
207
+ current_key = ""
208
+ current_val: list[str] = []
209
+
210
+ for line in docstring.splitlines():
211
+ stripped = line.strip()
212
+
213
+ # Try to match a key: value line
214
+ m = re.match(r"^(\w+):\s*(.*)", stripped)
215
+ if m:
216
+ # Save previous key
217
+ if current_key:
218
+ result[current_key] = " ".join(current_val).strip()
219
+ current_key = m.group(1)
220
+ val = m.group(2)
221
+ current_val = [val] if val else []
222
+ elif current_key and stripped:
223
+ current_val.append(stripped)
224
+
225
+ if current_key:
226
+ result[current_key] = " ".join(current_val).strip()
227
+
228
+ return result
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # AST parsing
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ def parse_ast(source: str) -> dict:
237
+ """Extract imports, definitions, and ida_* API calls from source code.
238
+
239
+ Returns dict with keys: imports, definitions, api_calls.
240
+ """
241
+ result: dict[str, list[str]] = {
242
+ "imports": [],
243
+ "definitions": [],
244
+ "api_calls": [],
245
+ }
246
+
247
+ try:
248
+ tree = ast.parse(source)
249
+ except SyntaxError:
250
+ return result
251
+
252
+ for node in ast.walk(tree):
253
+ # Collect imports
254
+ if isinstance(node, ast.Import):
255
+ for alias in node.names:
256
+ result["imports"].append(alias.name)
257
+ elif isinstance(node, ast.ImportFrom):
258
+ if node.module:
259
+ result["imports"].append(node.module)
260
+
261
+ # Collect top-level function/class definitions
262
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
263
+ # Only top-level (direct children of Module)
264
+ if _is_top_level(tree, node):
265
+ result["definitions"].append(node.name)
266
+
267
+ # Collect ida_* attribute accesses like ida_hexrays.decompile
268
+ elif isinstance(node, ast.Attribute):
269
+ if isinstance(node.value, ast.Name) and node.value.id.startswith("ida_"):
270
+ api = f"{node.value.id}.{node.attr}"
271
+ if api not in result["api_calls"]:
272
+ result["api_calls"].append(api)
273
+ elif isinstance(node.value, ast.Name) and node.value.id in ("idc", "idautils"):
274
+ api = f"{node.value.id}.{node.attr}"
275
+ if api not in result["api_calls"]:
276
+ result["api_calls"].append(api)
277
+
278
+ return result
279
+
280
+
281
+ def _is_top_level(tree: ast.Module, node: ast.AST) -> bool:
282
+ """Check if a node is a direct child of the module body."""
283
+ return node in tree.body
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Index building
288
+ # ---------------------------------------------------------------------------
289
+
290
+ # Map category directory names to canonical category ids
291
+ _DIR_TO_CATEGORY = {
292
+ "ui": "ui",
293
+ "disassembler": "disassembler",
294
+ "decompiler": "decompiler",
295
+ "debugger": "debugger",
296
+ "types": "types",
297
+ "misc": "misc",
298
+ }
299
+
300
+
301
+ def _infer_category(rel_path: str) -> str:
302
+ """Infer category from the relative path."""
303
+ parts = Path(rel_path).parts
304
+ if parts:
305
+ return _DIR_TO_CATEGORY.get(parts[0], "misc")
306
+ return "misc"
307
+
308
+
309
+ def _build_index(examples_dir: Path) -> list[ExampleEntry]:
310
+ """Build the full example index from index.md and .py files."""
311
+ entries: list[ExampleEntry] = []
312
+
313
+ # Parse index.md if available
314
+ index_md_path = examples_dir / "index.md"
315
+ index_data: dict[str, dict] = {}
316
+ if index_md_path.is_file():
317
+ try:
318
+ index_data = parse_index_md(index_md_path.read_text(errors="replace"))
319
+ except OSError:
320
+ log.warning("Could not read %s", index_md_path)
321
+
322
+ # Walk all .py files
323
+ py_files = sorted(examples_dir.rglob("*.py"))
324
+ for py_path in py_files:
325
+ rel_path = py_path.relative_to(examples_dir)
326
+ filename = py_path.stem # e.g. "vds1"
327
+
328
+ try:
329
+ source = py_path.read_text(errors="replace")
330
+ except OSError:
331
+ continue
332
+
333
+ entry = ExampleEntry(
334
+ id=filename,
335
+ filename=py_path.name,
336
+ rel_path=str(rel_path),
337
+ abs_path=str(py_path),
338
+ source=source,
339
+ category=_infer_category(str(rel_path)),
340
+ )
341
+
342
+ # Merge index.md metadata
343
+ md = index_data.get(filename, {})
344
+ if md:
345
+ entry.title = md.get("title", "")
346
+ entry.description = md.get("description", "")
347
+ entry.keywords = md.get("keywords", [])
348
+ entry.level = md.get("level", "")
349
+ if md.get("category"):
350
+ entry.category = md["category"]
351
+ entry.apis_used = md.get("apis_used", [])
352
+
353
+ # Parse docstring
354
+ try:
355
+ tree = ast.parse(source)
356
+ ds = ast.get_docstring(tree)
357
+ except SyntaxError:
358
+ ds = None
359
+ if ds:
360
+ parsed_ds = parse_docstring(ds)
361
+ if not entry.title and parsed_ds.get("summary"):
362
+ entry.title = parsed_ds["summary"]
363
+ entry.summary = parsed_ds.get("summary", "")
364
+ if not entry.description and parsed_ds.get("description"):
365
+ entry.description = parsed_ds["description"]
366
+ if not entry.level and parsed_ds.get("level"):
367
+ entry.level = parsed_ds["level"]
368
+
369
+ # Parse AST for imports, definitions, API calls
370
+ ast_info = parse_ast(source)
371
+ entry.imports = ast_info["imports"]
372
+ entry.definitions = ast_info["definitions"]
373
+ entry.api_calls = ast_info["api_calls"]
374
+
375
+ entries.append(entry)
376
+
377
+ return entries
378
+
379
+
380
+ def _ensure_index():
381
+ global _index
382
+ if _index is None:
383
+ log.info("Building example index from %s", IDA_EXAMPLES_DIR)
384
+ _index = _build_index(IDA_EXAMPLES_DIR)
385
+ log.info("Indexed %d example scripts", len(_index))
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Scoring
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ def score_example(entry: ExampleEntry, terms: list[str]) -> float:
394
+ """Score an example against search terms using weighted fields."""
395
+ total = 0.0
396
+ matched_terms = 0
397
+
398
+ for term in terms:
399
+ term_score = 0.0
400
+ t = term.lower()
401
+
402
+ # API match: apis_used (curated from index.md)
403
+ for api in entry.apis_used:
404
+ if term_matches(t, api.lower()):
405
+ term_score = max(term_score, 5.0)
406
+ break
407
+
408
+ # API match: api_calls (AST-derived)
409
+ for api in entry.api_calls:
410
+ if term_matches(t, api.lower()):
411
+ term_score = max(term_score, 4.0)
412
+ break
413
+
414
+ # Title match
415
+ if term_matches(t, entry.title.lower()):
416
+ term_score = max(term_score, 4.0)
417
+
418
+ # Keyword match
419
+ for kw in entry.keywords:
420
+ if term_matches(t, kw.lower()):
421
+ term_score = max(term_score, 3.0)
422
+ break
423
+
424
+ # Summary match
425
+ if term_matches(t, entry.summary.lower()):
426
+ term_score = max(term_score, 3.0)
427
+
428
+ # Import match
429
+ for imp in entry.imports:
430
+ if term_matches(t, imp.lower()):
431
+ term_score = max(term_score, 2.0)
432
+ break
433
+
434
+ # Description match
435
+ if term_matches(t, entry.description.lower()):
436
+ term_score = max(term_score, 1.5)
437
+
438
+ # Definition match
439
+ for defn in entry.definitions:
440
+ if term_matches(t, defn.lower()):
441
+ term_score = max(term_score, 1.5)
442
+ break
443
+
444
+ # Source fallback (uses plain substring — source is too large for boundary matching)
445
+ if term_score == 0 and t in entry.source.lower():
446
+ term_score = 0.5
447
+
448
+ if term_score > 0:
449
+ matched_terms += 1
450
+ total += term_score
451
+
452
+ # All-terms-match bonus
453
+ if len(terms) > 1 and matched_terms == len(terms):
454
+ total *= 1.5
455
+
456
+ return total
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # Snippet extraction
461
+ # ---------------------------------------------------------------------------
462
+
463
+
464
+ def extract_snippet(source: str, terms: list[str], max_lines: int = 15) -> str:
465
+ """Extract a relevant snippet from source, skipping the module docstring."""
466
+ lines = source.splitlines()
467
+ if not lines:
468
+ return ""
469
+
470
+ # Skip the docstring: find where it ends
471
+ start_line = _find_docstring_end(source)
472
+
473
+ code_lines = lines[start_line:]
474
+ if not code_lines:
475
+ code_lines = lines # fallback to full source
476
+
477
+ if not terms:
478
+ snippet = code_lines[:max_lines]
479
+ return "\n".join(snippet)
480
+
481
+ # Find the line with the best term overlap
482
+ best_idx = 0
483
+ best_count = 0
484
+ for i, line in enumerate(code_lines):
485
+ lower = line.lower()
486
+ count = sum(1 for t in terms if term_matches(t.lower(), lower))
487
+ if count > best_count:
488
+ best_count = count
489
+ best_idx = i
490
+
491
+ # Window around the best line
492
+ half = max_lines // 2
493
+ win_start = max(0, best_idx - half)
494
+ win_end = min(len(code_lines), win_start + max_lines)
495
+ # Adjust start if we're near the end
496
+ if win_end - win_start < max_lines:
497
+ win_start = max(0, win_end - max_lines)
498
+
499
+ snippet_lines = code_lines[win_start:win_end]
500
+ return "\n".join(snippet_lines)
501
+
502
+
503
+ def _find_docstring_end(source: str) -> int:
504
+ """Return the line number (0-based) where the module docstring ends."""
505
+ try:
506
+ tree = ast.parse(source)
507
+ except SyntaxError:
508
+ return 0
509
+
510
+ if not tree.body:
511
+ return 0
512
+
513
+ first = tree.body[0]
514
+ if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant):
515
+ # end_lineno is 1-based, we want the next line (0-based)
516
+ return first.end_lineno
517
+ return 0
518
+
519
+
520
+ # ---------------------------------------------------------------------------
521
+ # Public search API
522
+ # ---------------------------------------------------------------------------
523
+
524
+
525
+ def search(
526
+ query: str,
527
+ max_results: int = 5,
528
+ category: str = "",
529
+ level: str = "",
530
+ max_snippet_lines: int = 10,
531
+ ) -> dict:
532
+ """Search example scripts and return structured dict."""
533
+ _ensure_index()
534
+
535
+ terms = query.lower().split()
536
+ if not terms:
537
+ return {"query": query, "results": []}
538
+
539
+ results: list[tuple[float, ExampleEntry]] = []
540
+
541
+ for entry in _index:
542
+ # Apply filters
543
+ if category and entry.category != category.lower():
544
+ continue
545
+ if level and entry.level != level.lower():
546
+ continue
547
+
548
+ s = score_example(entry, terms)
549
+ if s > 0:
550
+ results.append((s, entry))
551
+
552
+ results.sort(key=lambda r: r[0], reverse=True)
553
+ results = results[:max_results]
554
+
555
+ return {
556
+ "query": query,
557
+ "results": [
558
+ {
559
+ "title": entry.title or entry.filename,
560
+ "file": entry.rel_path,
561
+ "level": entry.level,
562
+ "category": entry.category,
563
+ "summary": entry.summary,
564
+ "apis": entry.apis_used[:10],
565
+ "snippet": extract_snippet(entry.source, terms, max_lines=max_snippet_lines),
566
+ "score": score,
567
+ }
568
+ for score, entry in results
569
+ ],
570
+ }
ida_code/executor.py ADDED
@@ -0,0 +1,145 @@
1
+ import ast
2
+ import io
3
+ import logging
4
+ import signal
5
+ import sys
6
+ import traceback
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+ _MAX_OUTPUT = 50_000
11
+ _DEFAULT_TIMEOUT = 30 # seconds; 0 = no timeout
12
+
13
+ # Modules to pre-populate in the execution namespace.
14
+ _PRELOADED_MODULES = [
15
+ "ida_funcs",
16
+ "ida_bytes",
17
+ "ida_name",
18
+ "ida_segment",
19
+ "ida_auto",
20
+ "ida_idaapi",
21
+ "ida_nalt",
22
+ "ida_xref",
23
+ "ida_ua",
24
+ "ida_entry",
25
+ "ida_lines",
26
+ "ida_typeinf",
27
+ "ida_hexrays",
28
+ "idautils",
29
+ "idc",
30
+ ]
31
+
32
+ _namespace: dict = {}
33
+
34
+
35
+ def _build_namespace() -> dict:
36
+ ns = {"__builtins__": __builtins__}
37
+ for mod_name in _PRELOADED_MODULES:
38
+ try:
39
+ ns[mod_name] = __import__(mod_name)
40
+ except ImportError:
41
+ pass # Some modules (e.g. ida_hexrays) may not be available.
42
+ return ns
43
+
44
+
45
+ def reset() -> None:
46
+ """Clear the execution namespace (called when the database changes)."""
47
+ global _namespace
48
+ _namespace = _build_namespace()
49
+
50
+
51
+ class _Timeout(Exception):
52
+ pass
53
+
54
+
55
+ def _alarm_handler(signum, frame):
56
+ raise _Timeout()
57
+
58
+
59
+ def _exec_repl(code: str, namespace: dict, stdout: io.StringIO) -> None:
60
+ """Execute *code* with REPL-like last-expression printing.
61
+
62
+ If the last statement is a bare expression (not an assignment, not a
63
+ function call used for side-effects via print, etc.), its repr is
64
+ written to *stdout* — just like the interactive Python prompt.
65
+ """
66
+ try:
67
+ tree = ast.parse(code)
68
+ except SyntaxError:
69
+ # Fall back to plain exec if the code can't be parsed (exec will
70
+ # produce the same SyntaxError with a proper traceback).
71
+ exec(code, namespace)
72
+ return
73
+
74
+ if not tree.body:
75
+ return
76
+
77
+ last = tree.body[-1]
78
+ if not isinstance(last, ast.Expr):
79
+ # Last statement is not a bare expression — exec everything.
80
+ exec(code, namespace)
81
+ return
82
+
83
+ # Split: exec all statements except the last, then eval the last.
84
+ if len(tree.body) > 1:
85
+ head = ast.Module(body=tree.body[:-1], type_ignores=tree.type_ignores)
86
+ ast.fix_missing_locations(head)
87
+ exec(compile(head, "<exec>", "exec"), namespace)
88
+
89
+ expr_code = compile(ast.Expression(body=last.value), "<eval>", "eval")
90
+ result = eval(expr_code, namespace) # noqa: S307
91
+ if result is not None:
92
+ stdout.write(repr(result) + "\n")
93
+
94
+
95
+ def execute(code: str, timeout: int = _DEFAULT_TIMEOUT) -> str:
96
+ """Execute IDAPython code and return captured output.
97
+
98
+ *timeout* sets the maximum wall-clock seconds (0 = unlimited).
99
+ On expiry the code is interrupted and an error message is returned.
100
+ """
101
+ global _namespace
102
+
103
+ # Lazy-init namespace on first call.
104
+ if not _namespace:
105
+ _namespace = _build_namespace()
106
+
107
+ stdout_capture = io.StringIO()
108
+ stderr_capture = io.StringIO()
109
+ old_stdout, old_stderr = sys.stdout, sys.stderr
110
+ old_handler = None
111
+
112
+ try:
113
+ sys.stdout = stdout_capture
114
+ sys.stderr = stderr_capture
115
+
116
+ if timeout > 0:
117
+ old_handler = signal.signal(signal.SIGALRM, _alarm_handler)
118
+ signal.alarm(timeout)
119
+
120
+ log.debug("Executing code (%d chars, timeout=%ds)", len(code), timeout)
121
+ _exec_repl(code, _namespace, stdout_capture)
122
+ except _Timeout:
123
+ log.warning("Execution timed out after %ds", timeout)
124
+ stderr_capture.write(f"\n\nExecution timed out after {timeout} seconds.")
125
+ except (KeyboardInterrupt, SystemExit) as exc:
126
+ log.warning("%s intercepted from user code", type(exc).__name__)
127
+ stderr_capture.write(f"\n\n{type(exc).__name__} intercepted — the server is still running.\n")
128
+ stderr_capture.write(traceback.format_exc())
129
+ except Exception:
130
+ log.debug("User code raised exception", exc_info=True)
131
+ stderr_capture.write(traceback.format_exc())
132
+ finally:
133
+ if timeout > 0:
134
+ signal.alarm(0) # Cancel any pending alarm.
135
+ if old_handler is not None:
136
+ signal.signal(signal.SIGALRM, old_handler)
137
+ sys.stdout = old_stdout
138
+ sys.stderr = old_stderr
139
+
140
+ output = stdout_capture.getvalue() + stderr_capture.getvalue()
141
+
142
+ if len(output) > _MAX_OUTPUT:
143
+ output = output[:_MAX_OUTPUT] + f"\n\n[Output truncated at {_MAX_OUTPUT} characters]"
144
+
145
+ return output