mcpforunityserver 8.2.3__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 (65) hide show
  1. __init__.py +0 -0
  2. core/__init__.py +0 -0
  3. core/config.py +56 -0
  4. core/logging_decorator.py +37 -0
  5. core/telemetry.py +533 -0
  6. core/telemetry_decorator.py +164 -0
  7. main.py +411 -0
  8. mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
  9. mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
  10. mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
  11. mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
  12. mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
  13. mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
  14. models/__init__.py +4 -0
  15. models/models.py +56 -0
  16. models/unity_response.py +47 -0
  17. routes/__init__.py +0 -0
  18. services/__init__.py +0 -0
  19. services/custom_tool_service.py +339 -0
  20. services/registry/__init__.py +22 -0
  21. services/registry/resource_registry.py +53 -0
  22. services/registry/tool_registry.py +51 -0
  23. services/resources/__init__.py +81 -0
  24. services/resources/active_tool.py +47 -0
  25. services/resources/custom_tools.py +57 -0
  26. services/resources/editor_state.py +42 -0
  27. services/resources/layers.py +29 -0
  28. services/resources/menu_items.py +34 -0
  29. services/resources/prefab_stage.py +39 -0
  30. services/resources/project_info.py +39 -0
  31. services/resources/selection.py +55 -0
  32. services/resources/tags.py +30 -0
  33. services/resources/tests.py +55 -0
  34. services/resources/unity_instances.py +122 -0
  35. services/resources/windows.py +47 -0
  36. services/tools/__init__.py +76 -0
  37. services/tools/batch_execute.py +78 -0
  38. services/tools/debug_request_context.py +71 -0
  39. services/tools/execute_custom_tool.py +38 -0
  40. services/tools/execute_menu_item.py +29 -0
  41. services/tools/find_in_file.py +174 -0
  42. services/tools/manage_asset.py +129 -0
  43. services/tools/manage_editor.py +63 -0
  44. services/tools/manage_gameobject.py +240 -0
  45. services/tools/manage_material.py +95 -0
  46. services/tools/manage_prefabs.py +62 -0
  47. services/tools/manage_scene.py +75 -0
  48. services/tools/manage_script.py +602 -0
  49. services/tools/manage_shader.py +64 -0
  50. services/tools/read_console.py +115 -0
  51. services/tools/run_tests.py +108 -0
  52. services/tools/script_apply_edits.py +998 -0
  53. services/tools/set_active_instance.py +112 -0
  54. services/tools/utils.py +60 -0
  55. transport/__init__.py +0 -0
  56. transport/legacy/port_discovery.py +329 -0
  57. transport/legacy/stdio_port_registry.py +65 -0
  58. transport/legacy/unity_connection.py +785 -0
  59. transport/models.py +62 -0
  60. transport/plugin_hub.py +412 -0
  61. transport/plugin_registry.py +123 -0
  62. transport/unity_instance_middleware.py +141 -0
  63. transport/unity_transport.py +103 -0
  64. utils/module_discovery.py +55 -0
  65. utils/reload_sentinel.py +9 -0
@@ -0,0 +1,998 @@
1
+ import base64
2
+ import hashlib
3
+ import re
4
+ from typing import Annotated, Any, Union
5
+
6
+ from fastmcp import Context
7
+
8
+ from services.registry import mcp_for_unity_tool
9
+ from services.tools import get_unity_instance_from_context
10
+ from services.tools.utils import parse_json_payload
11
+ from transport.unity_transport import send_with_unity_instance
12
+ from transport.legacy.unity_connection import async_send_command_with_retry
13
+
14
+
15
+ async def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str:
16
+ text = original_text
17
+ for edit in edits or []:
18
+ op = (
19
+ (edit.get("op")
20
+ or edit.get("operation")
21
+ or edit.get("type")
22
+ or edit.get("mode")
23
+ or "")
24
+ .strip()
25
+ .lower()
26
+ )
27
+
28
+ if not op:
29
+ allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
30
+ raise RuntimeError(
31
+ f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)."
32
+ )
33
+
34
+ if op == "prepend":
35
+ prepend_text = edit.get("text", "")
36
+ text = (prepend_text if prepend_text.endswith(
37
+ "\n") else prepend_text + "\n") + text
38
+ elif op == "append":
39
+ append_text = edit.get("text", "")
40
+ if not text.endswith("\n"):
41
+ text += "\n"
42
+ text += append_text
43
+ if not text.endswith("\n"):
44
+ text += "\n"
45
+ elif op == "anchor_insert":
46
+ anchor = edit.get("anchor", "")
47
+ position = (edit.get("position") or "before").lower()
48
+ insert_text = edit.get("text", "")
49
+ flags = re.MULTILINE | (
50
+ re.IGNORECASE if edit.get("ignore_case") else 0)
51
+
52
+ # Find the best match using improved heuristics
53
+ match = _find_best_anchor_match(
54
+ anchor, text, flags, bool(edit.get("prefer_last", True)))
55
+ if not match:
56
+ if edit.get("allow_noop", True):
57
+ continue
58
+ raise RuntimeError(f"anchor not found: {anchor}")
59
+ idx = match.start() if position == "before" else match.end()
60
+ text = text[:idx] + insert_text + text[idx:]
61
+ elif op == "replace_range":
62
+ start_line = int(edit.get("startLine", 1))
63
+ start_col = int(edit.get("startCol", 1))
64
+ end_line = int(edit.get("endLine", start_line))
65
+ end_col = int(edit.get("endCol", 1))
66
+ replacement = edit.get("text", "")
67
+ lines = text.splitlines(keepends=True)
68
+ max_line = len(lines) + 1 # 1-based, exclusive end
69
+ if (start_line < 1 or end_line < start_line or end_line > max_line
70
+ or start_col < 1 or end_col < 1):
71
+ raise RuntimeError("replace_range out of bounds")
72
+
73
+ def index_of(line: int, col: int) -> int:
74
+ if line <= len(lines):
75
+ return sum(len(l) for l in lines[: line - 1]) + (col - 1)
76
+ return sum(len(l) for l in lines)
77
+ a = index_of(start_line, start_col)
78
+ b = index_of(end_line, end_col)
79
+ text = text[:a] + replacement + text[b:]
80
+ elif op == "regex_replace":
81
+ pattern = edit.get("pattern", "")
82
+ repl = edit.get("replacement", "")
83
+ # Translate $n backrefs (our input) to Python \g<n>
84
+ repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl)
85
+ count = int(edit.get("count", 0)) # 0 = replace all
86
+ flags = re.MULTILINE
87
+ if edit.get("ignore_case"):
88
+ flags |= re.IGNORECASE
89
+ text = re.sub(pattern, repl_py, text, count=count, flags=flags)
90
+ else:
91
+ allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
92
+ raise RuntimeError(
93
+ f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
94
+ return text
95
+
96
+
97
+ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True):
98
+ """
99
+ Find the best anchor match using improved heuristics.
100
+
101
+ For patterns like \\s*}\\s*$ that are meant to find class-ending braces,
102
+ this function uses heuristics to choose the most semantically appropriate match:
103
+
104
+ 1. If prefer_last=True, prefer the last match (common for class-end insertions)
105
+ 2. Use indentation levels to distinguish class vs method braces
106
+ 3. Consider context to avoid matches inside strings/comments
107
+
108
+ Args:
109
+ pattern: Regex pattern to search for
110
+ text: Text to search in
111
+ flags: Regex flags
112
+ prefer_last: If True, prefer the last match over the first
113
+
114
+ Returns:
115
+ Match object of the best match, or None if no match found
116
+ """
117
+
118
+ # Find all matches
119
+ matches = list(re.finditer(pattern, text, flags))
120
+ if not matches:
121
+ return None
122
+
123
+ # If only one match, return it
124
+ if len(matches) == 1:
125
+ return matches[0]
126
+
127
+ # For patterns that look like they're trying to match closing braces at end of lines
128
+ is_closing_brace_pattern = '}' in pattern and (
129
+ '$' in pattern or pattern.endswith(r'\s*'))
130
+
131
+ if is_closing_brace_pattern and prefer_last:
132
+ # Use heuristics to find the best closing brace match
133
+ return _find_best_closing_brace_match(matches, text)
134
+
135
+ # Default behavior: use last match if prefer_last, otherwise first match
136
+ return matches[-1] if prefer_last else matches[0]
137
+
138
+
139
+ def _find_best_closing_brace_match(matches, text: str):
140
+ """
141
+ Find the best closing brace match using C# structure heuristics.
142
+
143
+ Enhanced heuristics for scope-aware matching:
144
+ 1. Prefer matches with lower indentation (likely class-level)
145
+ 2. Prefer matches closer to end of file
146
+ 3. Avoid matches that seem to be inside method bodies
147
+ 4. For #endregion patterns, ensure class-level context
148
+ 5. Validate insertion point is at appropriate scope
149
+
150
+ Args:
151
+ matches: List of regex match objects
152
+ text: The full text being searched
153
+
154
+ Returns:
155
+ The best match object
156
+ """
157
+ if not matches:
158
+ return None
159
+
160
+ scored_matches = []
161
+ lines = text.splitlines()
162
+
163
+ for match in matches:
164
+ score = 0
165
+ start_pos = match.start()
166
+
167
+ # Find which line this match is on
168
+ lines_before = text[:start_pos].count('\n')
169
+ line_num = lines_before
170
+
171
+ if line_num < len(lines):
172
+ line_content = lines[line_num]
173
+
174
+ # Calculate indentation level (lower is better for class braces)
175
+ indentation = len(line_content) - len(line_content.lstrip())
176
+
177
+ # Prefer lower indentation (class braces are typically less indented than method braces)
178
+ # Max 20 points for indentation=0
179
+ score += max(0, 20 - indentation)
180
+
181
+ # Prefer matches closer to end of file (class closing braces are typically at the end)
182
+ distance_from_end = len(lines) - line_num
183
+ # More points for being closer to end
184
+ score += max(0, 10 - distance_from_end)
185
+
186
+ # Look at surrounding context to avoid method braces
187
+ context_start = max(0, line_num - 3)
188
+ context_end = min(len(lines), line_num + 2)
189
+ context_lines = lines[context_start:context_end]
190
+
191
+ # Penalize if this looks like it's inside a method (has method-like patterns above)
192
+ for context_line in context_lines:
193
+ if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
194
+ score -= 5 # Penalty for being near method signatures
195
+
196
+ # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
197
+ if indentation <= 4 and distance_from_end <= 3:
198
+ score += 15 # Bonus for likely class-ending brace
199
+
200
+ scored_matches.append((score, match))
201
+
202
+ # Return the match with the highest score
203
+ scored_matches.sort(key=lambda x: x[0], reverse=True)
204
+ best_match = scored_matches[0][1]
205
+
206
+ return best_match
207
+
208
+
209
+ def _infer_class_name(script_name: str) -> str:
210
+ # Default to script name as class name (common Unity pattern)
211
+ return (script_name or "").strip()
212
+
213
+
214
+ def _extract_code_after(keyword: str, request: str) -> str:
215
+ # Deprecated with NL removal; retained as no-op for compatibility
216
+ idx = request.lower().find(keyword)
217
+ if idx >= 0:
218
+ return request[idx + len(keyword):].strip()
219
+ return ""
220
+ # Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services
221
+
222
+
223
+ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
224
+ """Best-effort normalization of script "name" and "path".
225
+
226
+ Accepts any of:
227
+ - name = "SmartReach", path = "Assets/Scripts/Interaction"
228
+ - name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
229
+ - name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
230
+ - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
231
+ - name or path using uri prefixes: unity://path/..., file://...
232
+ - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
233
+
234
+ Returns (name_without_extension, directory_path_under_Assets).
235
+ """
236
+ n = (name or "").strip()
237
+ p = (path or "").strip()
238
+
239
+ def strip_prefix(s: str) -> str:
240
+ if s.startswith("unity://path/"):
241
+ return s[len("unity://path/"):]
242
+ if s.startswith("file://"):
243
+ return s[len("file://"):]
244
+ return s
245
+
246
+ def collapse_duplicate_tail(s: str) -> str:
247
+ # Collapse trailing "/X.cs/X.cs" to "/X.cs"
248
+ parts = s.split("/")
249
+ if len(parts) >= 2 and parts[-1] == parts[-2]:
250
+ parts = parts[:-1]
251
+ return "/".join(parts)
252
+
253
+ # Prefer a full path if provided in either field
254
+ candidate = ""
255
+ for v in (n, p):
256
+ v2 = strip_prefix(v)
257
+ if v2.endswith(".cs") or v2.startswith("Assets/"):
258
+ candidate = v2
259
+ break
260
+
261
+ if candidate:
262
+ candidate = collapse_duplicate_tail(candidate)
263
+ # If a directory was passed in path and file in name, join them
264
+ if not candidate.endswith(".cs") and n.endswith(".cs"):
265
+ v2 = strip_prefix(n)
266
+ candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1])
267
+ if candidate.endswith(".cs"):
268
+ parts = candidate.split("/")
269
+ file_name = parts[-1]
270
+ dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets"
271
+ base = file_name[:-
272
+ 3] if file_name.lower().endswith(".cs") else file_name
273
+ return base, dir_path
274
+
275
+ # Fall back: remove extension from name if present and return given path
276
+ base_name = n[:-3] if n.lower().endswith(".cs") else n
277
+ return base_name, (p or "Assets")
278
+
279
+
280
+ def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any:
281
+ if not isinstance(resp, dict):
282
+ return resp
283
+ data = resp.setdefault("data", {})
284
+ data.setdefault("normalizedEdits", edits)
285
+ if routing:
286
+ data["routing"] = routing
287
+ return resp
288
+
289
+
290
+ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None,
291
+ normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
292
+ payload: dict[str, Any] = {"success": False,
293
+ "code": code, "message": message}
294
+ data: dict[str, Any] = {}
295
+ if expected:
296
+ data["expected"] = expected
297
+ if rewrite:
298
+ data["rewrite_suggestion"] = rewrite
299
+ if normalized is not None:
300
+ data["normalizedEdits"] = normalized
301
+ if routing:
302
+ data["routing"] = routing
303
+ if extra:
304
+ data.update(extra)
305
+ if data:
306
+ payload["data"] = data
307
+ return payload
308
+
309
+ # Natural-language parsing removed; clients should send structured edits.
310
+
311
+
312
+ @mcp_for_unity_tool(name="script_apply_edits", description=(
313
+ """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
314
+ Best practices:
315
+ - Prefer anchor_* ops for pattern-based insert/replace near stable markers
316
+ - Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
317
+ - Avoid whole-file regex deletes; validators will guard unbalanced braces
318
+ - For tail insertions, prefer anchor/regex_replace on final brace (class closing)
319
+ - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
320
+ Canonical fields (use these exact keys):
321
+ - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
322
+ - className: string (defaults to 'name' if omitted on method/class ops)
323
+ - methodName: string (required for replace_method, delete_method)
324
+ - replacement: string (required for replace_method, insert_method)
325
+ - position: start | end | after | before (insert_method only)
326
+ - afterMethodName / beforeMethodName: string (required when position='after'/'before')
327
+ - anchor: regex string (for anchor_* ops)
328
+ - text: string (for anchor_insert/anchor_replace)
329
+ Examples:
330
+ 1) Replace a method:
331
+ {
332
+ "name": "SmartReach",
333
+ "path": "Assets/Scripts/Interaction",
334
+ "edits": [
335
+ {
336
+ "op": "replace_method",
337
+ "className": "SmartReach",
338
+ "methodName": "HasTarget",
339
+ "replacement": "public bool HasTarget(){ return currentTarget!=null; }"
340
+ }
341
+ ],
342
+ "options": {"validate": "standard", "refresh": "immediate"}
343
+ }
344
+ "2) Insert a method after another:
345
+ {
346
+ "name": "SmartReach",
347
+ "path": "Assets/Scripts/Interaction",
348
+ "edits": [
349
+ {
350
+ "op": "insert_method",
351
+ "className": "SmartReach",
352
+ "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }",
353
+ "position": "after",
354
+ "afterMethodName": "GetCurrentTarget"
355
+ }
356
+ ],
357
+ }
358
+ ]"""
359
+ ))
360
+ async def script_apply_edits(
361
+ ctx: Context,
362
+ name: Annotated[str, "Name of the script to edit"],
363
+ path: Annotated[str, "Path to the script to edit under Assets/ directory"],
364
+ edits: Annotated[Union[list[dict[str, Any]], str], "List of edits to apply to the script (JSON list or stringified JSON)"],
365
+ options: Annotated[dict[str, Any],
366
+ "Options for the script edit"] | None = None,
367
+ script_type: Annotated[str,
368
+ "Type of the script to edit"] = "MonoBehaviour",
369
+ namespace: Annotated[str,
370
+ "Namespace of the script to edit"] | None = None,
371
+ ) -> dict[str, Any]:
372
+ unity_instance = get_unity_instance_from_context(ctx)
373
+ await ctx.info(
374
+ f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
375
+
376
+ # Parse edits if they came as a stringified JSON
377
+ edits = parse_json_payload(edits)
378
+ if not isinstance(edits, list):
379
+ return {"success": False, "message": f"Edits must be a list or JSON string of a list, got {type(edits)}"}
380
+
381
+ # Normalize locator first so downstream calls target the correct script file.
382
+ name, path = _normalize_script_locator(name, path)
383
+ # Normalize unsupported or aliased ops to known structured/text paths
384
+
385
+ def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]:
386
+ # Unwrap single-key wrappers like {"replace_method": {...}}
387
+ for wrapper_key in (
388
+ "replace_method", "insert_method", "delete_method",
389
+ "replace_class", "delete_class",
390
+ "anchor_insert", "anchor_replace", "anchor_delete",
391
+ ):
392
+ if wrapper_key in edit and isinstance(edit[wrapper_key], dict):
393
+ inner = dict(edit[wrapper_key])
394
+ inner["op"] = wrapper_key
395
+ edit = inner
396
+ break
397
+
398
+ e = dict(edit)
399
+ op = (e.get("op") or e.get("operation") or e.get(
400
+ "type") or e.get("mode") or "").strip().lower()
401
+ if op:
402
+ e["op"] = op
403
+
404
+ # Common field aliases
405
+ if "class_name" in e and "className" not in e:
406
+ e["className"] = e.pop("class_name")
407
+ if "class" in e and "className" not in e:
408
+ e["className"] = e.pop("class")
409
+ if "method_name" in e and "methodName" not in e:
410
+ e["methodName"] = e.pop("method_name")
411
+ # Some clients use a generic 'target' for method name
412
+ if "target" in e and "methodName" not in e:
413
+ e["methodName"] = e.pop("target")
414
+ if "method" in e and "methodName" not in e:
415
+ e["methodName"] = e.pop("method")
416
+ if "new_content" in e and "replacement" not in e:
417
+ e["replacement"] = e.pop("new_content")
418
+ if "newMethod" in e and "replacement" not in e:
419
+ e["replacement"] = e.pop("newMethod")
420
+ if "new_method" in e and "replacement" not in e:
421
+ e["replacement"] = e.pop("new_method")
422
+ if "content" in e and "replacement" not in e:
423
+ e["replacement"] = e.pop("content")
424
+ if "after" in e and "afterMethodName" not in e:
425
+ e["afterMethodName"] = e.pop("after")
426
+ if "after_method" in e and "afterMethodName" not in e:
427
+ e["afterMethodName"] = e.pop("after_method")
428
+ if "before" in e and "beforeMethodName" not in e:
429
+ e["beforeMethodName"] = e.pop("before")
430
+ if "before_method" in e and "beforeMethodName" not in e:
431
+ e["beforeMethodName"] = e.pop("before_method")
432
+ # anchor_method → before/after based on position (default after)
433
+ if "anchor_method" in e:
434
+ anchor = e.pop("anchor_method")
435
+ pos = (e.get("position") or "after").strip().lower()
436
+ if pos == "before" and "beforeMethodName" not in e:
437
+ e["beforeMethodName"] = anchor
438
+ elif "afterMethodName" not in e:
439
+ e["afterMethodName"] = anchor
440
+ if "anchorText" in e and "anchor" not in e:
441
+ e["anchor"] = e.pop("anchorText")
442
+ if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"):
443
+ e["anchor"] = e.pop("pattern")
444
+ if "newText" in e and "text" not in e:
445
+ e["text"] = e.pop("newText")
446
+
447
+ # CI compatibility (T‑A/T‑E):
448
+ # Accept method-anchored anchor_insert and upgrade to insert_method
449
+ # Example incoming shape:
450
+ # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."}
451
+ if (
452
+ e.get("op") == "anchor_insert"
453
+ and not e.get("anchor")
454
+ and (e.get("afterMethodName") or e.get("beforeMethodName"))
455
+ ):
456
+ e["op"] = "insert_method"
457
+ if "replacement" not in e:
458
+ e["replacement"] = e.get("text", "")
459
+
460
+ # LSP-like range edit -> replace_range
461
+ if "range" in e and isinstance(e["range"], dict):
462
+ rng = e.pop("range")
463
+ start = rng.get("start", {})
464
+ end = rng.get("end", {})
465
+ # Convert 0-based to 1-based line/col
466
+ e["op"] = "replace_range"
467
+ e["startLine"] = int(start.get("line", 0)) + 1
468
+ e["startCol"] = int(start.get("character", 0)) + 1
469
+ e["endLine"] = int(end.get("line", 0)) + 1
470
+ e["endCol"] = int(end.get("character", 0)) + 1
471
+ if "newText" in edit and "text" not in e:
472
+ e["text"] = edit.get("newText", "")
473
+ return e
474
+
475
+ normalized_edits: list[dict[str, Any]] = []
476
+ for raw in edits or []:
477
+ e = _unwrap_and_alias(raw)
478
+ op = (e.get("op") or e.get("operation") or e.get(
479
+ "type") or e.get("mode") or "").strip().lower()
480
+
481
+ # Default className to script name if missing on structured method/class ops
482
+ if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"):
483
+ e["className"] = name
484
+
485
+ # Map common aliases for text ops
486
+ if op in ("text_replace",):
487
+ e["op"] = "replace_range"
488
+ normalized_edits.append(e)
489
+ continue
490
+ if op in ("regex_delete",):
491
+ e["op"] = "regex_replace"
492
+ e.setdefault("text", "")
493
+ normalized_edits.append(e)
494
+ continue
495
+ if op == "regex_replace" and ("replacement" not in e):
496
+ if "text" in e:
497
+ e["replacement"] = e.get("text", "")
498
+ elif "insert" in e or "content" in e:
499
+ e["replacement"] = e.get(
500
+ "insert") or e.get("content") or ""
501
+ if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")):
502
+ e["op"] = "anchor_delete"
503
+ normalized_edits.append(e)
504
+ continue
505
+ normalized_edits.append(e)
506
+
507
+ edits = normalized_edits
508
+ normalized_for_echo = edits
509
+
510
+ # Validate required fields and produce machine-parsable hints
511
+ def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]:
512
+ return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
513
+
514
+ for e in edits or []:
515
+ op = e.get("op", "")
516
+ if op == "replace_method":
517
+ if not e.get("methodName"):
518
+ return error_with_hint(
519
+ "replace_method requires 'methodName'.",
520
+ {"op": "replace_method", "required": [
521
+ "className", "methodName", "replacement"]},
522
+ {"edits[0].methodName": "HasTarget"}
523
+ )
524
+ if not (e.get("replacement") or e.get("text")):
525
+ return error_with_hint(
526
+ "replace_method requires 'replacement' (inline or base64).",
527
+ {"op": "replace_method", "required": [
528
+ "className", "methodName", "replacement"]},
529
+ {"edits[0].replacement": "public bool X(){ return true; }"}
530
+ )
531
+ elif op == "insert_method":
532
+ if not (e.get("replacement") or e.get("text")):
533
+ return error_with_hint(
534
+ "insert_method requires a non-empty 'replacement'.",
535
+ {"op": "insert_method", "required": ["className", "replacement"], "position": {
536
+ "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
537
+ {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
538
+ )
539
+ pos = (e.get("position") or "").lower()
540
+ if pos == "after" and not e.get("afterMethodName"):
541
+ return error_with_hint(
542
+ "insert_method with position='after' requires 'afterMethodName'.",
543
+ {"op": "insert_method", "position": {
544
+ "after_requires": "afterMethodName"}},
545
+ {"edits[0].afterMethodName": "GetCurrentTarget"}
546
+ )
547
+ if pos == "before" and not e.get("beforeMethodName"):
548
+ return error_with_hint(
549
+ "insert_method with position='before' requires 'beforeMethodName'.",
550
+ {"op": "insert_method", "position": {
551
+ "before_requires": "beforeMethodName"}},
552
+ {"edits[0].beforeMethodName": "GetCurrentTarget"}
553
+ )
554
+ elif op == "delete_method":
555
+ if not e.get("methodName"):
556
+ return error_with_hint(
557
+ "delete_method requires 'methodName'.",
558
+ {"op": "delete_method", "required": [
559
+ "className", "methodName"]},
560
+ {"edits[0].methodName": "PrintSeries"}
561
+ )
562
+ elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
563
+ if not e.get("anchor"):
564
+ return error_with_hint(
565
+ f"{op} requires 'anchor' (regex).",
566
+ {"op": op, "required": ["anchor"]},
567
+ {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("}
568
+ )
569
+ if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")):
570
+ return error_with_hint(
571
+ f"{op} requires 'text'.",
572
+ {"op": op, "required": ["anchor", "text"]},
573
+ {"edits[0].text": "/* comment */\n"}
574
+ )
575
+
576
+ # Decide routing: structured vs text vs mixed
577
+ STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method",
578
+ "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"}
579
+ TEXT = {"prepend", "append", "replace_range", "regex_replace"}
580
+ ops_set = {(e.get("op") or "").lower() for e in edits or []}
581
+ all_struct = ops_set.issubset(STRUCT)
582
+ all_text = ops_set.issubset(TEXT)
583
+ mixed = not (all_struct or all_text)
584
+
585
+ # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
586
+ if all_struct:
587
+ opts2 = dict(options or {})
588
+ # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
589
+ opts2.setdefault("refresh", "immediate")
590
+ params_struct: dict[str, Any] = {
591
+ "action": "edit",
592
+ "name": name,
593
+ "path": path,
594
+ "namespace": namespace,
595
+ "scriptType": script_type,
596
+ "edits": edits,
597
+ "options": opts2,
598
+ }
599
+ resp_struct = await send_with_unity_instance(
600
+ async_send_command_with_retry,
601
+ unity_instance,
602
+ "manage_script",
603
+ params_struct,
604
+ )
605
+ if isinstance(resp_struct, dict) and resp_struct.get("success"):
606
+ pass # Optional sentinel reload removed (deprecated)
607
+ return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
608
+
609
+ # 1) read from Unity
610
+ read_resp = await async_send_command_with_retry("manage_script", {
611
+ "action": "read",
612
+ "name": name,
613
+ "path": path,
614
+ "namespace": namespace,
615
+ "scriptType": script_type,
616
+ }, instance_id=unity_instance)
617
+ if not isinstance(read_resp, dict) or not read_resp.get("success"):
618
+ return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
619
+
620
+ data = read_resp.get("data") or read_resp.get(
621
+ "result", {}).get("data") or {}
622
+ contents = data.get("contents")
623
+ if contents is None and data.get("contentsEncoded") and data.get("encodedContents"):
624
+ contents = base64.b64decode(
625
+ data["encodedContents"]).decode("utf-8")
626
+ if contents is None:
627
+ return {"success": False, "message": "No contents returned from Unity read."}
628
+
629
+ # Optional preview/dry-run: apply locally and return diff without writing
630
+ preview = bool((options or {}).get("preview"))
631
+
632
+ # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
633
+ if mixed:
634
+ text_edits = [e for e in edits or [] if (
635
+ e.get("op") or "").lower() in TEXT]
636
+ struct_edits = [e for e in edits or [] if (
637
+ e.get("op") or "").lower() in STRUCT]
638
+ try:
639
+ base_text = contents
640
+
641
+ def line_col_from_index(idx: int) -> tuple[int, int]:
642
+ line = base_text.count("\n", 0, idx) + 1
643
+ last_nl = base_text.rfind("\n", 0, idx)
644
+ col = (idx - (last_nl + 1)) + \
645
+ 1 if last_nl >= 0 else idx + 1
646
+ return line, col
647
+
648
+ at_edits: list[dict[str, Any]] = []
649
+ for e in text_edits:
650
+ opx = (e.get("op") or e.get("operation") or e.get(
651
+ "type") or e.get("mode") or "").strip().lower()
652
+ text_field = e.get("text") or e.get("insert") or e.get(
653
+ "content") or e.get("replacement") or ""
654
+ if opx == "anchor_insert":
655
+ anchor = e.get("anchor") or ""
656
+ position = (e.get("position") or "after").lower()
657
+ flags = re.MULTILINE | (
658
+ re.IGNORECASE if e.get("ignore_case") else 0)
659
+ try:
660
+ # Use improved anchor matching logic
661
+ m = _find_best_anchor_match(
662
+ anchor, base_text, flags, prefer_last=True)
663
+ except Exception as ex:
664
+ return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first")
665
+ if not m:
666
+ return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first")
667
+ idx = m.start() if position == "before" else m.end()
668
+ # Normalize insertion to avoid jammed methods
669
+ text_field_norm = text_field
670
+ if not text_field_norm.startswith("\n"):
671
+ text_field_norm = "\n" + text_field_norm
672
+ if not text_field_norm.endswith("\n"):
673
+ text_field_norm = text_field_norm + "\n"
674
+ sl, sc = line_col_from_index(idx)
675
+ at_edits.append(
676
+ {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
677
+ # do not mutate base_text when building atomic spans
678
+ elif opx == "replace_range":
679
+ if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")):
680
+ at_edits.append({
681
+ "startLine": int(e.get("startLine", 1)),
682
+ "startCol": int(e.get("startCol", 1)),
683
+ "endLine": int(e.get("endLine", 1)),
684
+ "endCol": int(e.get("endCol", 1)),
685
+ "newText": text_field
686
+ })
687
+ else:
688
+ return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
689
+ elif opx == "regex_replace":
690
+ pattern = e.get("pattern") or ""
691
+ try:
692
+ regex_obj = re.compile(pattern, re.MULTILINE | (
693
+ re.IGNORECASE if e.get("ignore_case") else 0))
694
+ except Exception as ex:
695
+ return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first")
696
+ m = regex_obj.search(base_text)
697
+ if not m:
698
+ continue
699
+ # Expand $1, $2... in replacement using this match
700
+
701
+ def _expand_dollars(rep: str, _m=m) -> str:
702
+ return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
703
+ repl = _expand_dollars(text_field)
704
+ sl, sc = line_col_from_index(m.start())
705
+ el, ec = line_col_from_index(m.end())
706
+ at_edits.append(
707
+ {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
708
+ # do not mutate base_text when building atomic spans
709
+ elif opx in ("prepend", "append"):
710
+ if opx == "prepend":
711
+ sl, sc = 1, 1
712
+ at_edits.append(
713
+ {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
714
+ # prepend can be applied atomically without local mutation
715
+ else:
716
+ # Insert at true EOF position (handles both \n and \r\n correctly)
717
+ eof_idx = len(base_text)
718
+ sl, sc = line_col_from_index(eof_idx)
719
+ new_text = ("\n" if not base_text.endswith(
720
+ "\n") else "") + text_field
721
+ at_edits.append(
722
+ {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
723
+ # do not mutate base_text when building atomic spans
724
+ else:
725
+ return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
726
+
727
+ sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
728
+ if at_edits:
729
+ params_text: dict[str, Any] = {
730
+ "action": "apply_text_edits",
731
+ "name": name,
732
+ "path": path,
733
+ "namespace": namespace,
734
+ "scriptType": script_type,
735
+ "edits": at_edits,
736
+ "precondition_sha256": sha,
737
+ "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
738
+ }
739
+ resp_text = await send_with_unity_instance(
740
+ async_send_command_with_retry,
741
+ unity_instance,
742
+ "manage_script",
743
+ params_text,
744
+ )
745
+ if not (isinstance(resp_text, dict) and resp_text.get("success")):
746
+ return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
747
+ # Optional sentinel reload removed (deprecated)
748
+ except Exception as e:
749
+ return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
750
+
751
+ if struct_edits:
752
+ opts2 = dict(options or {})
753
+ # Prefer debounced background refresh unless explicitly overridden
754
+ opts2.setdefault("refresh", "debounced")
755
+ params_struct: dict[str, Any] = {
756
+ "action": "edit",
757
+ "name": name,
758
+ "path": path,
759
+ "namespace": namespace,
760
+ "scriptType": script_type,
761
+ "edits": struct_edits,
762
+ "options": opts2
763
+ }
764
+ resp_struct = await send_with_unity_instance(
765
+ async_send_command_with_retry,
766
+ unity_instance,
767
+ "manage_script",
768
+ params_struct,
769
+ )
770
+ if isinstance(resp_struct, dict) and resp_struct.get("success"):
771
+ pass # Optional sentinel reload removed (deprecated)
772
+ return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
773
+
774
+ return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
775
+
776
+ # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
777
+ # so header guards and validation run on the C# side.
778
+ # Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
779
+ text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get(
780
+ "mode") or "").strip().lower() for e in (edits or [])}
781
+ structured_kinds = {"replace_class", "delete_class",
782
+ "replace_method", "delete_method", "insert_method", "anchor_insert"}
783
+ if not text_ops.issubset(structured_kinds):
784
+ # Convert to apply_text_edits payload
785
+ try:
786
+ base_text = contents
787
+
788
+ def line_col_from_index(idx: int) -> tuple[int, int]:
789
+ # 1-based line/col against base buffer
790
+ line = base_text.count("\n", 0, idx) + 1
791
+ last_nl = base_text.rfind("\n", 0, idx)
792
+ col = (idx - (last_nl + 1)) + \
793
+ 1 if last_nl >= 0 else idx + 1
794
+ return line, col
795
+
796
+ at_edits: list[dict[str, Any]] = []
797
+ for e in edits or []:
798
+ op = (e.get("op") or e.get("operation") or e.get(
799
+ "type") or e.get("mode") or "").strip().lower()
800
+ # aliasing for text field
801
+ text_field = e.get("text") or e.get(
802
+ "insert") or e.get("content") or ""
803
+ if op == "anchor_insert":
804
+ anchor = e.get("anchor") or ""
805
+ position = (e.get("position") or "after").lower()
806
+ # Use improved anchor matching logic with helpful errors, honoring ignore_case
807
+ try:
808
+ flags = re.MULTILINE | (
809
+ re.IGNORECASE if e.get("ignore_case") else 0)
810
+ m = _find_best_anchor_match(
811
+ anchor, base_text, flags, prefer_last=True)
812
+ except Exception as ex:
813
+ return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
814
+ if not m:
815
+ return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text")
816
+ idx = m.start() if position == "before" else m.end()
817
+ # Normalize insertion newlines
818
+ if text_field and not text_field.startswith("\n"):
819
+ text_field = "\n" + text_field
820
+ if text_field and not text_field.endswith("\n"):
821
+ text_field = text_field + "\n"
822
+ sl, sc = line_col_from_index(idx)
823
+ at_edits.append({
824
+ "startLine": sl,
825
+ "startCol": sc,
826
+ "endLine": sl,
827
+ "endCol": sc,
828
+ "newText": text_field or ""
829
+ })
830
+ # Do not mutate base buffer when building an atomic batch
831
+ elif op == "replace_range":
832
+ # Directly forward if already in line/col form
833
+ if "startLine" in e:
834
+ at_edits.append({
835
+ "startLine": int(e.get("startLine", 1)),
836
+ "startCol": int(e.get("startCol", 1)),
837
+ "endLine": int(e.get("endLine", 1)),
838
+ "endCol": int(e.get("endCol", 1)),
839
+ "newText": text_field
840
+ })
841
+ else:
842
+ # If only indices provided, skip (we don't support index-based here)
843
+ return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text")
844
+ elif op == "regex_replace":
845
+ pattern = e.get("pattern") or ""
846
+ repl = text_field
847
+ flags = re.MULTILINE | (
848
+ re.IGNORECASE if e.get("ignore_case") else 0)
849
+ # Early compile for clearer error messages
850
+ try:
851
+ regex_obj = re.compile(pattern, flags)
852
+ except Exception as ex:
853
+ return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text")
854
+ # Use smart anchor matching for consistent behavior with anchor_insert
855
+ m = _find_best_anchor_match(
856
+ pattern, base_text, flags, prefer_last=True)
857
+ if not m:
858
+ continue
859
+ # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
860
+
861
+ def _expand_dollars(rep: str, _m=m) -> str:
862
+ return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
863
+ repl_expanded = _expand_dollars(repl)
864
+ # Let C# side handle validation using Unity's built-in compiler services
865
+ sl, sc = line_col_from_index(m.start())
866
+ el, ec = line_col_from_index(m.end())
867
+ at_edits.append({
868
+ "startLine": sl,
869
+ "startCol": sc,
870
+ "endLine": el,
871
+ "endCol": ec,
872
+ "newText": repl_expanded
873
+ })
874
+ # Do not mutate base buffer when building an atomic batch
875
+ else:
876
+ return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text")
877
+
878
+ if not at_edits:
879
+ return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
880
+
881
+ sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
882
+ params: dict[str, Any] = {
883
+ "action": "apply_text_edits",
884
+ "name": name,
885
+ "path": path,
886
+ "namespace": namespace,
887
+ "scriptType": script_type,
888
+ "edits": at_edits,
889
+ "precondition_sha256": sha,
890
+ "options": {
891
+ "refresh": (options or {}).get("refresh", "debounced"),
892
+ "validate": (options or {}).get("validate", "standard"),
893
+ "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
894
+ }
895
+ }
896
+ resp = await send_with_unity_instance(
897
+ async_send_command_with_retry,
898
+ unity_instance,
899
+ "manage_script",
900
+ params,
901
+ )
902
+ if isinstance(resp, dict) and resp.get("success"):
903
+ pass # Optional sentinel reload removed (deprecated)
904
+ return _with_norm(
905
+ resp if isinstance(resp, dict)
906
+ else {"success": False, "message": str(resp)},
907
+ normalized_for_echo,
908
+ routing="text",
909
+ )
910
+ except Exception as e:
911
+ return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
912
+
913
+ # For regex_replace, honor preview consistently: if preview=true, always return diff without writing.
914
+ # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply.
915
+ if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")):
916
+ try:
917
+ preview_text = _apply_edits_locally(contents, edits)
918
+ import difflib
919
+ diff = list(difflib.unified_diff(contents.splitlines(
920
+ ), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
921
+ if len(diff) > 800:
922
+ diff = diff[:800] + ["... (diff truncated) ..."]
923
+ if preview:
924
+ return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
925
+ return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text")
926
+ except Exception as e:
927
+ return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text")
928
+ # 2) apply edits locally (only if not text-ops)
929
+ try:
930
+ new_contents = _apply_edits_locally(contents, edits)
931
+ except Exception as e:
932
+ return {"success": False, "message": f"Edit application failed: {e}"}
933
+
934
+ # Short-circuit no-op edits to avoid false "applied" reports downstream
935
+ if new_contents == contents:
936
+ return _with_norm({
937
+ "success": True,
938
+ "message": "No-op: contents unchanged",
939
+ "data": {"no_op": True, "evidence": {"reason": "identical_content"}}
940
+ }, normalized_for_echo, routing="text")
941
+
942
+ if preview:
943
+ # Produce a compact unified diff limited to small context
944
+ import difflib
945
+ a = contents.splitlines()
946
+ b = new_contents.splitlines()
947
+ diff = list(difflib.unified_diff(
948
+ a, b, fromfile="before", tofile="after", n=3))
949
+ # Limit diff size to keep responses small
950
+ if len(diff) > 2000:
951
+ diff = diff[:2000] + ["... (diff truncated) ..."]
952
+ return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
953
+
954
+ # 3) update to Unity
955
+ # Default refresh/validate for natural usage on text path as well
956
+ options = dict(options or {})
957
+ options.setdefault("validate", "standard")
958
+ options.setdefault("refresh", "debounced")
959
+
960
+ # Compute the SHA of the current file contents for the precondition
961
+ old_lines = contents.splitlines(keepends=True)
962
+ end_line = len(old_lines) + 1 # 1-based exclusive end
963
+ sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
964
+
965
+ # Apply a whole-file text edit rather than the deprecated 'update' action
966
+ params = {
967
+ "action": "apply_text_edits",
968
+ "name": name,
969
+ "path": path,
970
+ "namespace": namespace,
971
+ "scriptType": script_type,
972
+ "edits": [
973
+ {
974
+ "startLine": 1,
975
+ "startCol": 1,
976
+ "endLine": end_line,
977
+ "endCol": 1,
978
+ "newText": new_contents,
979
+ }
980
+ ],
981
+ "precondition_sha256": sha,
982
+ "options": options or {"validate": "standard", "refresh": "debounced"},
983
+ }
984
+
985
+ write_resp = await send_with_unity_instance(
986
+ async_send_command_with_retry,
987
+ unity_instance,
988
+ "manage_script",
989
+ params,
990
+ )
991
+ if isinstance(write_resp, dict) and write_resp.get("success"):
992
+ pass # Optional sentinel reload removed (deprecated)
993
+ return _with_norm(
994
+ write_resp if isinstance(write_resp, dict)
995
+ else {"success": False, "message": str(write_resp)},
996
+ normalized_for_echo,
997
+ routing="text",
998
+ )