utim-cli 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.
utim_cli/tools.py ADDED
@@ -0,0 +1,3381 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ import difflib
6
+ import threading
7
+ import time
8
+ import queue
9
+ import requests
10
+ import json
11
+ import urllib.parse
12
+ import pathlib
13
+ import sqlite3
14
+ from typing import Dict, Optional
15
+ from .blender_agent import blender_agent_create_from_image
16
+
17
+ # Strip ANSI/VT100 escape sequences from terminal output
18
+ _ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
19
+
20
+ def _strip_ansi(text: str) -> str:
21
+ return _ANSI_RE.sub("", text)
22
+
23
+ def _make_file_uri(path: str) -> str:
24
+ """Convert a Windows path to a clickable `file://` URI.
25
+ Handles spaces and backslashes correctly for terminals that auto‑link URIs.
26
+ """
27
+ # Resolve absolute path and normalise to POSIX style
28
+ p = pathlib.Path(path).resolve()
29
+ # Percent‑encode characters (e.g., spaces) and replace backslashes
30
+ encoded = urllib.parse.quote(str(p).replace('\\', '/'))
31
+ return f"file:///{encoded}"
32
+
33
+
34
+ # ─── Background Process Management ─────────────────────────────────────────────
35
+ import uuid, json, pathlib, subprocess, os, time, threading
36
+
37
+ # Helper for Blender object creation
38
+ def blender_create_object(name: str, object_type: str, mesh_data: dict, location: list = None, rotation: list = None, scale: list = None, export_path: str = None, export_format: str = "blend") -> str:
39
+ """Create a Blender object from mesh data.
40
+
41
+ Parameters
42
+ ----------
43
+ name: str
44
+ Name for the new object.
45
+ object_type: str
46
+ Currently only "MESH" is supported.
47
+ mesh_data: dict
48
+ Dictionary with ``vertices`` and ``faces`` (and optional normals/uvs).
49
+ location, rotation, scale: list of floats (optional)
50
+ Transform applied after creation.
51
+ export_path: str (optional)
52
+ Directory where the exported file will be saved. Defaults to ``blender_assets``.
53
+ export_format: str
54
+ One of "blend", "obj", "glb". Determines Blender export operator.
55
+ """
56
+ # Validate mesh data
57
+ if not isinstance(mesh_data, dict) or 'vertices' not in mesh_data or 'faces' not in mesh_data:
58
+ raise ValueError("mesh_data must contain 'vertices' and 'faces' keys")
59
+
60
+ # Build a temporary script that runs inside Blender
61
+ script_id = uuid.uuid4().hex
62
+ script_path = pathlib.Path('.utim_tmp') / f"{script_id}.py"
63
+ script_path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Prepare export settings
66
+ export_path = export_path or os.path.abspath(os.path.join('.utim_tmp', 'blender_assets'))
67
+ os.makedirs(export_path, exist_ok=True)
68
+ export_file = pathlib.Path(export_path) / f"{name}.{export_format}"
69
+
70
+ script_content = f"""
71
+ import bpy, json, os, pathlib
72
+
73
+ # Clean scene
74
+ bpy.ops.wm.read_factory_settings(use_empty=True)
75
+
76
+ mesh_dict = {json.dumps(mesh_data)}
77
+ verts = [(v[0], v[1], v[2]) for v in mesh_dict['vertices']]
78
+ faces = [tuple(f) for f in mesh_dict['faces']]
79
+
80
+ mesh = bpy.data.meshes.new('{name}_mesh')
81
+ mesh.from_pydata(verts, [], faces)
82
+ mesh.update()
83
+ obj = bpy.data.objects.new('{name}', mesh)
84
+
85
+ # Link object to collection
86
+ collection = bpy.context.scene.collection
87
+ collection.objects.link(obj)
88
+
89
+ # Apply transforms if provided
90
+ if {location is not None}:
91
+ obj.location = {location or [0,0,0]}
92
+ if {rotation is not None}:
93
+ obj.rotation_euler = {rotation or [0,0,0]}
94
+ if {scale is not None}:
95
+ obj.scale = {scale or [1,1,1]}
96
+
97
+ # Export according to format
98
+ export_path = r"{export_file}"
99
+ os.makedirs(os.path.dirname(export_path), exist_ok=True)
100
+ if '{export_format}' == 'obj':
101
+ bpy.ops.export_scene.obj(filepath=export_path, use_selection=False)
102
+ elif '{export_format}' == 'glb':
103
+ bpy.ops.export_scene.gltf(filepath=export_path, export_format='GLB')
104
+ else:
105
+ # default blend save
106
+ bpy.ops.wm.save_as_mainfile(filepath=export_path)
107
+ """
108
+ script_path.write_text(script_content)
109
+
110
+ # Run Blender in background mode
111
+ blender_exe = os.getenv('BLENDER_EXE', 'blender')
112
+ cmd = [blender_exe, '--background', '--python', str(script_path)]
113
+ result = subprocess.run(cmd, capture_output=True, text=True)
114
+ if result.returncode != 0:
115
+ raise RuntimeError(f"Blender execution failed: {result.stderr}")
116
+
117
+ # Cleanup script
118
+ try:
119
+ os.remove(script_path)
120
+ except OSError:
121
+ pass
122
+
123
+ return f"Blender object created and exported to {export_file}"
124
+
125
+ # Stores running background processes: {process_id: {process, output_queue, stopped}}
126
+ _BACKGROUND_PROCESSES: Dict[int, dict] = {}
127
+ _PROCESS_COUNTER = 0
128
+ _PROCESS_LOCK = threading.Lock()
129
+
130
+ def read_file(filepath: str, start_line: int = None, end_line: int = None) -> str:
131
+ """Reads the content of a file, optionally between start_line and end_line (1-indexed, inclusive).
132
+
133
+ If no range is given and the file is large, the first 250 lines AND last 250 lines
134
+ are returned (total 500 lines) to preserve critical tail sections like implementations.
135
+ The response header always shows the total line count so you know when to read further.
136
+ """
137
+ MAX_LINES = 250
138
+ try:
139
+ with open(filepath, "r", encoding="utf-8") as f:
140
+ all_lines = f.readlines()
141
+ except Exception as e:
142
+ return f"Error reading file {filepath}: {str(e)}"
143
+
144
+ total = len(all_lines)
145
+
146
+ if start_line is not None or end_line is not None:
147
+ # 1-indexed, inclusive; clamp to file bounds
148
+ s = max(1, int(start_line or 1))
149
+ e = min(total, int(end_line or total))
150
+ selected = all_lines[s - 1 : e]
151
+ header = f"[File: {filepath} ({_make_file_uri(filepath)}) | Lines {s}-{e} of {total}]\n"
152
+ return header + "".join(selected)
153
+ else:
154
+ if total <= MAX_LINES:
155
+ header = f"[File: {filepath} ({_make_file_uri(filepath)}) | {total} lines]\n"
156
+ return header + "".join(all_lines)
157
+ elif total <= MAX_LINES * 2:
158
+ # For moderately large files, show first 250 lines
159
+ selected = all_lines[:MAX_LINES]
160
+ header = (
161
+ f"[File: {filepath} ({_make_file_uri(filepath)}) | Showing lines 1-{MAX_LINES} of {total} — "
162
+ f"use start_line/end_line to read further]\n"
163
+ )
164
+ return header + "".join(selected)
165
+ else:
166
+ # FIX #3: For very large files, show BOTH first 250 AND last 250 lines
167
+ # This preserves critical tail sections (implementations, closing brackets, etc.)
168
+ first_part = all_lines[:MAX_LINES]
169
+ last_part = all_lines[-MAX_LINES:]
170
+ header = (
171
+ f"[File: {filepath} ({_make_file_uri(filepath)}) | Showing lines 1-{MAX_LINES} and {total-MAX_LINES+1}-{total} of {total} — "
172
+ f"critical end section preserved]\n"
173
+ )
174
+ result = "".join(first_part)
175
+ # Add separator to indicate continuation
176
+ result += f"\n... [lines {MAX_LINES+1} through {total-MAX_LINES} omitted] ...\n"
177
+ result += "".join(last_part)
178
+ return header + result
179
+
180
+ def _extract_patterns_after_write(filepath: str, content: str, old_content: str = ""):
181
+ """Extract patterns from file content after write/edit operation. Runs asynchronously."""
182
+ try:
183
+ from .pattern_extractor import extract_patterns
184
+ extract_patterns(filepath, content, "write" if not old_content else "edit")
185
+ except Exception:
186
+ pass # Pattern extraction failures should be silent
187
+
188
+ _DRY_RUN: bool = False
189
+
190
+ def validate_syntax(filepath: str, content: str) -> Optional[str]:
191
+ """Validates syntax of content based on file extension.
192
+ Returns error string if invalid, None if valid or unsupported.
193
+ """
194
+ ext = os.path.splitext(filepath)[1].lower()
195
+ if ext == ".py":
196
+ import ast
197
+ try:
198
+ ast.parse(content, filename=filepath)
199
+ except SyntaxError as e:
200
+ return f"Syntax Error: {e.msg} at line {e.lineno}, column {e.offset} in {filepath}"
201
+ except Exception as e:
202
+ return f"Parse Error in {filepath}: {str(e)}"
203
+ elif ext == ".json":
204
+ import json
205
+ try:
206
+ json.loads(content)
207
+ except json.JSONDecodeError as e:
208
+ return f"JSON Syntax Error: {e.msg} at line {e.lineno}, column {e.colno} in {filepath}"
209
+ except Exception as e:
210
+ return f"JSON Parse Error in {filepath}: {str(e)}"
211
+ elif ext in (".yaml", ".yml"):
212
+ try:
213
+ import yaml
214
+ try:
215
+ yaml.safe_load(content)
216
+ except Exception as e:
217
+ return f"YAML Syntax Error in {filepath}: {str(e)}"
218
+ except ImportError:
219
+ pass
220
+ elif ext == ".js":
221
+ import shutil
222
+ import subprocess
223
+ import tempfile
224
+ if shutil.which("node"):
225
+ with tempfile.NamedTemporaryFile(suffix=".js", delete=False, mode="w", encoding="utf-8") as f:
226
+ f.write(content)
227
+ temp_name = f.name
228
+ try:
229
+ res = subprocess.run(["node", "--check", temp_name], capture_output=True, text=True)
230
+ if res.returncode != 0:
231
+ err = res.stderr.replace(temp_name, filepath)
232
+ return f"JavaScript Syntax Error in {filepath}:\n{err}"
233
+ finally:
234
+ try:
235
+ os.unlink(temp_name)
236
+ except Exception:
237
+ pass
238
+ elif ext == ".ts":
239
+ import shutil
240
+ import subprocess
241
+ import tempfile
242
+ if shutil.which("tsc"):
243
+ with tempfile.NamedTemporaryFile(suffix=".ts", delete=False, mode="w", encoding="utf-8") as f:
244
+ f.write(content)
245
+ temp_name = f.name
246
+ try:
247
+ res = subprocess.run(["tsc", "--noEmit", "--skipLibCheck", temp_name], capture_output=True, text=True)
248
+ if res.returncode != 0:
249
+ err = res.stdout.replace(temp_name, filepath) + res.stderr.replace(temp_name, filepath)
250
+ return f"TypeScript Compilation Error in {filepath}:\n{err}"
251
+ finally:
252
+ try:
253
+ os.unlink(temp_name)
254
+ except Exception:
255
+ pass
256
+ return None
257
+
258
+ def write_file(filepath: str, content: str) -> str:
259
+ """Writes complete content to a file, overwriting any existing file. Use this to create or modify code."""
260
+ try:
261
+ old_content = ""
262
+ if os.path.exists(filepath):
263
+ with open(filepath, "r", encoding="utf-8") as f:
264
+ old_content = f.read()
265
+
266
+ # Pre-commit syntax check
267
+ syntax_error = validate_syntax(filepath, content)
268
+ if syntax_error:
269
+ err_mode = " (Dry Run Mode)" if _DRY_RUN else ""
270
+ return f"Pre-Commit Validation Failed{err_mode}:\n{syntax_error}"
271
+
272
+ if _DRY_RUN:
273
+ old_lines = old_content.splitlines() if old_content else []
274
+ new_lines = content.splitlines()
275
+ diff = list(difflib.unified_diff(old_lines, new_lines, n=0))
276
+ added = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
277
+ removed = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
278
+ if old_content:
279
+ return f"[Dry Run] Successfully simulated modifying {filepath}. Projected changes: +{added} -{removed} lines."
280
+ else:
281
+ return f"[Dry Run] Successfully simulated creating {filepath}. Projected: {len(new_lines)} lines."
282
+
283
+ os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
284
+ with open(filepath, "w", encoding="utf-8") as f:
285
+ f.write(content)
286
+
287
+ # Extract patterns asynchronously after write
288
+ import threading
289
+ threading.Thread(target=_extract_patterns_after_write, args=(filepath, content, old_content), daemon=True).start()
290
+
291
+ # Calculate a simple diff
292
+ old_lines = old_content.splitlines()
293
+ new_lines = content.splitlines()
294
+ diff = list(difflib.unified_diff(old_lines, new_lines, n=0))
295
+
296
+ added = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
297
+ removed = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
298
+
299
+ if old_content:
300
+ return f"Successfully modified {filepath}. Changes: +{added} -{removed} lines."
301
+ else:
302
+ return f"Successfully created {filepath}. Added {len(new_lines)} lines."
303
+ except Exception as e:
304
+ return f"Error writing file {filepath}: {str(e)}"
305
+
306
+ def edit_file(filepath: str, old_str: str = None, new_str: str = None, replacements: list = None) -> str:
307
+ """Replaces specific strings in a file. Can perform a single replacement or multiple non-contiguous replacements in batch."""
308
+ try:
309
+ if not os.path.exists(filepath):
310
+ return f"Error: File {filepath} does not exist."
311
+
312
+ with open(filepath, "r", encoding="utf-8") as f:
313
+ content = f.read()
314
+
315
+ if replacements is not None:
316
+ if not isinstance(replacements, list):
317
+ return "Error: replacements must be a list of objects with 'old_str' and 'new_str' keys."
318
+ if not replacements:
319
+ return "Error: replacements list is empty."
320
+
321
+ # Verify all replacements are valid and occur exactly once first
322
+ # to prevent partial edits leaving the file in a broken intermediate state.
323
+ current_content = content
324
+ for i, rep in enumerate(replacements):
325
+ if not isinstance(rep, dict) or "old_str" not in rep or "new_str" not in rep:
326
+ return f"Error: Replacement at index {i} must be a dictionary with 'old_str' and 'new_str'."
327
+
328
+ o_str = rep["old_str"]
329
+ n_str = rep["new_str"]
330
+
331
+ count = current_content.count(o_str)
332
+ if count == 0:
333
+ return f"Error (Replacement #{i+1}): The target string to replace was not found in the file."
334
+ if count > 1:
335
+ return f"Error (Replacement #{i+1}): The target string is ambiguous as it occurs {count} times in the file. Please provide more context."
336
+
337
+ current_content = current_content.replace(o_str, n_str, 1)
338
+
339
+ # Pre-commit syntax check
340
+ syntax_error = validate_syntax(filepath, current_content)
341
+ if syntax_error:
342
+ err_mode = " (Dry Run Mode)" if _DRY_RUN else ""
343
+ return f"Pre-Commit Validation Failed{err_mode}:\n{syntax_error}"
344
+
345
+ if _DRY_RUN:
346
+ return f"[Dry Run] Successfully simulated applying {len(replacements)} replacements in batch to {filepath}."
347
+
348
+ # All checks passed, apply edits
349
+ with open(filepath, "w", encoding="utf-8") as f:
350
+ f.write(current_content)
351
+ return f"Successfully applied {len(replacements)} replacements in batch to {filepath}."
352
+
353
+ else:
354
+ if old_str is None or new_str is None:
355
+ return "Error: Must specify either 'replacements' or both 'old_str' and 'new_str'."
356
+
357
+ count = content.count(old_str)
358
+ if count == 0:
359
+ return f"Error: The string to replace was not found in {filepath}."
360
+ if count > 1:
361
+ return f"Error: The string to replace occurs {count} times in {filepath}. Please provide more context in `old_str` to make it unique."
362
+
363
+ new_content = content.replace(old_str, new_str, 1)
364
+
365
+ # Pre-commit syntax check
366
+ syntax_error = validate_syntax(filepath, new_content)
367
+ if syntax_error:
368
+ err_mode = " (Dry Run Mode)" if _DRY_RUN else ""
369
+ return f"Pre-Commit Validation Failed{err_mode}:\n{syntax_error}"
370
+
371
+ if _DRY_RUN:
372
+ return f"[Dry Run] Successfully edited {filepath} (Simulated)."
373
+
374
+ with open(filepath, "w", encoding="utf-8") as f:
375
+ f.write(new_content)
376
+ return f"Successfully edited {filepath}."
377
+ except Exception as e:
378
+ return f"Error editing file {filepath}: {str(e)}"
379
+
380
+
381
+
382
+
383
+ # Module-level cancel-event slot. The orchestrator injects its own
384
+ # threading.Event here before each tool call so run_command can be aborted.
385
+ _cancel_event = None # type: threading.Event | None
386
+
387
+ # ─── Intelligent Sandbox mode ──────────────────────────────────────────────────
388
+ # Set to True via --sandbox CLI flag. When active, run_command uses static analysis
389
+ # to check commands for safety. Risky commands are blocked unless approved.
390
+ _SANDBOX_MODE: bool = False
391
+ _SANDBOX_IMAGE: str = "ubuntu:22.04" # unused legacy parameter
392
+
393
+ _APPROVED_COMMANDS = set()
394
+
395
+ def approve_command(command: str):
396
+ """Mark a specific command string as approved by the user for execution."""
397
+ _APPROVED_COMMANDS.add(command)
398
+
399
+ def is_command_approved(command: str) -> bool:
400
+ """Check if a command has been explicitly approved by the user."""
401
+ return command in _APPROVED_COMMANDS
402
+
403
+ def analyze_command_safety(command: str) -> tuple:
404
+ """Analyze a shell command for potential security risks.
405
+
406
+ Returns a tuple of (is_safe, reason).
407
+ """
408
+ if not command:
409
+ return True, ""
410
+
411
+ cmd_lower = command.lower()
412
+
413
+ # Exemptions for known safe combinations
414
+ exemptions = [
415
+ r"^pytest\b",
416
+ r"^npm\s+(run\s+)?test\b",
417
+ r"^git\s+(status|diff|log|show|branch)\b",
418
+ r"^python\s+-m\s+py_compile\b",
419
+ r"^python\s+--version\b",
420
+ ]
421
+ for ex in exemptions:
422
+ if re.search(ex, cmd_lower.strip()):
423
+ return True, ""
424
+
425
+ # Deletion / destruction
426
+ destructive_patterns = [
427
+ (r"\brm\b", "File deletion (rm)"),
428
+ (r"\bdel\b", "File deletion (del)"),
429
+ (r"\berase\b", "File deletion (erase)"),
430
+ (r"\brd\b", "Directory removal (rd)"),
431
+ (r"\brmdir\b", "Directory removal (rmdir)"),
432
+ (r"\bremove-item\b", "File deletion (Remove-Item)"),
433
+ (r"\bformat\b", "Disk formatting (format)"),
434
+ ]
435
+
436
+ # Execution of arbitrary/external scripts, binaries, or shells
437
+ execution_patterns = [
438
+ (r"\bsh\b", "Shell execution (sh)"),
439
+ (r"\bbash\b", "Shell execution (bash)"),
440
+ (r"\bcmd\b", "Shell execution (cmd)"),
441
+ (r"\bpowershell\b", "Shell execution (powershell)"),
442
+ (r"\bpwsh\b", "Shell execution (pwsh)"),
443
+ (r"\bpython\b", "Python script execution"),
444
+ (r"\bnode\b", "Node script execution"),
445
+ (r"\bperl\b", "Perl script execution"),
446
+ (r"\bruby\b", "Ruby script execution"),
447
+ (r"\bexec\b", "Process execution (exec)"),
448
+ (r"\beval\b", "Command evaluation (eval)"),
449
+ (r"\bsudo\b", "Superuser privilege escalation (sudo)"),
450
+ (r"\brunas\b", "Privilege escalation (runas)"),
451
+ ]
452
+
453
+ # Network tools (potential data exfiltration or malware download)
454
+ network_patterns = [
455
+ (r"\bcurl\b", "Network download/upload (curl)"),
456
+ (r"\bwget\b", "Network download (wget)"),
457
+ (r"\biwr\b", "PowerShell web request (Invoke-WebRequest)"),
458
+ (r"\birm\b", "PowerShell web request (Invoke-RestMethod)"),
459
+ (r"\bssh\b", "Remote access (ssh)"),
460
+ (r"\bscp\b", "Remote copy (scp)"),
461
+ (r"\bsftp\b", "Remote file transfer (sftp)"),
462
+ (r"\bftp\b", "File transfer (ftp)"),
463
+ (r"\btelnet\b", "Remote connection (telnet)"),
464
+ (r"\bnslookup\b", "DNS lookup (nslookup)"),
465
+ (r"\bdig\b", "DNS lookup (dig)"),
466
+ (r"\bping\b", "Network ping"),
467
+ ]
468
+
469
+ # Package managers & software installers (mutating system state)
470
+ installer_patterns = [
471
+ (r"\bpip\b", "Python package manager (pip)"),
472
+ (r"\bnpm\b", "Node package manager (npm)"),
473
+ (r"\byarn\b", "Node package manager (yarn)"),
474
+ (r"\bpnpm\b", "Node package manager (pnpm)"),
475
+ (r"\bpoetry\b", "Python environment manager (poetry)"),
476
+ (r"\bapt\b", "System package manager (apt)"),
477
+ (r"\byum\b", "System package manager (yum)"),
478
+ (r"\bbrew\b", "System package manager (brew)"),
479
+ (r"\bchoco\b", "System package manager (choco)"),
480
+ (r"\bgem\b", "Ruby package manager (gem)"),
481
+ ]
482
+
483
+ # File redirection / piping to files (writing contents)
484
+ redirection_patterns = [
485
+ (r">", "File writing/overwriting redirection (>)"),
486
+ (r">>", "File appending redirection (>>)"),
487
+ (r"\|", "Pipeline redirection (|)"),
488
+ ]
489
+
490
+ # Git mutation / modification
491
+ git_mutation_patterns = [
492
+ (r"\bgit\s+commit\b", "Git commit creation"),
493
+ (r"\bgit\s+push\b", "Git remote push"),
494
+ (r"\bgit\s+reset\b", "Git repository reset"),
495
+ (r"\bgit\s+checkout\b", "Git branch checkout/file discard"),
496
+ (r"\bgit\s+clean\b", "Git untracked file removal"),
497
+ (r"\bgit\s+merge\b", "Git branch merge"),
498
+ (r"\bgit\s+rebase\b", "Git branch rebase"),
499
+ ]
500
+
501
+ # System and Process Control
502
+ system_patterns = [
503
+ (r"\bkill\b", "Process termination (kill)"),
504
+ (r"\btaskkill\b", "Process termination (taskkill)"),
505
+ (r"\bstop-process\b", "Process termination (Stop-Process)"),
506
+ (r"\bshutdown\b", "System shutdown"),
507
+ (r"\breboot\b", "System reboot"),
508
+ (r"\breg\b", "Windows Registry access"),
509
+ ]
510
+
511
+ all_patterns = (
512
+ destructive_patterns +
513
+ execution_patterns +
514
+ network_patterns +
515
+ installer_patterns +
516
+ redirection_patterns +
517
+ git_mutation_patterns +
518
+ system_patterns
519
+ )
520
+
521
+ for pattern, name in all_patterns:
522
+ if re.search(pattern, cmd_lower):
523
+ # General safe check overrides
524
+ if "pytest" in cmd_lower and name in ["Python script execution", "Pipeline redirection (|)"]:
525
+ continue
526
+ if "git status" in cmd_lower and name == "Shell execution (sh)":
527
+ continue
528
+ if cmd_lower.strip() in ["python --version", "python -v"]:
529
+ continue
530
+ return False, name
531
+
532
+ return True, ""
533
+
534
+
535
+ # ─── Live shell state (shared with utim.py UI) ────────────────────────────────
536
+ _SHELL_STATE = {
537
+ "proc": None,
538
+ "cmd": "",
539
+ "cwd": "",
540
+ "output_lines": [],
541
+ "focused": False,
542
+ "active": False,
543
+ "_app_ref": [None],
544
+ }
545
+
546
+ _MAX_SHELL_LINES = 50
547
+
548
+
549
+ def _build_shell_argv(command: str, cwd: str) -> tuple:
550
+ """Return the argv list that executes *command* in the correct shell.
551
+
552
+ Returns a tuple of (argv_list, error_message). If error_message is not None,
553
+ the caller should return it instead of executing the command.
554
+
555
+ Dispatch rules
556
+ ──────────────
557
+ - Windows (native) → powershell.exe -NoProfile -NonInteractive -Command …
558
+ - macOS / Linux → bash -c …
559
+ """
560
+ if os.name == "nt":
561
+ return ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command], None
562
+ return ["bash", "-c", command], None
563
+
564
+
565
+ def _run_single_command_internal(command: str, dir_path: str = "", timeout: int = 120) -> tuple:
566
+ """Core process runner logic returning (output_string, exit_code)."""
567
+ # Resolve working directory — fall back to process cwd
568
+ if dir_path:
569
+ cwd = os.path.abspath(dir_path)
570
+ if not os.path.isdir(cwd):
571
+ return f"Error: dir_path '{dir_path}' is not a valid directory.", -1
572
+ else:
573
+ cwd = os.getcwd()
574
+
575
+ argv, error_msg = _build_shell_argv(command, cwd)
576
+ if error_msg:
577
+ return error_msg, -1
578
+
579
+ try:
580
+ # On Windows with the subprocess list form, CREATE_NEW_PROCESS_GROUP
581
+ # lets us send CTRL_BREAK_EVENT to terminate the child tree cleanly.
582
+ _pg_flag = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
583
+ proc = subprocess.Popen(
584
+ argv,
585
+ shell=False, # argv is already fully formed — no shell wrapper
586
+ stdin=subprocess.PIPE,
587
+ stdout=subprocess.PIPE,
588
+ stderr=subprocess.PIPE, # capture stderr separately
589
+ text=True,
590
+ encoding="utf-8",
591
+ errors="replace",
592
+ cwd=cwd,
593
+ creationflags=_pg_flag,
594
+ )
595
+ except FileNotFoundError as e:
596
+ # Docker/PowerShell/bash not found — give a clear error
597
+ binary = argv[0]
598
+ return (
599
+ f"Error: could not start '{binary}'. "
600
+ f"Make sure it is installed and on your PATH.\nDetail: {e}", -1
601
+ )
602
+ except Exception as e:
603
+ return f"Error starting command: {e}", -1
604
+
605
+ # ── Update shell state so the UI panel can render immediately ─────────────
606
+ _SHELL_STATE["proc"] = proc
607
+ _SHELL_STATE["cmd"] = command
608
+ _SHELL_STATE["cwd"] = cwd
609
+ _SHELL_STATE["output_lines"] = []
610
+ _SHELL_STATE["focused"] = False
611
+ _SHELL_STATE["active"] = True
612
+ _invalidate_ui()
613
+
614
+ stdout_parts: list = []
615
+ stderr_parts: list = []
616
+ cancel = _cancel_event # snapshot so it can't change mid-run
617
+
618
+ # ── Stdout reader ─────────────────────────────────────────────────────────
619
+ def _stdout_reader():
620
+ try:
621
+ for raw in proc.stdout:
622
+ line = _strip_ansi(raw.rstrip("\n"))
623
+ stdout_parts.append(raw)
624
+ _SHELL_STATE["output_lines"].append(line)
625
+ if len(_SHELL_STATE["output_lines"]) > _MAX_SHELL_LINES:
626
+ _SHELL_STATE["output_lines"].pop(0)
627
+ _invalidate_ui()
628
+ except Exception:
629
+ pass
630
+
631
+ # ── Stderr reader ─────────────────────────────────────────────────────────
632
+ def _stderr_reader():
633
+ try:
634
+ for raw in proc.stderr:
635
+ stderr_parts.append(raw)
636
+ except Exception:
637
+ pass
638
+
639
+ stdout_t = threading.Thread(target=_stdout_reader, daemon=True)
640
+ stderr_t = threading.Thread(target=_stderr_reader, daemon=True)
641
+ stdout_t.start()
642
+ stderr_t.start()
643
+
644
+ # ── Poll until process exits, is cancelled, or times out ───────────────
645
+ exit_code: int = 0
646
+ start_time = time.time()
647
+ try:
648
+ while True:
649
+ if cancel is not None and cancel.is_set():
650
+ _kill_proc(proc)
651
+ stdout_t.join(timeout=1)
652
+ stderr_t.join(timeout=1)
653
+ return "[Command aborted by user]", -1
654
+ if time.time() - start_time > timeout:
655
+ _kill_proc(proc)
656
+ stdout_t.join(timeout=1)
657
+ stderr_t.join(timeout=1)
658
+ return f"[Command timed out after {timeout} seconds]", -2
659
+ rc = proc.poll()
660
+ if rc is not None:
661
+ exit_code = rc
662
+ break
663
+ time.sleep(0.05)
664
+ finally:
665
+ _SHELL_STATE["active"] = False
666
+ _SHELL_STATE["focused"] = False
667
+ _SHELL_STATE["proc"] = None
668
+ _invalidate_ui()
669
+
670
+ stdout_t.join(timeout=2)
671
+ stderr_t.join(timeout=2)
672
+
673
+ stdout_text = "".join(stdout_parts)
674
+ stderr_text = "".join(stderr_parts)
675
+
676
+ # ── Build structured result ───────────────────────────────────────────────
677
+ parts = []
678
+ parts.append(f"[exit_code: {exit_code}]")
679
+ if stdout_text.strip():
680
+ parts.append("[stdout]")
681
+ parts.append(stdout_text.rstrip())
682
+ if stderr_text.strip():
683
+ parts.append("[stderr]")
684
+ parts.append(stderr_text.rstrip())
685
+ if not stdout_text.strip() and not stderr_text.strip():
686
+ parts.append("(no output)")
687
+
688
+ return "\n".join(parts), exit_code
689
+
690
+
691
+ def run_command(command: str = "", dir_path: str = "", timeout: int = 120, commands: list = None) -> str:
692
+ """Execute a shell command (or list of commands sequentially) and return stdout, stderr, and exit code.
693
+
694
+ Parameters
695
+ ──────────
696
+ command : the shell command string to run (single command)
697
+ dir_path : directory to run the command in (defaults to current working dir)
698
+ timeout : maximum execution time in seconds (defaults to 120)
699
+ commands : list of shell commands to run in sequence (stops on first failure)
700
+ """
701
+ if commands is None:
702
+ if not command:
703
+ return "Error: No command specified."
704
+ commands = [command]
705
+ elif not commands:
706
+ return "Error: No commands specified in list."
707
+
708
+ if _DRY_RUN:
709
+ results = []
710
+ for cmd in commands:
711
+ results.append(f"--- Command: {cmd} ---\n[Dry Run] Simulated execution of: {cmd}\n(Exit Code: 0)")
712
+ return "\n\n".join(results)
713
+
714
+ results = []
715
+ for cmd in commands:
716
+ if _SANDBOX_MODE:
717
+ is_safe, reason = analyze_command_safety(cmd)
718
+ if not is_safe and not is_command_approved(cmd):
719
+ return f"Error: Command execution blocked by Intelligent Sandbox. Reason: {reason}."
720
+ _APPROVED_COMMANDS.discard(cmd)
721
+
722
+ res, code = _run_single_command_internal(cmd, dir_path, timeout)
723
+ results.append(f"--- Command: {cmd} ---\n{res}")
724
+ if code != 0:
725
+ results.append(f"\n[Execution halted due to non-zero exit code: {code}]")
726
+ break
727
+
728
+ return "\n\n".join(results)
729
+
730
+
731
+ def _kill_proc(proc):
732
+ """Terminate a Popen process cross-platform."""
733
+ try:
734
+ import signal as _sig
735
+ if os.name == "nt":
736
+ proc.terminate()
737
+ else:
738
+ os.kill(proc.pid, _sig.SIGTERM)
739
+ proc.wait(timeout=3)
740
+ except Exception:
741
+ try:
742
+ proc.kill()
743
+ except Exception:
744
+ pass
745
+
746
+
747
+ def _invalidate_ui():
748
+ """Ask the prompt_toolkit app to redraw (called from background threads)."""
749
+ app = _SHELL_STATE["_app_ref"][0]
750
+ if app is not None:
751
+ try:
752
+ if app.renderer and not getattr(app.renderer, "waiting_for_cpr", False):
753
+ app.invalidate()
754
+ except Exception:
755
+ pass
756
+
757
+
758
+ def shell_send_input(text: str):
759
+ """Write text to the running shell process stdin (called from UI key handler)."""
760
+ proc = _SHELL_STATE.get("proc")
761
+ if proc and proc.stdin and not proc.stdin.closed:
762
+ try:
763
+ proc.stdin.write(text)
764
+ proc.stdin.flush()
765
+ except Exception:
766
+ pass
767
+
768
+
769
+ def shell_send_ctrl_c():
770
+ """Kill the running shell process instantly (Ctrl+C scoped to shell only).
771
+
772
+ On Windows: uses 'taskkill /f /t' to force-kill the entire process tree
773
+ (cmd.exe + any child processes like npm/node) — equivalent to closing a
774
+ terminal window. This is more reliable than CTRL_BREAK_EVENT which can
775
+ be slow or ignored by grandchild processes when shell=True is used.
776
+
777
+ On Unix: sends SIGINT to the process group so all children receive it.
778
+ """
779
+ import signal as _sig
780
+ proc = _SHELL_STATE.get("proc")
781
+ if proc is None:
782
+ return
783
+ try:
784
+ if os.name == "nt":
785
+ # Force-kill the whole process tree instantly
786
+ subprocess.run(
787
+ ["taskkill", "/f", "/t", "/pid", str(proc.pid)],
788
+ capture_output=True,
789
+ )
790
+ else:
791
+ # Send SIGINT to the entire process group (pid < 0 targets group)
792
+ try:
793
+ os.killpg(os.getpgid(proc.pid), _sig.SIGINT)
794
+ except Exception:
795
+ os.kill(proc.pid, _sig.SIGINT)
796
+ except Exception:
797
+ try:
798
+ proc.terminate()
799
+ except Exception:
800
+ pass
801
+
802
+
803
+ def list_directory(path: str = ".") -> str:
804
+ """Lists the files and directories in a given path."""
805
+ try:
806
+ items = os.listdir(path)
807
+ return f"Contents of {path}:\n" + "\n".join(items)
808
+ except Exception as e:
809
+ return f"Error listing directory: {str(e)}"
810
+
811
+ def web_search(prompt: str, level: str = "medium") -> str:
812
+ """Performs an agentic deep web research based on the prompt and level.
813
+
814
+ Concurrent scraping is used to fetch raw web page contents in parallel to enrich Tavily search snippets.
815
+ """
816
+ import concurrent.futures
817
+ import re
818
+ import html as html_lib
819
+ import time
820
+
821
+ api_key = os.getenv("TAVILY_API_KEY")
822
+ from utim_cli.config import config
823
+ llm_key = os.getenv("OPENROUTER_API_KEY") or config.get("api_key")
824
+ if not api_key:
825
+ return "Error: TAVILY_API_KEY environment variable is not set."
826
+ if not llm_key:
827
+ return "Error: Neither UTIM API key nor OPENROUTER_API_KEY environment variable is set. The research agent needs an LLM key."
828
+
829
+ num_queries = {"low": 1, "medium": 4, "high": 8}.get(level.lower(), 4)
830
+
831
+ # 1. Generate search queries optimized for keyword-matching search engines
832
+ try:
833
+ from utim_cli.bootstrap import get_subagent_rag_context
834
+ subagent_rag_ctx = get_subagent_rag_context("web_search", prompt)
835
+ except Exception:
836
+ subagent_rag_ctx = ""
837
+
838
+ system_prompt = (
839
+ "You are a search engine query optimizer. Generate exactly {num_queries} distinct, short, "
840
+ "keyword-based search queries (not full sentences) optimized for a standard keyword-matching "
841
+ "search engine to research the following prompt. Keep each query under 5-6 words. "
842
+ "Output ONLY the queries, one per line. Do not use quotes, bullet points, numbering, or extra text."
843
+ ).format(num_queries=num_queries)
844
+ if subagent_rag_ctx:
845
+ system_prompt += f"\n\n{subagent_rag_ctx}"
846
+
847
+ models_to_try = [
848
+ "liquid/lfm-2.5-1.2b-instruct:free",
849
+ "qwen/qwen3-coder:free",
850
+ "google/gemma-3-27b-it:free",
851
+ ]
852
+
853
+ queries = []
854
+ for model in models_to_try:
855
+ try:
856
+ query_gen_payload = {
857
+ "model": model,
858
+ "messages": [
859
+ {"role": "system", "content": system_prompt},
860
+ {"role": "user", "content": prompt}
861
+ ]
862
+ }
863
+ from utim_cli.client_utils import proxy_openrouter_request
864
+ resp = proxy_openrouter_request(json_data=query_gen_payload, stream=False, timeout=20)
865
+ if resp.status_code == 200:
866
+ queries_text = resp.json()["choices"][0]["message"]["content"]
867
+ lines = [q.strip("- *1234567890.\"") for q in queries_text.splitlines() if q.strip()]
868
+ queries = [q for q in lines if len(q.split()) <= 8][:num_queries]
869
+ if queries:
870
+ break
871
+ except Exception:
872
+ continue
873
+
874
+ if not queries:
875
+ # Heuristic fallback to turn prompt into keywords if LLMs are down or returned empty results
876
+ import re
877
+ s = re.sub(r"[^\w\s\-\/\.]", " ", prompt)
878
+ words = s.split()
879
+ stop_words = {
880
+ "find", "search", "specifically", "the", "a", "an", "and", "or", "but", "in", "on",
881
+ "at", "to", "for", "with", "by", "about", "against", "from", "how", "what", "which",
882
+ "who", "why", "where", "when", "are", "is", "was", "were", "be", "been", "being",
883
+ "have", "has", "had", "do", "does", "did", "recommendations", "recommendation",
884
+ "reputable", "review", "reviews", "sites", "site", "sources", "source", "top", "picks",
885
+ "include", "source", "names", "links", "link"
886
+ }
887
+ keywords = [w for w in words if w.lower() not in stop_words]
888
+ fallback_query = " ".join(keywords[:6]) if keywords else " ".join(words[:6])
889
+ queries = [fallback_query]
890
+
891
+ # 2. Execute searches in parallel
892
+ search_results = []
893
+
894
+ def fetch_query(q):
895
+ results = []
896
+ try:
897
+ tavily_resp = requests.post("https://api.tavily.com/search", json={
898
+ "api_key": api_key,
899
+ "query": q,
900
+ "search_depth": "advanced",
901
+ "include_raw_content": False,
902
+ "max_results": 10
903
+ }, timeout=20)
904
+ if tavily_resp.status_code == 200:
905
+ results = tavily_resp.json().get("results", [])
906
+ except Exception:
907
+ pass
908
+
909
+ if not results:
910
+ # Fallback 1: Try Mojeek Search (very friendly to scrapers, returns status 200)
911
+ try:
912
+ from bs4 import BeautifulSoup
913
+ import urllib.parse
914
+ headers = {
915
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
916
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
917
+ 'Accept-Language': 'en-US,en;q=0.5',
918
+ }
919
+ encoded_q = urllib.parse.quote(q)
920
+ mojeek_url = f"https://www.mojeek.com/search?q={encoded_q}"
921
+ resp = requests.get(mojeek_url, headers=headers, timeout=10)
922
+ if resp.status_code == 200:
923
+ soup = BeautifulSoup(resp.text, 'html.parser')
924
+ items = soup.find_all('li')
925
+ for r in items:
926
+ title_el = r.find('h2') or r.find('a', class_='title')
927
+ if not title_el:
928
+ continue
929
+ link_el = title_el.find('a') if title_el.name == 'h2' else title_el
930
+ desc_el = r.find('p', class_='s') or r.find('p', class_='snippet') or r.find('p')
931
+
932
+ if link_el:
933
+ link = link_el.get('href', '')
934
+ if link.startswith('/') or 'mojeek.com' in link:
935
+ continue
936
+ title = title_el.get_text(strip=True)
937
+ desc = desc_el.get_text(strip=True) if desc_el else ""
938
+ results.append({
939
+ 'url': link,
940
+ 'title': title,
941
+ 'content': desc
942
+ })
943
+ except Exception:
944
+ pass
945
+
946
+ if not results:
947
+ # Fallback 2: Try Yahoo Search (returns status 200, highly reliable index)
948
+ try:
949
+ from bs4 import BeautifulSoup
950
+ import urllib.parse
951
+ headers = {
952
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
953
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
954
+ }
955
+ encoded_q = urllib.parse.quote(q)
956
+ yahoo_url = f"https://search.yahoo.com/search?p={encoded_q}"
957
+ resp = requests.get(yahoo_url, headers=headers, timeout=10)
958
+ if resp.status_code == 200:
959
+ soup = BeautifulSoup(resp.text, 'html.parser')
960
+ items = soup.find_all('div', class_='algo')
961
+ for r in items:
962
+ link_el = r.find('a')
963
+ desc_el = r.find('div', class_='compText') or r.find('span', class_='compText') or r.find('p')
964
+
965
+ if link_el:
966
+ link = link_el.get('href', '')
967
+ # Unquote Yahoo redirect URL if present
968
+ if "/RU=" in link:
969
+ try:
970
+ parts = link.split("/RU=")
971
+ if len(parts) > 1:
972
+ target = parts[1].split("/RK=")[0]
973
+ link = urllib.parse.unquote(target)
974
+ except:
975
+ pass
976
+
977
+ title = link_el.get_text(strip=True)
978
+ desc = desc_el.get_text(strip=True) if desc_el else ""
979
+ results.append({
980
+ 'url': link,
981
+ 'title': title,
982
+ 'content': desc
983
+ })
984
+ except Exception as e:
985
+ pass
986
+
987
+ return results
988
+
989
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
990
+ futures = [executor.submit(fetch_query, q) for q in queries]
991
+ for future in concurrent.futures.as_completed(futures):
992
+ search_results.extend(future.result())
993
+
994
+ if not search_results:
995
+ return "No results found during research. The search APIs or fallback endpoints may be blocked or unreachable."
996
+
997
+ # 3. Extract unique URLs to scrape raw content in parallel
998
+ unique_urls = []
999
+ seen_urls = set()
1000
+ for r in search_results:
1001
+ url = r.get("url")
1002
+ if url and url not in seen_urls:
1003
+ seen_urls.add(url)
1004
+ unique_urls.append(url)
1005
+
1006
+ # Take top 4 URLs to scrape using enhanced Scrapy-based scraper
1007
+ urls_to_scrape = unique_urls[:4]
1008
+ scraped_contents = {}
1009
+
1010
+ # Try to use Scrapy-enhanced scraper for better crawling
1011
+ try:
1012
+ from .scrapy_search import enhanced_scrape_urls
1013
+ scraped_contents = enhanced_scrape_urls(urls_to_scrape, use_js=False, timeout=10)
1014
+ except ImportError:
1015
+ # Fall back to original requests-based scraping if Scrapy not available
1016
+ def scrape_url_raw(url):
1017
+ try:
1018
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
1019
+ r = requests.get(url, headers=headers, timeout=10)
1020
+ if r.status_code == 200:
1021
+ html_content = r.text
1022
+ # Remove scripts, styles
1023
+ html_content = re.sub(r'<(script|style).*?>.*?</\1>', '', html_content, flags=re.DOTALL | re.IGNORECASE)
1024
+ # Remove html tags
1025
+ text = re.sub(r'<.*?>', ' ', html_content)
1026
+ text = html_lib.unescape(text)
1027
+ # Format whitespace
1028
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
1029
+ return url, "\n".join(lines)[:6000]
1030
+ except Exception:
1031
+ pass
1032
+ return url, ""
1033
+
1034
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
1035
+ scrape_futures = [executor.submit(scrape_url_raw, url) for url in urls_to_scrape]
1036
+ for future in concurrent.futures.as_completed(scrape_futures):
1037
+ url, content = future.result()
1038
+ if content:
1039
+ scraped_contents[url] = content
1040
+
1041
+ # 4. Build aggregated context payload
1042
+ aggregated_context = []
1043
+ for r in search_results[:15]: # limit snippets to keep context clean
1044
+ url = r.get("url")
1045
+ snippet = r.get("content", "")
1046
+ scraped = scraped_contents.get(url, "")
1047
+
1048
+ entry = f"Source URL: {url}\nSnippet: {snippet}"
1049
+ if scraped:
1050
+ entry += f"\nFull Scraped Page Body:\n{scraped}"
1051
+ aggregated_context.append(entry)
1052
+
1053
+ full_context = "\n\n========================================\n\n".join(aggregated_context)[:60000]
1054
+
1055
+ # 5. Summarize and reason with fallback
1056
+ models_to_try = [
1057
+ "qwen/qwen3-coder:free",
1058
+ "poolside/laguna-xs.2:free",
1059
+ "nvidia/nemotron-3-super-120b-a12b:free",
1060
+ "nvidia/nemotron-3-nano-30b-a3b:free",
1061
+ "qwen/qwen3-next-80b-a3b-instruct:free",
1062
+ "openrouter/free"
1063
+ ]
1064
+ last_err = None
1065
+
1066
+ for model in models_to_try:
1067
+ model_retries = 2
1068
+ for attempt in range(model_retries + 1):
1069
+ try:
1070
+ try:
1071
+ from utim_cli.bootstrap import get_subagent_rag_context
1072
+ subagent_rag_ctx = get_subagent_rag_context("web_search", prompt)
1073
+ except Exception:
1074
+ subagent_rag_ctx = ""
1075
+
1076
+ sys_prompt = "You are a Deep Research AI. Analyze the provided search results and crawler content, then create a comprehensive, detailed, and properly structured technical information summary that addresses the user's research prompt. Focus on extracting exact facts, code snippets, configurations, documentation, and reasoning. Do not add conversational filler. Synthesize the data from all the sources into a highly informative report."
1077
+ if subagent_rag_ctx:
1078
+ sys_prompt += f"\n\n{subagent_rag_ctx}"
1079
+
1080
+ summary_payload = {
1081
+ "model": model,
1082
+ "messages": [
1083
+ {"role": "system", "content": sys_prompt},
1084
+ {"role": "user", "content": f"Research Prompt: {prompt}\n\nSearch Results and Scraped Pages:\n{full_context}"}
1085
+ ],
1086
+ "stream": True
1087
+ }
1088
+ from utim_cli.client_utils import proxy_openrouter_request
1089
+ resp = proxy_openrouter_request(json_data=summary_payload, stream=True, timeout=(15, 120))
1090
+ resp.raise_for_status()
1091
+
1092
+ summary = ""
1093
+ start_time = time.time()
1094
+ last_token_time = start_time
1095
+ for raw_line in resp.iter_lines(decode_unicode=True):
1096
+ if _cancel_event and _cancel_event.is_set():
1097
+ return "Error: User cancelled the research process."
1098
+ now = time.time()
1099
+ if now - start_time > 600: # 10 minute absolute max
1100
+ raise Exception("Hard timeout exceeded (10m)")
1101
+ if now - last_token_time > 60: # 60 seconds idle timeout
1102
+ raise Exception("Idle timeout: no tokens received for 60s")
1103
+ if not raw_line or not raw_line.startswith("data: "):
1104
+ continue
1105
+ data_str = raw_line[6:]
1106
+ if data_str == "[DONE]":
1107
+ break
1108
+ import json
1109
+ try:
1110
+ chunk = json.loads(data_str)
1111
+ except Exception:
1112
+ continue
1113
+
1114
+ if "error" in chunk:
1115
+ raise Exception(chunk["error"].get("message", "API Error"))
1116
+
1117
+ try:
1118
+ delta = chunk["choices"][0].get("delta", {})
1119
+ if "content" in delta and delta["content"]:
1120
+ summary += delta["content"]
1121
+ last_token_time = time.time()
1122
+ except Exception:
1123
+ continue
1124
+
1125
+ summary = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", summary, flags=re.DOTALL).strip()
1126
+ if not summary:
1127
+ raise Exception("Model returned empty summary after parsing.")
1128
+
1129
+ # Save the research report to .utim_tmp/web_search_{timestamp}.md
1130
+ try:
1131
+ os.makedirs(".utim_tmp", exist_ok=True)
1132
+ timestamp = int(time.time())
1133
+ report_file = f".utim_tmp/web_search_{timestamp}.md"
1134
+
1135
+ # Format queries
1136
+ queries_formatted = "\n".join(f"- `{q}`" for q in queries)
1137
+
1138
+ # Format sources
1139
+ sources_formatted = "\n".join(f"- {url}" for url in unique_urls[:15])
1140
+
1141
+ report_content = f"""# Web Research Report
1142
+
1143
+ - **Research Prompt:** {prompt}
1144
+ - **Date/Time:** {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp))}
1145
+ - **Research Level:** {level}
1146
+
1147
+ ## Search Queries
1148
+ {queries_formatted}
1149
+
1150
+ ## Sources Explored
1151
+ {sources_formatted}
1152
+
1153
+ ---
1154
+
1155
+ ## Research Findings
1156
+
1157
+ {summary}
1158
+ """
1159
+ with open(report_file, "w", encoding="utf-8") as f:
1160
+ f.write(report_content)
1161
+
1162
+ # Append a footnote about where the file was saved
1163
+ summary += f"\n\n*(Detailed research report saved to `{report_file}`)*"
1164
+ except Exception:
1165
+ pass
1166
+
1167
+ return summary
1168
+ except requests.exceptions.HTTPError as e:
1169
+ code = e.response.status_code if e.response is not None else 0
1170
+ if code == 429 and attempt < model_retries:
1171
+ time.sleep(5 * (attempt + 1))
1172
+ continue
1173
+ last_err = e
1174
+ break
1175
+ except Exception as e:
1176
+ last_err = e
1177
+ break
1178
+
1179
+ return f"Error generating research summary after trying all fallback models: {last_err}"
1180
+
1181
+
1182
+ def plan_project(plan_part: str, prompt: str, context: str = "") -> str:
1183
+ """Spawns a specialized sub-agent to plan a specific part of the project."""
1184
+ from utim_cli.config import config
1185
+ llm_key = os.getenv("OPENROUTER_API_KEY") or config.get("api_key")
1186
+ if not llm_key:
1187
+ return "Error: Neither UTIM API key nor OPENROUTER_API_KEY environment variable is set."
1188
+
1189
+ # Map plan parts to specific roles
1190
+ roles = {
1191
+ "design": """You are an expert UI/UX Design Architect. Your task is to architect a comprehensive, realistic design system and a granular component breakdown for the requested application or feature.
1192
+
1193
+ Do not speak in broad design generalities; instead, provide concrete, production-ready specifications.
1194
+
1195
+ For every project, you must deliver:
1196
+ 1. **Design Tokens & Theme:**
1197
+ - A precise color palette including exact HEX codes (Primary, Secondary, Neutrals, Semantic/Status colors). Specify accessible contrast ratios (WCAG AA/AAA).
1198
+ - Typography scale specified in rem/px (Font families, weights, sizes, and line-heights for Headings, Body, and Captions).
1199
+ - Global layout rules (Grid structure, spacing scale in a 4px/8px base, and border-radius tokens).
1200
+
1201
+ 2. **Component Architecture & Hierarchy:**
1202
+ - Break down the core interface into atoms, molecules, and organisms (Atomic Design methodology).
1203
+ - Detail the structural hierarchy—exactly how components nest within each other for this specific layout.
1204
+
1205
+ 3. **Interactive States & Edge Cases:**
1206
+ - Define exact visual transformations for interactive components across all states: Default, Hover, Active, Focus (including outline styles), Disabled, and Loading.
1207
+ - Specify responsive behavior (Mobile vs. Desktop layout shifts) and how empty or error states are handled visually.
1208
+
1209
+ Structure your response using clear headers, markdown tables for tokens, and bulleted lists for structural breakdowns. Maintain a technical, precise, and highly analytical tone.
1210
+
1211
+ If the project is a PowerPoint presentation (PPT/slide deck), document, or non-software asset, adapt the design plan to specify page/slide layouts, slide structure, visual themes, color palette, typography, and content alignment instead of UI components.""",
1212
+ "architecture": """You are a Senior Systems Architect. Produce a production-ready architecture plan that an engineering team can act on immediately. Cover these without fluff:
1213
+ - **Stack**: Recommend specific tools/frameworks/services with one-line justifications and key trade-offs
1214
+ - **Structure**: Directory layout with clear separation of concerns (presentation / logic / data / infra)
1215
+ - **Data Flow**: Request lifecycle, sync vs async boundaries, ASCII component diagram
1216
+ - **API Design**: Endpoints, auth strategy (JWT/OAuth2), versioning, rate limiting, error contracts
1217
+ - **State**: Client/server/shared state boundaries, caching layers (Redis/CDN) + invalidation strategy
1218
+ - **Scalability**: Bottlenecks, failover, circuit breakers, horizontal scaling approach
1219
+ - **Security**: AuthN/AuthZ model, encryption at rest/transit, top OWASP risks for this system
1220
+ - **Deployment**: CI/CD shape, containerization (Docker/K8s), environment strategy (dev/staging/prod)
1221
+
1222
+ Rules: Be opinionated. Flag assumptions. Prefer simple over clever. No filler.
1223
+
1224
+ If the project is a PowerPoint presentation (PPT/slide deck), document, or non-software asset, adapt the architecture to specify the outline, slide hierarchy, layout structures, and narration/presentation flow rather than directories, APIs, and scaling strategies.""",
1225
+ "security": """You are a world-class Security Engineer and Application Security Architect. Design a comprehensive, production-grade security strategy for the entire system. Analyze the application from an attacker’s perspective and identify potential vulnerabilities, attack vectors, trust boundaries, and high-risk components. Define secure authentication and authorization flows including RBAC, ABAC, OAuth2, JWT, session management, MFA, device trust, and secure token lifecycle handling. Specify data protection strategies for data at rest, in transit, and in use using modern encryption standards, key rotation, secrets management, hashing, salting, and secure credential storage. Enforce secure coding practices aligned with OWASP Top 10, SANS, and modern AppSec standards. Include API security, rate limiting, input validation, output encoding, CSRF/XSS/SQLi prevention, SSRF protection, CSP policies, sandboxing, dependency auditing, supply chain security, and secure file handling. Design infrastructure and cloud security including network isolation, firewalls, WAF, IAM policies, zero-trust architecture, container security, CI/CD hardening, runtime monitoring, and intrusion detection. Define logging, auditing, anomaly detection, threat monitoring, incident response workflows, backup/recovery strategies, and compliance considerations (GDPR, SOC2, HIPAA if applicable). Provide actionable recommendations, architecture-level protections, and implementation-level safeguards with clear reasoning behind every security decision.
1226
+
1227
+ If the project is a PowerPoint presentation (PPT/slide deck), document, or non-software asset, adapt this plan to cover data confidentiality, distribution controls, or access control policies for the final document.""",
1228
+ "database": """You are an expert Database Administrator. Create a detailed database schema, relationships, indexing strategies, and query optimization plans.
1229
+
1230
+ If the project is a PowerPoint presentation (PPT/slide deck), document, or non-software asset, adapt this plan to detail data collection, slide tables/graphics data, or information schemas required for the content.""",
1231
+ "verification": """You are an elite QA Engineer, Senior Software Architect, and Principal Code Reviewer specializing in deep system validation, debugging, and production-grade quality assurance. Thoroughly analyze the provided codebase, runtime behavior, logs, stack traces, UI output, architecture decisions, and all available context. Compare the implementation against the original requirements, design specifications, expected workflows, and intended user experience. Detect and explain all bugs, edge-case failures, race conditions, performance bottlenecks, memory leaks, state management issues, accessibility problems, security risks, responsiveness issues, missing features, broken integrations, and architectural inconsistencies. Identify missing or incorrect CSS, layout instability, animation glitches, typography inconsistencies, spacing/alignment problems, responsive design failures, and deviations from the intended visual design system. Validate frontend, backend, APIs, database interactions, authentication flows, caching behavior, and asynchronous operations. Analyze error logs deeply to trace root causes instead of only identifying surface-level failures. Detect bad coding practices, dead code, duplicated logic, anti-patterns, scalability concerns, and maintainability risks. Verify proper handling of loading states, empty states, retries, failures, permissions, validation, and edge-case user interactions. Ensure adherence to clean architecture principles, secure coding standards, performance optimization practices, and framework best practices. Output a highly structured, strict, implementation-focused checklist of exact fixes required. Each checklist item must include: the issue, root cause, affected component/file if identifiable, severity level, exact corrective action, and why the fix is necessary. Prioritize issues intelligently from critical to low priority and ensure the output is actionable enough for direct implementation without ambiguity.
1232
+
1233
+ If the project is a PowerPoint presentation (PPT/slide deck), document, or non-software asset, adapt the verification checklist to cover readability, styling consistency, formatting alignment, visual appeal, grammar, and completeness of content against requirements."""
1234
+ }
1235
+
1236
+ system_role = roles.get(plan_part.lower(), "You are an expert Technical Planner. Create a detailed implementation plan for the requested domain.")
1237
+
1238
+ system_content = (
1239
+ f"{system_role}\n\n"
1240
+ "CRITICAL PLANNING DIRECTIVES:\n"
1241
+ "1. DO NOT WRITE CODE SNIPPETS, programming scripts, programming code, or function definitions in the plan. The goal of this planning agent is to design high-level/low-level architectures, layouts, sequential steps, schemas, and outlines. Avoid writing actual code blocks.\n"
1242
+ "2. RESPECT THE SPECIFIC PROJECT FORMAT. If the user requested a PowerPoint presentation (PPT/slide deck), document, or non-software asset, adapt the plan to match that format entirely. Do not default to website, UI components, directory structures, or API routes. Detail the slide structure, narration points, visual elements, content outlines, and page layouts instead."
1243
+ )
1244
+
1245
+ try:
1246
+ from utim_cli.bootstrap import get_subagent_rag_context
1247
+ subagent_rag_ctx = get_subagent_rag_context("plan_project", prompt)
1248
+ if subagent_rag_ctx:
1249
+ system_content += f"\n\n{subagent_rag_ctx}"
1250
+ except Exception:
1251
+ pass
1252
+
1253
+ # Gather workspace/project context
1254
+ workspace_context = ""
1255
+ try:
1256
+ if os.path.exists("."):
1257
+ items = [item for item in os.listdir(".") if not item.startswith(".") and item not in ("__pycache__", "node_modules", "build", "dist")]
1258
+ if items:
1259
+ workspace_context += f"Existing workspace files/folders: {', '.join(items)}\n"
1260
+ # Read snippet of key project files
1261
+ for key_file in ["package.json", "requirements.txt", "setup.py", "README.md"]:
1262
+ if os.path.exists(key_file):
1263
+ try:
1264
+ with open(key_file, "r", encoding="utf-8") as f:
1265
+ workspace_context += f"\nSnippet of {key_file}:\n{f.read(1500)}\n"
1266
+ except Exception:
1267
+ pass
1268
+ except Exception:
1269
+ pass
1270
+
1271
+ user_content = f"Project Prompt: {prompt}\n"
1272
+ if workspace_context:
1273
+ user_content += f"\nWorkspace & Project Context:\n{workspace_context}\n"
1274
+ if context:
1275
+ user_content += f"\nPrevious Context/Other Plans:\n{context}\n"
1276
+
1277
+ models_to_try = [
1278
+ "qwen/qwen3-coder:free",
1279
+ "poolside/laguna-xs.2:free",
1280
+ "nvidia/nemotron-3-super-120b-a12b:free",
1281
+ "nvidia/nemotron-3-nano-30b-a3b:free",
1282
+ "qwen/qwen3-next-80b-a3b-instruct:free",
1283
+ "openrouter/free"
1284
+ ]
1285
+ last_err = None
1286
+
1287
+ for model in models_to_try:
1288
+ model_retries = 2
1289
+ for attempt in range(model_retries + 1):
1290
+ try:
1291
+ payload = {
1292
+ "model": model,
1293
+ "messages": [
1294
+ {"role": "system", "content": system_content},
1295
+ {"role": "user", "content": user_content}
1296
+ ],
1297
+ "stream": True
1298
+ }
1299
+ import time
1300
+ from utim_cli.client_utils import proxy_openrouter_request
1301
+ resp = proxy_openrouter_request(json_data=payload, stream=True, timeout=(15, 120))
1302
+ resp.raise_for_status()
1303
+
1304
+ plan = ""
1305
+ start_time = time.time()
1306
+ last_token_time = start_time
1307
+ for raw_line in resp.iter_lines(decode_unicode=True):
1308
+ if _cancel_event and _cancel_event.is_set():
1309
+ return "Error: User cancelled the planning process."
1310
+ now = time.time()
1311
+ if now - start_time > 900: # 15 minute absolute max for planning
1312
+ raise Exception("Hard timeout exceeded (15m)")
1313
+ if now - last_token_time > 120: # 120 seconds idle timeout for planning (models can think a long time)
1314
+ raise Exception("Idle timeout: no tokens received for 120s")
1315
+ if not raw_line or not raw_line.startswith("data: "):
1316
+ continue
1317
+ data_str = raw_line[6:]
1318
+ if data_str == "[DONE]":
1319
+ break
1320
+ import json
1321
+ try:
1322
+ chunk = json.loads(data_str)
1323
+ except Exception:
1324
+ continue
1325
+
1326
+ if "error" in chunk:
1327
+ raise Exception(chunk["error"].get("message", "API Error"))
1328
+
1329
+ try:
1330
+ delta = chunk["choices"][0].get("delta", {})
1331
+ if "content" in delta and delta["content"]:
1332
+ plan += delta["content"]
1333
+ last_token_time = time.time()
1334
+ except Exception:
1335
+ continue
1336
+
1337
+ # Clean up thinking tags
1338
+ import re
1339
+ plan = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", plan, flags=re.DOTALL).strip()
1340
+
1341
+ if not plan:
1342
+ raise Exception("Model returned empty plan after parsing.")
1343
+
1344
+ # Save the plan to disk
1345
+ os.makedirs(".utim_tmp/plans", exist_ok=True)
1346
+ plan_file = f".utim_tmp/plans/{plan_part.lower()}_plan.md"
1347
+ with open(plan_file, "w", encoding="utf-8") as f:
1348
+ f.write(plan)
1349
+
1350
+ return f"Plan successfully generated and saved to {plan_file}. Please read this file when you need to implement the detailed {plan_part} plan."
1351
+ except requests.exceptions.HTTPError as e:
1352
+ code = e.response.status_code if e.response is not None else 0
1353
+ if code == 429 and attempt < model_retries:
1354
+ time.sleep(5 * (attempt + 1))
1355
+ continue
1356
+ last_err = e
1357
+ break
1358
+ except Exception as e:
1359
+ last_err = e
1360
+ break
1361
+
1362
+ return f"Error generating plan after trying all fallback models: {last_err}"
1363
+
1364
+
1365
+
1366
+ # ─────────────────────────────────────────────────────────────────────────────────
1367
+ # Caching infrastructure for query_codebase
1368
+ # ─────────────────────────────────────────────────────────────────────────────────
1369
+
1370
+ _query_cache = {} # {query_hash: {"time": float, "result": str, "keywords": list}}
1371
+ _file_index_cache = {"mtime": 0, "files": {}} # Persistent file mtime cache
1372
+ _index_timestamp = 0
1373
+ _INDEX_CACHE_TTL = 300 # 5 minutes
1374
+
1375
+ def _get_query_cache_key(query: str) -> str:
1376
+ """Generate a cache key from the query."""
1377
+ import hashlib
1378
+ normalized = query.lower().strip()[:100]
1379
+ return hashlib.md5(normalized.encode()).hexdigest()[:16]
1380
+
1381
+ def _extract_keywords_fast(query: str) -> list:
1382
+ """Fast local keyword extraction without LLM calls.
1383
+
1384
+ Uses heuristics to extract technical identifiers, function names,
1385
+ class names, and file patterns from the query.
1386
+ """
1387
+ import re
1388
+ # Extract potential variable/class/function names (camelCase, snake_case, PascalCase)
1389
+ patterns = [
1390
+ r'\b[A-Za-z_][A-Za-z0-9_]{3,}\b', # Standard identifiers
1391
+ r'\b[A-Z][a-z]+[A-Z][a-z]+\b', # PascalCase (class names)
1392
+ r'\b[a-z]+[A-Z][a-z]+\b', # camelCase
1393
+ ]
1394
+
1395
+ keywords = set()
1396
+ for pattern in patterns:
1397
+ matches = re.findall(pattern, query)
1398
+ for m in matches:
1399
+ if len(m) > 2: # Minimum length
1400
+ keywords.add(m)
1401
+
1402
+ # Also extract file extensions and known tech terms
1403
+ tech_terms = re.findall(r'\b(js|ts|py|java|cpp|go|rs|rb|php|html|css|json|yaml|xml|sql)\b', query, re.I)
1404
+ keywords.update(t.lower() for t in tech_terms)
1405
+
1406
+ return list(keywords)[:5] # Limit to 5 keywords
1407
+
1408
+ def query_codebase(query: str) -> str:
1409
+ """Acts as a local RAG. Optimized for speed with caching and intelligent keyword extraction.
1410
+
1411
+ Speed optimizations:
1412
+ - Query result caching (60s TTL)
1413
+ - Fast local keyword extraction (no LLM call for keyword generation)
1414
+ - Persistent file mtime caching
1415
+ - Fast synthesis model first (liquid/lfm-2.5-1.2b-instruct:free)
1416
+ - Reduced timeouts and smarter fallbacks
1417
+ """
1418
+ import os
1419
+ import requests
1420
+ import json
1421
+ import sqlite3
1422
+ import re
1423
+ import time
1424
+
1425
+ global _query_cache, _file_index_cache, _index_timestamp
1426
+
1427
+ from utim_cli.config import config
1428
+ llm_key = os.getenv("OPENROUTER_API_KEY") or config.get("api_key")
1429
+ if not llm_key:
1430
+ return "Error: Neither UTIM API key nor OPENROUTER_API_KEY environment variable is set."
1431
+
1432
+ # ── 1. Check query cache first (60s TTL) ─────────────────────────────
1433
+ cache_key = _get_query_cache_key(query)
1434
+ now = time.time()
1435
+ if cache_key in _query_cache:
1436
+ cached = _query_cache[cache_key]
1437
+ if now - cached["time"] < 60: # 60 second cache
1438
+ return cached["result"]
1439
+
1440
+ # ── 2. Fast local keyword extraction (no LLM) ───────────────────────
1441
+ keywords = _extract_keywords_fast(query)
1442
+ if not keywords:
1443
+ # Fallback: use raw words from query
1444
+ keywords = [w for w in query.lower().split() if len(w) > 3][:4]
1445
+
1446
+ if not keywords:
1447
+ return "Could not extract searchable keywords from query."
1448
+
1449
+ # ── 3. Optimized file indexing with persistent cache ─────────────────
1450
+ db_path = ".utim_tmp/codebase_fts.db"
1451
+ os.makedirs(".utim_tmp", exist_ok=True)
1452
+
1453
+ try:
1454
+ conn = sqlite3.connect(db_path)
1455
+ cur = conn.cursor()
1456
+
1457
+ # Quick table setup
1458
+ cur.execute("CREATE VIRTUAL TABLE IF NOT EXISTS files USING fts5(path, content);")
1459
+ cur.execute("CREATE TABLE IF NOT EXISTS file_meta (path TEXT PRIMARY KEY, mtime REAL);")
1460
+
1461
+ # Check if we need to update the index (only if 5+ minutes old)
1462
+ cur.execute("SELECT last_modified FROM sqlite_master WHERE type='table' AND name='files'")
1463
+ table_info = cur.fetchone()
1464
+
1465
+ need_index = False
1466
+ if table_info is None:
1467
+ need_index = True
1468
+ elif now - _index_timestamp > _INDEX_CACHE_TTL:
1469
+ need_index = True
1470
+ else:
1471
+ # Quick check: see if any files changed
1472
+ try:
1473
+ cur.execute("SELECT path, mtime FROM file_meta LIMIT 20")
1474
+ cached_meta = {row[0]: row[1] for row in cur.fetchall()}
1475
+ for p in list(cached_meta.keys())[:5]:
1476
+ if os.path.exists(p):
1477
+ if abs(os.path.getmtime(p) - cached_meta.get(p, 0)) > 1:
1478
+ need_index = True
1479
+ break
1480
+ except Exception:
1481
+ need_index = True
1482
+
1483
+ if need_index:
1484
+ # Mini-indexing: only check a sample of files for changes
1485
+ current_files = {}
1486
+ for root, dirs, files in os.walk("."):
1487
+ dirs[:] = [d for d in dirs if d not in [".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv", ".utim_tmp"]]
1488
+ for f in files[:50]: # Limit to 50 files per directory for speed
1489
+ ext = os.path.splitext(f)[1].lower()
1490
+ if ext in ['.png', '.jpg', '.jpeg', '.gif', '.mp4', '.pdf', '.zip', '.exe', '.dll', '.pyc', '.sqlite', '.db']:
1491
+ continue
1492
+ p = os.path.join(root, f)
1493
+ try:
1494
+ current_files[p] = os.path.getmtime(p)
1495
+ except Exception:
1496
+ pass
1497
+
1498
+ # Update index incrementally
1499
+ cur.execute("SELECT path, mtime FROM file_meta")
1500
+ existing = {row[0]: row[1] for row in cur.fetchall()}
1501
+
1502
+ for p, mtime in current_files.items():
1503
+ if p not in existing or existing[p] < mtime - 1:
1504
+ try:
1505
+ with open(p, "r", encoding="utf-8") as f:
1506
+ content = f.read()[:30000] # Limit content size
1507
+ cur.execute("INSERT OR REPLACE INTO files (path, content) VALUES (?, ?)", (p, content))
1508
+ cur.execute("INSERT OR REPLACE INTO file_meta (path, mtime) VALUES (?, ?)", (p, mtime))
1509
+ except Exception:
1510
+ pass
1511
+
1512
+ conn.commit()
1513
+ _index_timestamp = now
1514
+ _file_index_cache = {"mtime": now, "files": current_files}
1515
+ except Exception as e:
1516
+ pass
1517
+
1518
+ # ── 4. FTS5 Search ────────────────────────────────────────────────────
1519
+ try:
1520
+ match_query = " OR ".join('"{}"'.format(kw.replace('"', '')) for kw in keywords[:5])
1521
+ cur.execute("SELECT path, content FROM files WHERE files MATCH ? ORDER BY rank LIMIT 5", (match_query,))
1522
+ results = cur.fetchall()
1523
+ conn.close()
1524
+ except Exception as e:
1525
+ try:
1526
+ conn.close()
1527
+ except:
1528
+ pass
1529
+ return f"Database search error: {e}"
1530
+
1531
+ if not results:
1532
+ return f"No relevant code found for keywords: {', '.join(keywords)}"
1533
+
1534
+ # ── 5. Fast synthesis with optimized model order ─────────────────────
1535
+ context = ""
1536
+ for path, content in results[:4]: # Limit to 4 files for speed
1537
+ if len(content) > 6000:
1538
+ content = content[:6000] + "\n...[truncated]"
1539
+ context += f"\n--- {path} ---\n{content}\n"
1540
+
1541
+ synth_payload = {
1542
+ "messages": [
1543
+ {"role": "system", "content": "You are a senior developer. Answer the query concisely using only the provided context. Include relevant code snippets."},
1544
+ {"role": "user", "content": f"Query: {query}\n\nContext:\n{context}"}
1545
+ ]
1546
+ }
1547
+
1548
+ # Fast synthesis model first, with reduced timeout
1549
+ fast_models = [
1550
+ "liquid/lfm-2.5-1.2b-instruct:free", # Fastest
1551
+ "nex-agi/nex-n2-pro:free", # Good balance
1552
+ "qwen/qwen3-coder:free", # Fallback
1553
+ ]
1554
+
1555
+ answer = ""
1556
+ for model in fast_models:
1557
+ try:
1558
+ synth_payload["model"] = model
1559
+ from utim_cli.client_utils import proxy_openrouter_request
1560
+ resp = proxy_openrouter_request(json_data=synth_payload, stream=False, timeout=25)
1561
+ if resp.status_code == 200:
1562
+ answer = resp.json()["choices"][0]["message"]["content"]
1563
+ answer = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", answer, flags=re.DOTALL).strip()
1564
+ if answer:
1565
+ break
1566
+ except Exception:
1567
+ continue
1568
+
1569
+ if not answer:
1570
+ # Return raw context without synthesis
1571
+ answer = f"Found relevant files but synthesis failed. Keywords: {', '.join(keywords)}\n\n{context[:2000]}"
1572
+
1573
+ # ── 6. Cache the result ───────────────────────────────────────────────
1574
+ _query_cache[cache_key] = {"time": now, "result": answer, "keywords": keywords}
1575
+ if len(_query_cache) > 20:
1576
+ _query_cache = dict(list(_query_cache.items())[-10:])
1577
+
1578
+ return answer
1579
+
1580
+
1581
+ def manage_todos(action: str = "", task_id: str = "", description: str = "", operations: list = None) -> str:
1582
+ """Manages the project to-do list. Supports batch operations."""
1583
+
1584
+ todo_file = ".utim_tmp/todos.json"
1585
+ os.makedirs(".utim_tmp", exist_ok=True)
1586
+
1587
+ todos = {}
1588
+ if os.path.exists(todo_file):
1589
+ try:
1590
+ with open(todo_file, "r", encoding="utf-8") as f:
1591
+ todos = json.load(f)
1592
+ except Exception:
1593
+ pass
1594
+
1595
+ ops = operations if operations else [{"action": action, "task_id": task_id, "description": description}]
1596
+ results = []
1597
+
1598
+ for op in ops:
1599
+ act = op.get("action", "")
1600
+ tid = op.get("task_id", "")
1601
+ desc = op.get("description", "")
1602
+
1603
+ if act == "add":
1604
+ if not tid:
1605
+ tid = f"task_{len(todos) + 1}"
1606
+ todos[tid] = {"description": desc, "status": "pending"}
1607
+ results.append(f"Added task '{tid}': {desc}")
1608
+ elif act == "mark_done":
1609
+ if tid in todos:
1610
+ todos[tid]["status"] = "done"
1611
+ results.append(f"Marked task '{tid}' as done.")
1612
+ else:
1613
+ results.append(f"Error: Task '{tid}' not found.")
1614
+ elif act == "mark_pending":
1615
+ if tid in todos:
1616
+ todos[tid]["status"] = "pending"
1617
+ results.append(f"Marked task '{tid}' as pending.")
1618
+ else:
1619
+ results.append(f"Error: Task '{tid}' not found.")
1620
+ elif act == "delete":
1621
+ if tid in todos:
1622
+ del todos[tid]
1623
+ results.append(f"Deleted task '{tid}'.")
1624
+ else:
1625
+ results.append(f"Error: Task '{tid}' not found.")
1626
+ elif act == "list":
1627
+ results.append("Listed tasks.")
1628
+ else:
1629
+ results.append(f"Error: Unknown action '{act}'.")
1630
+
1631
+ result = "\n".join(results)
1632
+
1633
+ with open(todo_file, "w", encoding="utf-8") as f:
1634
+ json.dump(todos, f, indent=2)
1635
+
1636
+ out = "Current To-Do List:\n"
1637
+ if not todos:
1638
+ out += "(empty)"
1639
+ else:
1640
+ for tid, t in todos.items():
1641
+ status_mark = "[x]" if t["status"] == "done" else "[ ]"
1642
+ out += f"{status_mark} {tid}: {t['description']}\n"
1643
+ return result + "\n\n" + out if action != "list" else out
1644
+
1645
+ def manage_memory(action: str, key: str = "", content: str = "",
1646
+ category: str = "fact", query: str = "") -> str:
1647
+ """Manages persistent cross-session memory stored in .utim/memory.json.
1648
+
1649
+ Actions
1650
+ -------
1651
+ save Store a memory under *key* with optional *category*.
1652
+ Categories: 'behaviour' (how user communicates/works),
1653
+ 'preference' (UI, design, style choices),
1654
+ 'fact' (explicit facts/codes/data user stated),
1655
+ 'project' (architecture & tech decisions).
1656
+ read Return the full content of *key*.
1657
+ search Keyword-search across all keys+content, return top matches
1658
+ with short previews. Use *query* for the search terms.
1659
+ get_traits Return ONLY 'behaviour' and 'preference' memories in compact
1660
+ form (used to seed session context without bloating the prompt).
1661
+ list Return all keys and their categories with 60-char previews.
1662
+ delete Remove *key* from memory.
1663
+ verify Verify user identity using the secret code passed in *query*.
1664
+ """
1665
+ import os, json, pathlib, time as _time
1666
+ from utim_cli.state import STATE
1667
+ mem_file = pathlib.Path(".utim").resolve() / "memory.json"
1668
+ os.makedirs(mem_file.parent, exist_ok=True)
1669
+
1670
+ memories: dict = {}
1671
+ if mem_file.exists():
1672
+ try:
1673
+ with open(mem_file, "r", encoding="utf-8") as f:
1674
+ memories = json.load(f)
1675
+ except Exception:
1676
+ pass
1677
+
1678
+ # Ensure all memories are synced to ChromaDB user_memories on load
1679
+ try:
1680
+ from utim_cli.vector_memory import get_user_memories_memory
1681
+ vm = get_user_memories_memory()
1682
+ if vm and vm.collection:
1683
+ from utim_cli.state import STATE
1684
+ if not STATE.get("memories_synced", False):
1685
+ for k, v in memories.items():
1686
+ content_val = v if isinstance(v, str) else v.get("content", "")
1687
+ cat = "fact" if isinstance(v, str) else v.get("category", "fact")
1688
+ updated_at = "" if isinstance(v, str) else v.get("updated_at", "")
1689
+ vm.add_text(
1690
+ text_id=k,
1691
+ content=content_val,
1692
+ metadata={"category": cat, "updated_at": updated_at}
1693
+ )
1694
+ STATE["memories_synced"] = True
1695
+ except Exception:
1696
+ pass
1697
+
1698
+ def _save():
1699
+ with open(mem_file, "w", encoding="utf-8") as f:
1700
+ json.dump(memories, f, indent=2, ensure_ascii=False)
1701
+
1702
+ act = action.lower()
1703
+ is_verified = STATE.get("is_verified", False)
1704
+ sensitive_keywords = {"girlfriend", "gf", "wife", "spouse", "partner", "relationship", "secret", "password", "code", "private", "personal", "anushka", "puchkuli"}
1705
+
1706
+ if act == "verify":
1707
+ if not query:
1708
+ return "Error: 'query' parameter (containing the secret code) is required for verification."
1709
+
1710
+ # Collect all possible secret codes from memories
1711
+ possible_codes = []
1712
+ for k, v in memories.items():
1713
+ if "secret_code" in k.lower() or "user_secret" in k.lower():
1714
+ val = v if isinstance(v, str) else v.get("content")
1715
+ if val:
1716
+ possible_codes.append(val.strip())
1717
+
1718
+ if not possible_codes:
1719
+ return "Error: No secret code was found in memory. Please set one first or check memory keys."
1720
+
1721
+ clean_query = query.strip().lower()
1722
+ matched = False
1723
+ for code in possible_codes:
1724
+ if clean_query == code.lower():
1725
+ matched = True
1726
+ break
1727
+
1728
+ if matched:
1729
+ STATE["is_verified"] = True
1730
+ return "Verification successful! User identity has been verified for this session."
1731
+ else:
1732
+ return "Verification failed. The provided code does not match the stored secret code."
1733
+
1734
+ elif act == "save":
1735
+ if not key:
1736
+ return "Error: 'key' is required to save a memory."
1737
+
1738
+ # If not verified, block saving/updating any sensitive keys or content
1739
+ if not is_verified:
1740
+ if any(w in key.lower() or w in content.lower() for w in sensitive_keywords):
1741
+ return (
1742
+ "[VERIFICATION REQUIRED] Access to or modification of sensitive information requires identity verification. "
1743
+ "Please ask the user for the secret code, and once provided, call the manage_memory tool with action=\"verify\" and the code as the query parameter."
1744
+ )
1745
+
1746
+ allowed_cats = {"behaviour", "preference", "fact", "project"}
1747
+ cat = category.lower() if category.lower() in allowed_cats else "fact"
1748
+ memories[key] = {
1749
+ "content": content,
1750
+ "category": cat,
1751
+ "updated_at": _time.strftime("%Y-%m-%dT%H:%M:%S"),
1752
+ }
1753
+ _save()
1754
+
1755
+ # Sync to Vector DB
1756
+ try:
1757
+ from utim_cli.vector_memory import get_user_memories_memory
1758
+ vm = get_user_memories_memory()
1759
+ if vm:
1760
+ vm.add_text(
1761
+ text_id=key,
1762
+ content=content,
1763
+ metadata={"category": cat, "updated_at": memories[key]["updated_at"]}
1764
+ )
1765
+ except Exception:
1766
+ pass
1767
+
1768
+ return f"Memory saved: [{cat}] '{key}'."
1769
+
1770
+ elif act == "read":
1771
+ if not key:
1772
+ return "Error: 'key' is required to read a memory."
1773
+
1774
+ # If not verified, block reading if the key itself contains any sensitive keywords
1775
+ if not is_verified:
1776
+ if any(w in key.lower() for w in sensitive_keywords):
1777
+ return (
1778
+ "[VERIFICATION REQUIRED] Access to or modification of sensitive information requires identity verification. "
1779
+ "Please ask the user for the secret code, and once provided, call the manage_memory tool with action=\"verify\" and the code as the query parameter."
1780
+ )
1781
+
1782
+ entry = memories.get(key)
1783
+ if not entry:
1784
+ return f"No memory found for key '{key}'."
1785
+
1786
+ # Support legacy flat-string entries
1787
+ if isinstance(entry, str):
1788
+ content_text = entry
1789
+ result = f"[fact] {key}:\n{entry}"
1790
+ else:
1791
+ content_text = entry.get("content", "")
1792
+ result = f"[{entry.get('category', 'fact')}] {key} (saved {entry.get('updated_at', '?')}):\n{content_text}"
1793
+
1794
+ # If not verified, block reading if the content contains any sensitive keywords
1795
+ if not is_verified:
1796
+ if any(w in content_text.lower() for w in sensitive_keywords):
1797
+ return (
1798
+ "[VERIFICATION REQUIRED] Access to or modification of sensitive information requires identity verification. "
1799
+ "Please ask the user for the secret code, and once provided, call the manage_memory tool with action=\"verify\" and the code as the query parameter."
1800
+ )
1801
+ return result
1802
+
1803
+ elif act == "search":
1804
+ if not query:
1805
+ return "Error: 'query' is required for search."
1806
+
1807
+ # If not verified and query is sensitive, block immediately
1808
+ if not is_verified:
1809
+ if any(w in query.lower() for w in sensitive_keywords):
1810
+ return (
1811
+ "[VERIFICATION REQUIRED] Access to or modification of sensitive information requires identity verification. "
1812
+ "Please ask the user for the secret code, and once provided, call the manage_memory tool with action=\"verify\" and the code as the query parameter."
1813
+ )
1814
+
1815
+ # Try semantic search using ChromaDB first
1816
+ try:
1817
+ from utim_cli.vector_memory import get_user_memories_memory
1818
+ vm = get_user_memories_memory()
1819
+ if vm and vm.collection:
1820
+ results = vm.query(query, n_results=25)
1821
+ hits = []
1822
+ for r in results:
1823
+ m_id = r.get("id")
1824
+ content_text = r["content"]
1825
+ cat = r.get("metadata", {}).get("category", "fact")
1826
+
1827
+ if not m_id:
1828
+ continue
1829
+
1830
+ # If hit is sensitive and user is not verified, block/skip
1831
+ if not is_verified:
1832
+ if any(w in m_id.lower() or w in content_text.lower() for w in sensitive_keywords):
1833
+ continue
1834
+
1835
+ preview = content_text[:80].replace("\n", " ") + ("…" if len(content_text) > 80 else "")
1836
+ hits.append(f"[{cat}] {m_id}: {preview}")
1837
+ if len(hits) >= 10:
1838
+ break
1839
+ if hits:
1840
+ return "Semantic Search results:\n" + "\n".join(hits)
1841
+ except Exception:
1842
+ pass
1843
+
1844
+ # Normalise query: lowercase, strip punctuation, expand common synonyms
1845
+ _SYNONYMS: dict[str, list[str]] = {
1846
+ "color": ["colour"],
1847
+ "colour": ["color"],
1848
+ "favorite": ["favourite", "fav", "fave", "preferred"],
1849
+ "favourite": ["favorite", "fav", "fave", "preferred"],
1850
+ "fav": ["favorite", "favourite"],
1851
+ "prefer": ["favorite", "favourite", "like", "love", "want"],
1852
+ "preference": ["prefer", "favorite", "favourite", "like"],
1853
+ "secret": ["code", "key", "password", "token"],
1854
+ "password": ["secret", "code", "key", "token"],
1855
+ "code": ["secret", "password", "key", "token"],
1856
+ "style": ["design", "theme", "aesthetic", "look"],
1857
+ "design": ["style", "theme", "aesthetic", "look"],
1858
+ "theme": ["style", "design", "color", "colour"],
1859
+ "dark": ["dark mode", "night"],
1860
+ "explain": ["explanation", "details", "verbose"],
1861
+ "project": ["app", "application", "codebase", "repo"],
1862
+ "wife": ["bou", "bouer", "spouse", "partner", "girlfriend", "gf", "relationship", "marriage"],
1863
+ "bou": ["wife", "bouer", "spouse", "partner", "girlfriend", "gf", "relationship", "marriage"],
1864
+ "bouer": ["wife", "bou", "spouse", "partner", "girlfriend", "gf", "relationship", "marriage"],
1865
+ "girlfriend": ["gf", "wife", "bou", "bouer", "partner", "spouse", "relationship", "love", "fiancee"],
1866
+ "gf": ["girlfriend", "wife", "bou", "partner", "spouse"],
1867
+ "partner": ["wife", "bou", "girlfriend", "gf", "spouse"],
1868
+ "husband": ["spouse", "partner", "boyfriend", "bf", "relationship", "marriage", "jamai", "bor"],
1869
+ "boyfriend": ["bf", "husband", "partner", "spouse", "relationship", "love", "fiance"],
1870
+ "bf": ["boyfriend", "husband", "partner", "spouse"],
1871
+ "jamai": ["husband", "bor", "partner"],
1872
+ "bor": ["husband", "jamai", "partner"],
1873
+ "name": ["nam", "called", "identity"],
1874
+ "nam": ["name", "called"],
1875
+ }
1876
+ raw_tokens = query.lower().split()
1877
+ q_tokens = [tok.strip("?,.!-()\"'[]{}*&^%$#@;:_+=|\\/") for tok in raw_tokens]
1878
+ q_tokens = [tok for tok in q_tokens if tok]
1879
+
1880
+ expanded: set[str] = set(q_tokens)
1881
+ for tok in q_tokens:
1882
+ for syn in _SYNONYMS.get(tok, []):
1883
+ expanded.add(syn)
1884
+ expanded.add(query.lower())
1885
+
1886
+ hits = []
1887
+ import re
1888
+ for k, v in memories.items():
1889
+ text = v if isinstance(v, str) else v.get("content", "")
1890
+ cat = "fact" if isinstance(v, str) else v.get("category", "fact")
1891
+ haystack = (k + " " + text).lower()
1892
+
1893
+ words = set(re.findall(r'[a-z0-9]+', haystack))
1894
+ matched = False
1895
+ for term in expanded:
1896
+ if ' ' in term or '_' in term or '-' in term:
1897
+ if term in haystack:
1898
+ matched = True
1899
+ break
1900
+ else:
1901
+ if term in words:
1902
+ matched = True
1903
+ break
1904
+
1905
+ if matched:
1906
+ # If hit is sensitive and user is not verified, block immediately
1907
+ if not is_verified:
1908
+ if any(w in k.lower() or w in text.lower() for w in sensitive_keywords):
1909
+ return (
1910
+ "[VERIFICATION REQUIRED] Access to or modification of sensitive information requires identity verification. "
1911
+ "Please ask the user for the secret code, and once provided, call the manage_memory tool with action=\"verify\" and the code as the query parameter."
1912
+ )
1913
+ preview = text[:80].replace("\n", " ") + ("…" if len(text) > 80 else "")
1914
+ hits.append(f"[{cat}] {k}: {preview}")
1915
+ if not hits:
1916
+ return f"No memories matched '{query}'."
1917
+ return "Search results:\n" + "\n".join(hits)
1918
+
1919
+ elif act == "get_traits":
1920
+ trait_cats = {"behaviour", "preference"}
1921
+ entries = []
1922
+ for k, v in memories.items():
1923
+ if isinstance(v, str):
1924
+ continue
1925
+ if v.get("category") in trait_cats:
1926
+ # Extra safety: filter out sensitive key/content if not verified
1927
+ if not is_verified:
1928
+ content_val = v.get("content", "")
1929
+ if any(w in k.lower() or w in content_val.lower() for w in sensitive_keywords):
1930
+ continue
1931
+ entries.append((k, v))
1932
+
1933
+ entries.sort(key=lambda x: x[1].get("updated_at", ""), reverse=True)
1934
+
1935
+ lines = []
1936
+ for k, v in entries[:15]:
1937
+ preview = v["content"][:120].replace("\n", " ")
1938
+ lines.append(f"• [{v['category']}] {k}: {preview}")
1939
+ if not lines:
1940
+ return "No behavioural traits stored yet."
1941
+ return "User traits:\n" + "\n".join(lines)
1942
+
1943
+ elif act == "list":
1944
+ if not memories:
1945
+ return "Memory is empty."
1946
+ lines = []
1947
+ for k, v in memories.items():
1948
+ content_val = v if isinstance(v, str) else v.get("content", "")
1949
+ cat = "fact" if isinstance(v, str) else v.get("category", "fact")
1950
+
1951
+ # Redact previews of sensitive entries if not verified
1952
+ is_key_sensitive = any(w in k.lower() or w in content_val.lower() for w in sensitive_keywords)
1953
+ if is_key_sensitive and not is_verified:
1954
+ preview = "[REDACTED - VERIFICATION REQUIRED]"
1955
+ else:
1956
+ preview = content_val[:60].replace("\n", " ")
1957
+ if len(content_val) > 60:
1958
+ preview += "…"
1959
+
1960
+ lines.append(f"- [{cat}] {k}: {preview}")
1961
+ return "Memories (" + str(len(lines)) + " entries):\n" + "\n".join(lines)
1962
+
1963
+ elif act == "delete":
1964
+ if not key:
1965
+ return "Error: 'key' is required to delete a memory."
1966
+ if key not in memories:
1967
+ return f"No memory found for key '{key}'."
1968
+
1969
+ # If not verified, block deleting any sensitive keys
1970
+ if not is_verified:
1971
+ if any(w in key.lower() for w in sensitive_keywords):
1972
+ return (
1973
+ "[VERIFICATION REQUIRED] Access to or modification of sensitive information requires identity verification. "
1974
+ "Please ask the user for the secret code, and once provided, call the manage_memory tool with action=\"verify\" and the code as the query parameter."
1975
+ )
1976
+
1977
+ del memories[key]
1978
+ _save()
1979
+
1980
+ # Delete from Vector DB too
1981
+ try:
1982
+ from utim_cli.vector_memory import get_user_memories_memory
1983
+ vm = get_user_memories_memory()
1984
+ if vm and vm.collection:
1985
+ vm.collection.delete(ids=[key])
1986
+ except Exception:
1987
+ pass
1988
+
1989
+ return f"Memory deleted: '{key}'."
1990
+
1991
+ return f"Error: Unknown action '{action}'. Valid actions: save, read, search, get_traits, list, delete, verify."
1992
+
1993
+
1994
+ def analyze_image(image_path: str, prompt: str) -> str:
1995
+ """Analyzes a local image file using a vision model."""
1996
+ import os, base64, requests, mimetypes
1997
+
1998
+ if not os.path.exists(image_path):
1999
+ return f"Error: Image file '{image_path}' not found."
2000
+
2001
+ mime_type, _ = mimetypes.guess_type(image_path)
2002
+ if not mime_type or not mime_type.startswith("image/"):
2003
+ # Fallback if mimetypes fails
2004
+ ext = os.path.splitext(image_path)[1].lower()
2005
+ if ext in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
2006
+ mime_type = f"image/{ext[1:]}"
2007
+ if ext == '.jpg': mime_type = "image/jpeg"
2008
+ else:
2009
+ return f"Error: File '{image_path}' does not appear to be a supported image format."
2010
+
2011
+ try:
2012
+ with open(image_path, "rb") as f:
2013
+ encoded_image = base64.b64encode(f.read()).decode("utf-8")
2014
+ except Exception as e:
2015
+ return f"Error reading image file: {e}"
2016
+
2017
+ from utim_cli.config import config
2018
+ llm_key = os.getenv("OPENROUTER_API_KEY") or config.get("api_key")
2019
+ if not llm_key:
2020
+ return "Error: Neither UTIM API key nor OPENROUTER_API_KEY environment variable is set."
2021
+
2022
+ payload = {
2023
+ "messages": [
2024
+ {
2025
+ "role": "user",
2026
+ "content": [
2027
+ {"type": "text", "text": prompt},
2028
+ {
2029
+ "type": "image_url",
2030
+ "image_url": {"url": f"data:{mime_type};base64,{encoded_image}"}
2031
+ }
2032
+ ]
2033
+ }
2034
+ ]
2035
+ }
2036
+
2037
+ models_to_try = [
2038
+ "google/gemma-4-31b-it:free",
2039
+ "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
2040
+ "nvidia/nemotron-nano-12b-v2-vl:free",
2041
+ "google/gemma-4-26b-a4b-it:free",
2042
+ "nvidia/llama-nemotron-embed-vl-1b-v2:free",
2043
+ "nvidia/llama-nemotron-rerank-vl-1b-v2:free",
2044
+ "openrouter/free"
2045
+ ]
2046
+ last_err = None
2047
+
2048
+ for model in models_to_try:
2049
+ payload["model"] = model
2050
+ model_retries = 2
2051
+ for attempt in range(model_retries + 1):
2052
+ try:
2053
+ from utim_cli.client_utils import proxy_openrouter_request
2054
+ resp = proxy_openrouter_request(json_data=payload, stream=False, timeout=60)
2055
+ resp.raise_for_status()
2056
+ return resp.json()["choices"][0]["message"]["content"]
2057
+ except requests.exceptions.HTTPError as e:
2058
+ code = e.response.status_code if e.response is not None else 0
2059
+ if code == 429 and attempt < model_retries:
2060
+ import time
2061
+ time.sleep(5 * (attempt + 1))
2062
+ continue
2063
+ last_err = e
2064
+ break
2065
+ except Exception as e:
2066
+ last_err = e
2067
+ break
2068
+
2069
+ return f"Error analyzing image after trying all fallback models. Last error: {last_err}"
2070
+
2071
+
2072
+ def is_image_mostly_black(image_path: str, threshold: float = 0.95) -> bool:
2073
+ """Check if the image is mostly black (common output when NSFW safety filters are triggered)."""
2074
+ try:
2075
+ from PIL import Image
2076
+ with Image.open(image_path) as img:
2077
+ gray_img = img.convert("L")
2078
+ pixels = list(gray_img.getdata())
2079
+ # Count pixels that are zero or near-zero (e.g. intensity < 10)
2080
+ black_pixels = sum(1 for p in pixels if p < 10)
2081
+ ratio = black_pixels / len(pixels)
2082
+ return ratio > threshold
2083
+ except Exception:
2084
+ return False
2085
+
2086
+
2087
+ def safe_truncate_prompt(text: str, limit: int = 799) -> str:
2088
+ """Safely truncate prompt to character limit without cutting mid-word."""
2089
+ if len(text) <= limit:
2090
+ return text
2091
+ truncated = text[:limit]
2092
+ last_space = truncated.rfind(' ')
2093
+ if last_space > 0:
2094
+ return truncated[:last_space]
2095
+ return truncated
2096
+
2097
+
2098
+ def generate_image(
2099
+ prompt: str,
2100
+ output_path: str = "",
2101
+ width: int = None,
2102
+ height: int = None,
2103
+ num_inference_steps: int = None,
2104
+ guidance_scale: float = None,
2105
+ seed: int = None
2106
+ ) -> str:
2107
+ """Generates an image from a text prompt using NVIDIA NIM APIs.
2108
+
2109
+ Tries the primary model black-forest-labs/flux.1-schnell, falling back to
2110
+ stabilityai/stable-diffusion-3.5-large, and stabilityai/stable-diffusion-xl.
2111
+ """
2112
+ import os
2113
+ import time
2114
+ import re
2115
+ import pathlib
2116
+ import requests
2117
+ import uuid
2118
+ import base64
2119
+
2120
+ from utim_cli.config import config
2121
+ api_key = config.get("api_key")
2122
+ nvidia_key = os.getenv("NVIDIA_API_KEY")
2123
+ if not api_key and not nvidia_key:
2124
+ return "Error: Neither UTIM API key nor NVIDIA_API_KEY environment variable is set. Please set one of them to generate images."
2125
+
2126
+ if not output_path:
2127
+ out_dir = pathlib.Path('.utim_tmp/images')
2128
+ out_dir.mkdir(parents=True, exist_ok=True)
2129
+ safe_prompt = re.sub(r'[^a-zA-Z0-9_-]', '_', prompt)[:30].strip('_')
2130
+ if not safe_prompt:
2131
+ safe_prompt = "generated"
2132
+ timestamp = int(time.time())
2133
+ filename = f"{timestamp}_{safe_prompt}_{uuid.uuid4().hex[:4]}.png"
2134
+ output_path = str(out_dir / filename)
2135
+
2136
+ out_file = pathlib.Path(output_path)
2137
+ try:
2138
+ out_file.parent.mkdir(parents=True, exist_ok=True)
2139
+ except Exception as e:
2140
+ return f"Error creating parent directories for output_path '{output_path}': {e}"
2141
+
2142
+ # 1. Agentic prompt expansion using LLM and sub-agent rules/context
2143
+ llm_key = os.getenv("OPENROUTER_API_KEY") or api_key
2144
+ expanded_prompt = prompt
2145
+ if llm_key:
2146
+ try:
2147
+ from utim_cli.bootstrap import get_subagent_rag_context
2148
+ subagent_rag_ctx = get_subagent_rag_context("generate_image", prompt)
2149
+ except Exception:
2150
+ subagent_rag_ctx = ""
2151
+
2152
+ # Gather workspace context for image generation
2153
+ workspace_context = ""
2154
+ try:
2155
+ for f in ["README.md", "index.html", "package.json"]:
2156
+ if os.path.exists(f):
2157
+ try:
2158
+ with open(f, "r", encoding="utf-8") as file_obj:
2159
+ workspace_context += f"\n- {f} Content snippet: {file_obj.read(1500)}"
2160
+ except Exception:
2161
+ pass
2162
+ except Exception:
2163
+ pass
2164
+
2165
+ system_prompt = (
2166
+ "You are an expert Image Generation Prompt Optimizer. Your task is to expand the user's short image request "
2167
+ "into a highly descriptive, visually rich prompt for state-of-the-art text-to-image models (like Flux or Stable Diffusion). "
2168
+ "Describe the scene composition, style (e.g. photographic, cinematic, 3D render, vector art, flat design), lighting, colors, "
2169
+ "key elements, background details, and atmosphere. Do not write filler introduction or metadata - return ONLY the final "
2170
+ "rich, expanded prompt text that can be directly passed to the image generator."
2171
+ )
2172
+ if subagent_rag_ctx:
2173
+ system_prompt += f"\n\nContext and Learned Rules:\n{subagent_rag_ctx}"
2174
+ if workspace_context:
2175
+ system_prompt += f"\n\nWorkspace/Project Details:\n{workspace_context}"
2176
+
2177
+ models_to_try = [
2178
+ "liquid/lfm-2.5-1.2b-instruct:free",
2179
+ "qwen/qwen3-coder:free",
2180
+ "google/gemma-3-27b-it:free",
2181
+ ]
2182
+ for model in models_to_try:
2183
+ try:
2184
+ payload = {
2185
+ "model": model,
2186
+ "messages": [
2187
+ {"role": "system", "content": system_prompt},
2188
+ {"role": "user", "content": f"Please expand this request: {prompt}"}
2189
+ ]
2190
+ }
2191
+ from utim_cli.client_utils import proxy_openrouter_request
2192
+ resp = proxy_openrouter_request(json_data=payload, stream=False, timeout=20)
2193
+ if resp.status_code == 200:
2194
+ result = resp.json()["choices"][0]["message"]["content"].strip()
2195
+ if result:
2196
+ expanded_prompt = result
2197
+ print(f"✨ Expanded prompt: {expanded_prompt}", flush=True)
2198
+ break
2199
+ except Exception:
2200
+ continue
2201
+
2202
+ # Use the expanded prompt for NVIDIA NIM payload
2203
+ prompt = expanded_prompt
2204
+
2205
+ models_to_try = [
2206
+ "black-forest-labs/flux.1-schnell",
2207
+ "black-forest-labs/flux.2-klein-4b",
2208
+ "black-forest-labs/flux.1-dev"
2209
+ ]
2210
+
2211
+ headers = {
2212
+ "Authorization": f"Bearer {nvidia_key}",
2213
+ "Accept": "application/json",
2214
+ "Content-Type": "application/json"
2215
+ }
2216
+
2217
+ last_err = None
2218
+
2219
+ for model in models_to_try:
2220
+ api_url = f"https://ai.api.nvidia.com/v1/genai/{model}"
2221
+ print(f"🔄 Requesting image from NVIDIA NIM model: {model}...", flush=True)
2222
+
2223
+ # Ensure prompt length is safe (especially for flux.2-klein-4b which has a strict 800-char limit)
2224
+ prompt_for_model = prompt
2225
+ if len(prompt_for_model) > 799:
2226
+ prompt_for_model = safe_truncate_prompt(prompt_for_model, 799)
2227
+
2228
+ # Build model-specific payload to avoid 422 Unprocessable Entity
2229
+ if "stabilityai" in model:
2230
+ # Stable Diffusion payload structure
2231
+ payload = {
2232
+ "text_prompts": [{"text": prompt_for_model, "weight": 1.0}]
2233
+ }
2234
+ if seed is not None:
2235
+ payload["seed"] = seed
2236
+ if width is not None:
2237
+ payload["width"] = width
2238
+ if height is not None:
2239
+ payload["height"] = height
2240
+ if num_inference_steps is not None:
2241
+ payload["steps"] = num_inference_steps
2242
+ if guidance_scale is not None:
2243
+ payload["cfg_scale"] = guidance_scale
2244
+ else:
2245
+ # Flux payload structure (only supports prompt, width, height, seed)
2246
+ payload = {
2247
+ "prompt": prompt_for_model
2248
+ }
2249
+ if seed is not None:
2250
+ payload["seed"] = seed
2251
+ if width is not None:
2252
+ payload["width"] = width
2253
+ if height is not None:
2254
+ payload["height"] = height
2255
+
2256
+ # We try making the request. If it fails with 422/400 due to parameter schema,
2257
+ # we try a fallback with absolute minimal payload (only prompt).
2258
+ attempts_to_make = [payload]
2259
+ if "stabilityai" in model:
2260
+ attempts_to_make.append({"text_prompts": [{"text": prompt_for_model, "weight": 1.0}]})
2261
+ else:
2262
+ attempts_to_make.append({"prompt": prompt_for_model})
2263
+
2264
+ for attempt_payload in attempts_to_make:
2265
+ try:
2266
+ # Log the prompt snippet to help trace any failures
2267
+ sent_prompt = attempt_payload.get("prompt")
2268
+ if not sent_prompt and "text_prompts" in attempt_payload:
2269
+ sent_prompt = attempt_payload["text_prompts"][0].get("text")
2270
+ print(f"📡 Sending request to {model}... Prompt length: {len(sent_prompt) if sent_prompt else 0} chars.", flush=True)
2271
+
2272
+ if api_key:
2273
+ from utim_cli.client_utils import get_server_url
2274
+ proxy_url = f"{get_server_url()}/completions/images/generations"
2275
+ proxy_headers = {
2276
+ "X-API-Key": api_key,
2277
+ "Content-Type": "application/json"
2278
+ }
2279
+ proxy_payload = {
2280
+ "prompt": sent_prompt,
2281
+ "model": model
2282
+ }
2283
+ if seed is not None:
2284
+ proxy_payload["seed"] = seed
2285
+ if len(attempt_payload) > 2 or ("text_prompts" in attempt_payload and len(attempt_payload) > 1):
2286
+ if width is not None:
2287
+ proxy_payload["width"] = width
2288
+ if height is not None:
2289
+ proxy_payload["height"] = height
2290
+ if num_inference_steps is not None:
2291
+ proxy_payload["steps"] = num_inference_steps
2292
+ if guidance_scale is not None:
2293
+ proxy_payload["cfg_scale"] = guidance_scale
2294
+
2295
+ resp = requests.post(proxy_url, json=proxy_payload, headers=proxy_headers, timeout=120)
2296
+ else:
2297
+ resp = requests.post(api_url, json=attempt_payload, headers=headers, timeout=25)
2298
+ resp.raise_for_status()
2299
+
2300
+ res_json = resp.json()
2301
+ img_b64 = None
2302
+
2303
+ # Extract base64 image data from NVIDIA's response structure
2304
+ if isinstance(res_json, dict):
2305
+ if "artifacts" in res_json and isinstance(res_json["artifacts"], list) and len(res_json["artifacts"]) > 0:
2306
+ img_b64 = res_json["artifacts"][0].get("base64")
2307
+ elif "data" in res_json and isinstance(res_json["data"], list) and len(res_json["data"]) > 0:
2308
+ img_b64 = res_json["data"][0].get("b64_json") or res_json["data"][0].get("url")
2309
+
2310
+ if not img_b64:
2311
+ raise Exception(f"No image data found in response. Response: {res_json}")
2312
+
2313
+ # Save image bytes
2314
+ if img_b64.startswith("http://") or img_b64.startswith("https://"):
2315
+ img_resp = requests.get(img_b64, timeout=60)
2316
+ img_resp.raise_for_status()
2317
+ image_bytes = img_resp.content
2318
+ else:
2319
+ if "," in img_b64:
2320
+ img_b64 = img_b64.split(",", 1)[1]
2321
+ image_bytes = base64.b64decode(img_b64)
2322
+
2323
+ with open(out_file, "wb") as img_file:
2324
+ img_file.write(image_bytes)
2325
+
2326
+ # Check if the generated image is solid black (safety filter trigger)
2327
+ if is_image_mostly_black(output_path):
2328
+ raise Exception("Generated image is solid/mostly black, likely due to NVIDIA content safety filter.")
2329
+
2330
+ # Return clean formatted path using file:// protocol for clickability
2331
+ abs_path_str = str(out_file.resolve())
2332
+ abs_path_str_formatted = abs_path_str.replace('\\', '/')
2333
+ file_uri = f"file:///{abs_path_str_formatted.lstrip('/')}"
2334
+ if not file_uri.startswith("file:///"):
2335
+ file_uri = "file:///" + file_uri.lstrip("file:/")
2336
+
2337
+ return f"Success: Image generated and saved to [image]({file_uri}) (local path: {abs_path_str}) using model {model}.\nExpanded prompt used: {expanded_prompt}"
2338
+
2339
+ except Exception as e:
2340
+ last_err = e
2341
+ err_str = str(e)
2342
+ if isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
2343
+ err_str += f" - Response body: {e.response.text}"
2344
+ print(f"⚠️ Failed with model {model}: {err_str}", flush=True)
2345
+
2346
+ # If 422/400 and we had parameter fields, retry with minimal payload
2347
+ if isinstance(e, requests.exceptions.HTTPError):
2348
+ status_code = e.response.status_code if e.response is not None else 0
2349
+ if (status_code == 422 or status_code == 400) and len(attempt_payload) > 1:
2350
+ print(f"🔄 Retrying model {model} with minimal prompt payload...", flush=True)
2351
+ continue
2352
+ break # try next model
2353
+
2354
+ return f"Error: All image generation models failed. Last error: {last_err}"
2355
+
2356
+
2357
+ # JSON Schema for OpenAI Tool Calling (OpenRouter format)
2358
+ UTIM_TOOLS = [
2359
+ {
2360
+ "type": "function",
2361
+ "function": {
2362
+ "name": "generate_image",
2363
+ "description": "Generates an image from a text prompt using NVIDIA NIM APIs (primary and fallbacks). Saves the generated image locally and returns the file path.",
2364
+ "parameters": {
2365
+ "type": "object",
2366
+ "properties": {
2367
+ "prompt": {
2368
+ "type": "string",
2369
+ "description": "The detailed text prompt describing the image you want to generate."
2370
+ },
2371
+ "output_path": {
2372
+ "type": "string",
2373
+ "description": "Optional. The local file path where the generated image should be saved. If omitted, defaults to a path in .utim_tmp/images/."
2374
+ },
2375
+ "width": {
2376
+ "type": "integer",
2377
+ "description": "Optional. Width of the generated image (e.g. 1024)."
2378
+ },
2379
+ "height": {
2380
+ "type": "integer",
2381
+ "description": "Optional. Height of the generated image (e.g. 1024)."
2382
+ },
2383
+ "num_inference_steps": {
2384
+ "type": "integer",
2385
+ "description": "Optional. Number of denoising/inference steps."
2386
+ },
2387
+ "guidance_scale": {
2388
+ "type": "number",
2389
+ "description": "Optional. Guidance scale / CFG scale for generation."
2390
+ },
2391
+ "seed": {
2392
+ "type": "integer",
2393
+ "description": "Optional. Seed for random generation."
2394
+ }
2395
+ },
2396
+ "required": ["prompt"]
2397
+ }
2398
+ }
2399
+ },
2400
+
2401
+ {
2402
+ "type": "function",
2403
+ "function": {
2404
+ "name": "compress_context",
2405
+ "description": "Proactively frees up your memory during long tasks. Call this if you have finished a major step (like reading several files) and want to compress older tool logs into a high-signal summary to avoid running out of context. Provide instructions on what facts/code strictly need to be preserved.",
2406
+ "parameters": {
2407
+ "type": "object",
2408
+ "properties": {
2409
+ "preservation_rules": {
2410
+ "type": "string",
2411
+ "description": "Specific facts, constraints, or code snippets you want the compressor to absolutely preserve in the summary."
2412
+ }
2413
+ },
2414
+ "required": ["preservation_rules"]
2415
+ }
2416
+ }
2417
+ },
2418
+ {
2419
+ "type": "function",
2420
+ "function": {
2421
+ "name": "query_codebase",
2422
+ "description": "Acts as a local RAG. Pass a query and it will automatically search the project tree, read relevant files, and return synthesized context. Use this to 'think' about a specific part of a large project without having to memorize it all.",
2423
+ "parameters": {
2424
+ "type": "object",
2425
+ "properties": {
2426
+ "query": {
2427
+ "type": "string",
2428
+ "description": "What you are looking for in the codebase (e.g. 'How does authentication work?' or 'Find the CSS file for the navbar')."
2429
+ }
2430
+ },
2431
+ "required": ["query"]
2432
+ }
2433
+ }
2434
+ },
2435
+ {
2436
+ "type": "function",
2437
+ "function": {
2438
+ "name": "manage_todos",
2439
+ "description": "Manages the project to-do list. Use this to track progress on complex plans. Provide either a single action or a list of 'operations' to execute multiple actions in batch.",
2440
+ "parameters": {
2441
+ "type": "object",
2442
+ "properties": {
2443
+ "operations": {
2444
+ "type": "array",
2445
+ "description": "A list of operations to perform in batch.",
2446
+ "items": {
2447
+ "type": "object",
2448
+ "properties": {
2449
+ "action": {
2450
+ "type": "string",
2451
+ "enum": ["add", "mark_done", "mark_pending", "delete", "list"]
2452
+ },
2453
+ "task_id": {"type": "string"},
2454
+ "description": {"type": "string"}
2455
+ },
2456
+ "required": ["action"]
2457
+ }
2458
+ }
2459
+ }
2460
+ }
2461
+ }
2462
+ },
2463
+ {
2464
+ "type": "function",
2465
+ "function": {
2466
+ "name": "read_file",
2467
+ "description": (
2468
+ "Reads a file's content. Large files (>250 lines) are auto-truncated — use "
2469
+ "start_line and end_line to read specific ranges (1-indexed, inclusive). "
2470
+ "Always read only the range you need; call multiple times to page through large files."
2471
+ ),
2472
+ "parameters": {
2473
+ "type": "object",
2474
+ "properties": {
2475
+ "filepath": {
2476
+ "type": "string",
2477
+ "description": "The absolute or relative path to the file to read."
2478
+ },
2479
+ "start_line": {
2480
+ "type": "integer",
2481
+ "description": "First line to read (1-indexed). Omit to start from the beginning."
2482
+ },
2483
+ "end_line": {
2484
+ "type": "integer",
2485
+ "description": "Last line to read (1-indexed, inclusive). Omit to read to end or up to the 250-line limit."
2486
+ }
2487
+ },
2488
+ "required": ["filepath"]
2489
+ }
2490
+ }
2491
+ },
2492
+ {
2493
+ "type": "function",
2494
+ "function": {
2495
+ "name": "write_file",
2496
+ "description": "Writes complete content to a file, overwriting any existing file. Use this to create or modify code.",
2497
+ "parameters": {
2498
+ "type": "object",
2499
+ "properties": {
2500
+ "filepath": {
2501
+ "type": "string",
2502
+ "description": "The path to the file."
2503
+ },
2504
+ "content": {
2505
+ "type": "string",
2506
+ "description": "The full content to write to the file."
2507
+ }
2508
+ },
2509
+ "required": ["filepath", "content"]
2510
+ }
2511
+ }
2512
+ },
2513
+ {
2514
+ "type": "function",
2515
+ "function": {
2516
+ "name": "edit_file",
2517
+ "description": "Replaces specific strings in a file. Use this for targeted edits without rewriting the whole file. Can do a single replace or multiple replacements in batch.",
2518
+ "parameters": {
2519
+ "type": "object",
2520
+ "properties": {
2521
+ "filepath": {
2522
+ "type": "string",
2523
+ "description": "The path to the file to edit."
2524
+ },
2525
+ "old_str": {
2526
+ "type": "string",
2527
+ "description": "Optional. The exact text to find and replace. Use for a single replacement."
2528
+ },
2529
+ "new_str": {
2530
+ "type": "string",
2531
+ "description": "Optional. The new text to replace the old text with. Use for a single replacement."
2532
+ },
2533
+ "replacements": {
2534
+ "type": "array",
2535
+ "description": "Optional. A list of search and replace pairs for batch updates.",
2536
+ "items": {
2537
+ "type": "object",
2538
+ "properties": {
2539
+ "old_str": {"type": "string", "description": "The exact unique text to find in the file."},
2540
+ "new_str": {"type": "string", "description": "The new text to replace it with."}
2541
+ },
2542
+ "required": ["old_str", "new_str"]
2543
+ }
2544
+ }
2545
+ },
2546
+ "required": ["filepath"]
2547
+ }
2548
+ }
2549
+ },
2550
+ {
2551
+ "type": "function",
2552
+ "function": {
2553
+ "name": "run_command",
2554
+ "description": (
2555
+ "Executes a shell command or list of commands sequentially and returns stdout, stderr, and exit code. "
2556
+ "On Windows the command runs via powershell.exe -NoProfile -NonInteractive; "
2557
+ "on macOS/Linux it runs via bash -c. "
2558
+ "Use dir_path to run the command in a specific directory (defaults to the "
2559
+ "current working directory). "
2560
+ "When the CLI is started with --sandbox, commands are checked by an "
2561
+ "intelligent static analysis sandbox and risky commands are blocked unless approved."
2562
+ ),
2563
+ "parameters": {
2564
+ "type": "object",
2565
+ "properties": {
2566
+ "command": {
2567
+ "type": "string",
2568
+ "description": "Optional. The single shell command to execute."
2569
+ },
2570
+ "commands": {
2571
+ "type": "array",
2572
+ "description": "Optional. A list of shell commands to execute sequentially. Execution halts on the first non-zero exit code.",
2573
+ "items": {
2574
+ "type": "string"
2575
+ }
2576
+ },
2577
+ "dir_path": {
2578
+ "type": "string",
2579
+ "description": (
2580
+ "Optional. The directory in which to run the command. "
2581
+ "Accepts absolute or relative paths. "
2582
+ "Defaults to the current working directory when omitted."
2583
+ )
2584
+ },
2585
+ "timeout": {
2586
+ "type": "integer",
2587
+ "description": "Optional timeout in seconds to prevent the command from hanging. Defaults to 120s."
2588
+ }
2589
+ }
2590
+ }
2591
+ }
2592
+ },
2593
+ {
2594
+ "type": "function",
2595
+ "function": {
2596
+ "name": "list_directory",
2597
+ "description": "Lists the files and folders inside a given directory. Use this to explore the project structure.",
2598
+ "parameters": {
2599
+ "type": "object",
2600
+ "properties": {
2601
+ "path": {
2602
+ "type": "string",
2603
+ "description": "The path to the directory to list (defaults to '.' if empty)."
2604
+ }
2605
+ },
2606
+ "required": ["path"]
2607
+ }
2608
+ }
2609
+ },
2610
+ {
2611
+ "type": "function",
2612
+ "function": {
2613
+ "name": "web_search",
2614
+ "description": "An agentic deep research tool. It spawns a sub-agent that performs multiple searches, reads dozens of sites based on the level, and reasons over the gathered data to provide a comprehensive raw info summary.",
2615
+ "parameters": {
2616
+ "type": "object",
2617
+ "properties": {
2618
+ "prompt": {
2619
+ "type": "string",
2620
+ "description": "The detailed research prompt or question."
2621
+ },
2622
+ "level": {
2623
+ "type": "string",
2624
+ "enum": ["low", "medium", "high"],
2625
+ "description": "The intensity of the research. Low (1-20 sites), Medium (20-80 sites), High (80-150+ sites)."
2626
+ }
2627
+ },
2628
+ "required": ["prompt", "level"]
2629
+ }
2630
+ }
2631
+ },
2632
+ {
2633
+ "type": "function",
2634
+ "function": {
2635
+ "name": "project_res",
2636
+ "description": "A specialized subagent for codebase analysis, architectural mapping, and understanding system-wide dependencies. FAST MODE enabled by default for quicker responses (45s timeout, smaller context, fewer model fallbacks).",
2637
+ "parameters": {
2638
+ "type": "object",
2639
+ "properties": {
2640
+ "prompt": {
2641
+ "type": "string",
2642
+ "description": "The detailed research prompt or question about the codebase."
2643
+ },
2644
+ "fast_mode": {
2645
+ "type": "boolean",
2646
+ "description": "Enable fast mode (default true) for quicker responses with smaller context and shorter timeouts."
2647
+ }
2648
+ },
2649
+ "required": ["prompt"]
2650
+ }
2651
+ }
2652
+ },
2653
+ {
2654
+ "type": "function",
2655
+ "function": {
2656
+ "name": "plan_project",
2657
+ "description": "An agentic tool that spawns a specialized sub-agent (design, architecture, security, etc.) to deeply reason and create a highly detailed plan for a specific part of a project. You can use this multiple times to gather different plans, then synthesize them.",
2658
+ "parameters": {
2659
+ "type": "object",
2660
+ "properties": {
2661
+ "plan_part": {
2662
+ "type": "string",
2663
+ "enum": ["design", "architecture", "security", "database", "verification", "general"],
2664
+ "description": "The specific domain to plan."
2665
+ },
2666
+ "prompt": {
2667
+ "type": "string",
2668
+ "description": "The detailed requirements or prompt for this plan."
2669
+ },
2670
+ "context": {
2671
+ "type": "string",
2672
+ "description": "Optional. Any previous context, summaries, or other plans to inform this sub-agent."
2673
+ }
2674
+ },
2675
+ "required": ["plan_part", "prompt"]
2676
+ }
2677
+ }
2678
+ },
2679
+ {
2680
+ "type": "function",
2681
+ "function": {
2682
+ "name": "analyze_image",
2683
+ "description": "Analyzes a local image file using a vision model. Can describe UI mockups, extract text, or understand diagrams.",
2684
+ "parameters": {
2685
+ "type": "object",
2686
+ "properties": {
2687
+ "image_path": {
2688
+ "type": "string",
2689
+ "description": "The path to the local image file (png, jpg, webp, gif)."
2690
+ },
2691
+ "prompt": {
2692
+ "type": "string",
2693
+ "description": "What to extract or analyze from the image."
2694
+ }
2695
+ },
2696
+ "required": ["image_path", "prompt"]
2697
+ }
2698
+ }
2699
+ },
2700
+ {
2701
+ "type": "function",
2702
+ "function": {
2703
+ "name": "analyze_blast_radius",
2704
+ "description": "Analyzes the potential impact of changes to a file by finding all dependent files. Uses the knowledge graph to identify imports, function calls, and other dependencies. Run this before modifying critical files to understand the blast radius.",
2705
+ "parameters": {
2706
+ "type": "object",
2707
+ "properties": {
2708
+ "filepath": {
2709
+ "type": "string",
2710
+ "description": "The path to the file to analyze (relative or absolute)."
2711
+ }
2712
+ },
2713
+ "required": ["filepath"]
2714
+ }
2715
+ }
2716
+ },
2717
+ {
2718
+ "type": "function",
2719
+ "function": {
2720
+ "name": "blender_create_object",
2721
+ "description": "Create a 3-D object in Blender and save it. Currently supports MESH object creation from vertices and faces.",
2722
+ "parameters": {
2723
+ "type": "object",
2724
+ "properties": {
2725
+ "name": {
2726
+ "type": "string",
2727
+ "description": "Identifier for the new object."
2728
+ },
2729
+ "object_type": {
2730
+ "type": "string",
2731
+ "description": "Currently only 'MESH' is supported.",
2732
+ "enum": ["MESH"]
2733
+ },
2734
+ "mesh_data": {
2735
+ "type": "object",
2736
+ "description": "Data for the MESH. e.g. {\"vertices\": [[x,y,z], ...], \"faces\": [[i0,i1,i2,...], ...]}.",
2737
+ "additionalProperties": True
2738
+ },
2739
+ "location": {
2740
+ "type": "array",
2741
+ "items": {"type": "number"},
2742
+ "description": "Location transforms applied after creation. Defaults to [0.0, 0.0, 0.0]."
2743
+ },
2744
+ "rotation": {
2745
+ "type": "array",
2746
+ "items": {"type": "number"},
2747
+ "description": "Rotation transforms applied after creation. Defaults to [0.0, 0.0, 0.0]."
2748
+ },
2749
+ "scale": {
2750
+ "type": "array",
2751
+ "items": {"type": "number"},
2752
+ "description": "Scale transforms applied after creation. Defaults to [1.0, 1.0, 1.0]."
2753
+ },
2754
+ "output_format": {
2755
+ "type": "string",
2756
+ "description": "Format to save the object. Can be 'blend', 'obj', or 'glb'. Defaults to 'blend'.",
2757
+ "enum": ["blend", "obj", "glb"]
2758
+ }
2759
+ },
2760
+ "required": ["name", "mesh_data"]
2761
+ }
2762
+ }
2763
+ },
2764
+ {
2765
+ "type": "function",
2766
+ "function": {
2767
+ "name": "blender_agent_create_from_image",
2768
+ "description": (
2769
+ "Advanced 4-phase Blender agent that creates a detailed 3-D model from any image, "
2770
+ "specialised for characters (anime, human, stylised), objects, and scenes. "
2771
+ "Phase 0: local Pillow-based image analysis (dominant colours, brightness, resolution). "
2772
+ "Phase 1: a vision-LLM deeply analyses the image, extracting a structured scene description "
2773
+ "including per-part geometry hints, materials, hair style, tattoos/decals, eye style, "
2774
+ "clothing, and lighting suggestions. "
2775
+ "Phase 2: a code-generation LLM writes a complete procedural bpy script that builds each "
2776
+ "part (head, hair spikes, eyes, scarf, tattoo decals, etc.), applies Principled BSDF "
2777
+ "materials with the analysed colours, projects the source image as a UV texture, and "
2778
+ "sets up a 3-point studio light rig. "
2779
+ "Phase 3: the script is executed by Blender in headless mode with automatic LLM-assisted "
2780
+ "fix-and-retry (up to 3 attempts) on failure. "
2781
+ "Use this whenever the user provides an image and wants a 3-D object, character, or scene."
2782
+ ),
2783
+ "parameters": {
2784
+ "type": "object",
2785
+ "properties": {
2786
+ "image_path": {
2787
+ "type": "string",
2788
+ "description": "Absolute or relative path to the source image file (PNG, JPG, JPEG, WEBP, BMP)."
2789
+ },
2790
+ "name": {
2791
+ "type": "string",
2792
+ "description": "Base name for the Blender object and the exported file (no extension)."
2793
+ },
2794
+ "output_path": {
2795
+ "type": "string",
2796
+ "description": "Directory where the exported file will be saved. Defaults to 'blender_assets/' in the cwd."
2797
+ },
2798
+ "output_format": {
2799
+ "type": "string",
2800
+ "description": "Export format: 'blend' (Blender native), 'obj' (Wavefront OBJ), 'glb' (glTF binary), 'fbx' (Autodesk FBX). Defaults to 'blend'.",
2801
+ "enum": ["blend", "obj", "glb", "fbx"]
2802
+ }
2803
+ },
2804
+ "required": ["image_path", "name"]
2805
+ }
2806
+ }
2807
+ },
2808
+ ]
2809
+
2810
+
2811
+ def _fast_query_codebase(query: str) -> str:
2812
+ """Lightweight codebase search without LLM synthesis - for internal use by project_res.
2813
+
2814
+ Uses internal caching to speed up repeated similar queries.
2815
+ """
2816
+ import os
2817
+ import sqlite3
2818
+ import re
2819
+ import time
2820
+
2821
+ # Simple query cache (last 5 queries, 10 second TTL)
2822
+ if not hasattr(_fast_query_codebase, "_cache"):
2823
+ _fast_query_codebase._cache = {}
2824
+
2825
+ cache_key = query[:100].lower() # First 100 chars as key
2826
+ now = time.time()
2827
+
2828
+ if cache_key in _fast_query_codebase._cache:
2829
+ cached = _fast_query_codebase._cache[cache_key]
2830
+ if now - cached["time"] < 10: # 10 second cache
2831
+ return cached["result"]
2832
+
2833
+ db_path = ".utim_tmp/codebase_fts.db"
2834
+ if not os.path.exists(db_path):
2835
+ return "No codebase index found."
2836
+
2837
+ try:
2838
+ conn = sqlite3.connect(db_path)
2839
+ cur = conn.cursor()
2840
+
2841
+ # Extract keywords from query
2842
+ words = re.findall(r"\b[A-Za-z_][A-Za-z0-9_]{3,}\b", query)
2843
+ keywords = [w for w in words[:5] if len(w) > 2] # Limit to 5 keywords
2844
+
2845
+ if not keywords:
2846
+ return "No searchable keywords found."
2847
+
2848
+ match_query = " OR ".join('"{}"'.format(kw.replace('"', '')) for kw in keywords)
2849
+
2850
+ cur.execute("SELECT path, content FROM files WHERE files MATCH ? ORDER BY rank LIMIT 5", (match_query,))
2851
+ results = cur.fetchall()
2852
+ conn.close()
2853
+
2854
+ if not results:
2855
+ result = f"No results for keywords: {match_query}"
2856
+ else:
2857
+ context = ""
2858
+ for path, content in results:
2859
+ if len(content) > 3000:
2860
+ content = content[:3000] + "\n...[truncated]"
2861
+ context += f"\n--- File: {path} ---\n{content}\n"
2862
+ result = context
2863
+
2864
+ # Cache the result
2865
+ _fast_query_codebase._cache[cache_key] = {"time": now, "result": result}
2866
+ # Clean old entries
2867
+ if len(_fast_query_codebase._cache) > 10:
2868
+ _fast_query_codebase._cache = dict(list(_fast_query_codebase._cache.items())[-5:])
2869
+
2870
+ return result
2871
+ except Exception as e:
2872
+ return f"Search error: {e}"
2873
+
2874
+
2875
+ # Cache for directory tree (valid for current session)
2876
+ _dir_tree_cache = {"timestamp": 0, "content": "", "max_depth": 0}
2877
+
2878
+ def _get_fast_dir_tree(max_depth: int = 3) -> str:
2879
+ """Fast directory tree generation with caching for repeated calls."""
2880
+ import os
2881
+ import time
2882
+
2883
+ current_time = time.time()
2884
+ # Use cache if less than 30 seconds old and depth matches
2885
+ if _dir_tree_cache["content"] and _dir_tree_cache["max_depth"] == max_depth:
2886
+ if current_time - _dir_tree_cache["timestamp"] < 30:
2887
+ return _dir_tree_cache["content"]
2888
+
2889
+ lines = []
2890
+ for root, dirs, files in os.walk("."):
2891
+ # Skip hidden and common non-source directories
2892
+ dirs[:] = [d for d in dirs if d not in [".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv", ".utim_tmp"]]
2893
+ depth = root.count(os.sep)
2894
+ if depth >= max_depth:
2895
+ dirs[:] = [] # Don't descend further
2896
+ continue
2897
+ rel = os.path.relpath(root)
2898
+ if rel == ".":
2899
+ lines.append(f"{rel}/")
2900
+ else:
2901
+ lines.append(f"{rel}/")
2902
+ for f in files[:15]: # Show more files per directory
2903
+ lines.append(f" {f}")
2904
+
2905
+ result = "\n".join(lines[:100]) # More total output
2906
+ # Update cache
2907
+ _dir_tree_cache["timestamp"] = current_time
2908
+ _dir_tree_cache["content"] = result
2909
+ _dir_tree_cache["max_depth"] = max_depth
2910
+
2911
+ return result
2912
+
2913
+
2914
+ def project_res(prompt: str, fast_mode: bool = True) -> str:
2915
+ """A specialized subagent for codebase analysis, architectural mapping, and understanding system-wide dependencies.
2916
+
2917
+ Enhanced for speed with optimizations:
2918
+ - Fast mode uses lightweight context gathering (no LLM synthesis for initial search)
2919
+ - Smaller context windows (10k chars) for faster processing
2920
+ - Reasonable timeouts (90s overall, 30s idle)
2921
+ - Still maintains quality with detailed reports (1500 tokens)
2922
+ """
2923
+ from utim_cli.config import config
2924
+ llm_key = os.getenv("OPENROUTER_API_KEY") or config.get("api_key")
2925
+ if not llm_key:
2926
+ return "Error: Neither UTIM API key nor OPENROUTER_API_KEY environment variable is set."
2927
+
2928
+ # Fast context gathering - skip LLM synthesis overhead
2929
+ if fast_mode:
2930
+ try:
2931
+ # Try vector memory first (fastest)
2932
+ from utim_cli.vector_memory import get_vector_memory
2933
+ vm = get_vector_memory()
2934
+ context_snippets = ""
2935
+ if vm:
2936
+ results = vm.query(prompt, n_results=5) # More results for quality
2937
+ for r in results:
2938
+ content = (r.get("content", "") or "")[:3000] # More content
2939
+ context_snippets += f"\n--- File: {r.get('filepath', 'unknown')} ---\n{content}\n"
2940
+ if not context_snippets:
2941
+ # Fallback to FTS (no LLM call)
2942
+ context_snippets = _fast_query_codebase(prompt)
2943
+ except Exception:
2944
+ context_snippets = _fast_query_codebase(prompt)
2945
+ else:
2946
+ try:
2947
+ context_snippets = query_codebase(prompt)
2948
+ except Exception as e:
2949
+ context_snippets = f"Failed to query codebase: {e}"
2950
+
2951
+ # Get directory tree with limited depth
2952
+ try:
2953
+ dir_tree = _get_fast_dir_tree(max_depth=2)
2954
+ except Exception as e:
2955
+ dir_tree = f"Failed to list directory: {e}"
2956
+
2957
+ # Optimized concise prompt for speed
2958
+ sys_prompt = (
2959
+ "You are an expert Codebase Investigator. Analyze the provided context and "
2960
+ "return a structured markdown report with: 1) Summary of Findings (detailed), "
2961
+ "2) Relevant File Paths, and 3) Key Symbols/Functions to examine. "
2962
+ "Be comprehensive and specific."
2963
+ )
2964
+
2965
+ try:
2966
+ from utim_cli.bootstrap import get_subagent_rag_context
2967
+ subagent_rag_ctx = get_subagent_rag_context("project_res", prompt)
2968
+ if subagent_rag_ctx:
2969
+ sys_prompt += f"\n\n{subagent_rag_ctx}"
2970
+ except Exception:
2971
+ pass
2972
+
2973
+ user_content = f"Query: {prompt}\n\nDir Tree:\n{dir_tree}\n\nContext:\n{context_snippets[:10000]}"
2974
+
2975
+ models_to_try = [
2976
+ "qwen/qwen3-coder:free",
2977
+ "poolside/laguna-xs.2:free",
2978
+ "nvidia/nemotron-3-super-120b-a12b:free",
2979
+ "nvidia/nemotron-3-nano-30b-a3b:free",
2980
+ "qwen/qwen3-next-80b-a3b-instruct:free",
2981
+ "openrouter/free"
2982
+ ]
2983
+
2984
+ last_err = None
2985
+
2986
+ for model in models_to_try:
2987
+ model_retries = 2
2988
+ for attempt in range(model_retries + 1):
2989
+ try:
2990
+ payload = {
2991
+ "model": model,
2992
+ "messages": [
2993
+ {"role": "system", "content": sys_prompt},
2994
+ {"role": "user", "content": user_content}
2995
+ ],
2996
+ "stream": True,
2997
+ "max_tokens": 1500 # Allow detailed reports
2998
+ }
2999
+ from utim_cli.client_utils import proxy_openrouter_request
3000
+ resp = proxy_openrouter_request(json_data=payload, stream=True, timeout=(10, 90))
3001
+ resp.raise_for_status()
3002
+ resp.encoding = "utf-8"
3003
+
3004
+ report = ""
3005
+ start_time = time.time()
3006
+ last_token_time = start_time
3007
+ for raw_line in resp.iter_lines(decode_unicode=True):
3008
+ if _cancel_event and _cancel_event.is_set():
3009
+ return "Error: User cancelled the investigation process."
3010
+ now = time.time()
3011
+ if now - start_time > 90: # 90 second overall timeout
3012
+ raise Exception("Timeout (90s)")
3013
+ if now - last_token_time > 30: # 30 second idle timeout
3014
+ raise Exception("Idle timeout (30s)")
3015
+ if not raw_line or not raw_line.startswith("data: "):
3016
+ continue
3017
+ data_str = raw_line[6:]
3018
+ if data_str == "[DONE]":
3019
+ break
3020
+ try:
3021
+ chunk = json.loads(data_str)
3022
+ except Exception:
3023
+ continue
3024
+
3025
+ if "error" in chunk:
3026
+ raise Exception(chunk["error"].get("message", "API Error"))
3027
+
3028
+ try:
3029
+ delta = chunk["choices"][0].get("delta", {})
3030
+ if "content" in delta and delta["content"]:
3031
+ report += delta["content"]
3032
+ last_token_time = time.time()
3033
+ except Exception:
3034
+ continue
3035
+
3036
+ # Clean up thinking tags
3037
+ report = re.sub(r"<think(?:ing)?>.*?</think(?:ing)?>", "", report, flags=re.DOTALL).strip()
3038
+
3039
+ if not report:
3040
+ raise Exception("Empty report")
3041
+
3042
+ # Save the report to disk
3043
+ os.makedirs(".utim_tmp/research", exist_ok=True)
3044
+ report_file = f".utim_tmp/research/investigation_{int(time.time())}.md"
3045
+ with open(report_file, "w", encoding="utf-8") as f:
3046
+ f.write(report)
3047
+
3048
+ return f"Codebase investigation complete. Report saved to {report_file}.\n\n### Summary\n{report[:1500]}..."
3049
+ except requests.exceptions.HTTPError as e:
3050
+ code = e.response.status_code if e.response is not None else 0
3051
+ if code == 429 and attempt < model_retries:
3052
+ time.sleep(5 * (attempt + 1))
3053
+ continue
3054
+ last_err = e
3055
+ break
3056
+ except Exception as e:
3057
+ last_err = e
3058
+ break
3059
+
3060
+ def analyze_blast_radius(filepath: str) -> str:
3061
+ """Analyzes the potential impact of changes to a file using the knowledge graph.
3062
+
3063
+ Returns files that depend on this file (imports, function calls, etc.) to help
3064
+ understand the blast radius before making edits.
3065
+ """
3066
+ try:
3067
+ from utim_cli.knowledge_graph import get_knowledge_graph
3068
+ kg = get_knowledge_graph()
3069
+
3070
+ if kg is None:
3071
+ return "Knowledge graph not available. Tree-sitter may not be installed."
3072
+
3073
+ # Build graph if needed
3074
+ if len(kg.entities) == 0:
3075
+ kg.build_graph()
3076
+
3077
+ # Get blast radius
3078
+ affected_files = kg.get_blast_radius(filepath)
3079
+
3080
+ if not affected_files:
3081
+ return f"No dependents found for `{filepath}` (file may not exist in graph)."
3082
+
3083
+ result = f"### Potential Impact Analysis for `{filepath}`\n\n"
3084
+ result += f"**{len(affected_files)} file(s) may be affected by changes:**\n\n"
3085
+ for f in affected_files:
3086
+ result += f"- `{f}`\n"
3087
+
3088
+ result += "\n**Recommendation:** Review these files before making changes to understand potential side effects."
3089
+ return result
3090
+ except ImportError as e:
3091
+ return f"Knowledge graph not available: {str(e)}"
3092
+ except Exception as e:
3093
+ return f"Error analyzing blast radius: {str(e)}"
3094
+
3095
+ def store_experience(category: str, content: str, priority: int = None, subagent: str = None) -> str:
3096
+ """
3097
+ Store learning experiences in the experiences.json file for continuous improvement.
3098
+
3099
+ Args:
3100
+ category: Type of learning (e.g., 'logic_failure', 'success_pattern', 'user_preference', 'analytical_framework')
3101
+ content: The actual learning content or insight gained
3102
+ priority: Optional priority score (defaults to based on category)
3103
+ subagent: Optional subagent name to store experiences specifically for that subagent ('project_res', 'plan_project', 'web_search')
3104
+
3105
+ Returns:
3106
+ Status message indicating success or failure
3107
+ """
3108
+ import json
3109
+ from datetime import datetime
3110
+ from pathlib import Path
3111
+
3112
+ try:
3113
+ # Ensure .utim directory exists
3114
+ utim_dir = Path('.utim')
3115
+ utim_dir.mkdir(exist_ok=True)
3116
+ exp_file = utim_dir / 'experiences.json'
3117
+
3118
+ # Load existing experiences
3119
+ experiences = []
3120
+ if exp_file.exists():
3121
+ try:
3122
+ with open(exp_file, 'r', encoding='utf-8') as f:
3123
+ experiences = json.load(f)
3124
+ except Exception:
3125
+ experiences = []
3126
+
3127
+ timestamp = datetime.now().isoformat()
3128
+ entry = {
3129
+ "category": category,
3130
+ "content": content,
3131
+ "timestamp": timestamp,
3132
+ "subagent": subagent,
3133
+ "priority": priority or 0
3134
+ }
3135
+ experiences.append(entry)
3136
+
3137
+ with open(exp_file, 'w', encoding='utf-8') as f:
3138
+ json.dump(experiences, f, indent=2)
3139
+
3140
+ # Also store in vector memory if available
3141
+ try:
3142
+ if subagent:
3143
+ import utim_cli.vector_memory as vm_mod
3144
+ getter_name = f"get_{subagent}_experiences_memory"
3145
+ getter = getattr(vm_mod, getter_name, None)
3146
+ vm = getter() if getter else None
3147
+ else:
3148
+ from utim_cli.vector_memory import get_experiences_memory
3149
+ vm = get_experiences_memory()
3150
+
3151
+ if vm:
3152
+ vm.add_text(
3153
+ text_id=f"exp_{timestamp}_{category}",
3154
+ content=content,
3155
+ metadata={"category": category, "timestamp": timestamp, "type": "learning"}
3156
+ )
3157
+ except Exception:
3158
+ pass
3159
+
3160
+ return f"[OK] Experience stored: {category}" + (f" for subagent {subagent}" if subagent else "")
3161
+ except Exception as e:
3162
+ return f"[ERROR] Failed to store experience: {str(e)}"
3163
+
3164
+ def recall_experience(query: str, limit: int = 5, subagent: str = None) -> str:
3165
+ """
3166
+ Search the RAG intelligence database (Experiences and Skills) using keyword matches and vector DB.
3167
+
3168
+ Args:
3169
+ query: Natural language search query or keywords describing what you are looking for.
3170
+ limit: Number of results to return (default 5).
3171
+ subagent: Optional subagent name to recall memory specifically from that subagent ('project_res', 'plan_project', 'web_search').
3172
+
3173
+ Returns:
3174
+ Formatted string containing the top matching experiences and skills.
3175
+ """
3176
+ try:
3177
+ from pathlib import Path
3178
+ import json
3179
+ from utim_cli.state import STATE
3180
+
3181
+ results_str = []
3182
+ utim_dir = Path('.utim')
3183
+ exp_file = utim_dir / 'experiences.json'
3184
+
3185
+ # Search experiences from experiences.json
3186
+ if exp_file.exists():
3187
+ try:
3188
+ with open(exp_file, 'r', encoding='utf-8') as f:
3189
+ experiences = json.load(f)
3190
+ except Exception:
3191
+ experiences = []
3192
+
3193
+ query_lower = query.lower()
3194
+ matched_exps = []
3195
+ for exp in experiences:
3196
+ if subagent and exp.get("subagent") != subagent:
3197
+ continue
3198
+ content = exp.get("content", "")
3199
+ cat = exp.get("category", "")
3200
+ if query_lower in content.lower() or query_lower in cat.lower():
3201
+ matched_exps.append(exp)
3202
+
3203
+ # Sort by priority and timestamp
3204
+ matched_exps.sort(key=lambda x: (x.get("priority", 0), x.get("timestamp", "")), reverse=True)
3205
+
3206
+ if matched_exps:
3207
+ results_str.append("### RELEVANT PAST EXPERIENCES ###")
3208
+ for r in matched_exps[:limit]:
3209
+ results_str.append(f"- [{r['category']}] {r['content']}")
3210
+ if "injected_contexts" not in STATE:
3211
+ STATE["injected_contexts"] = []
3212
+ STATE["injected_contexts"].append(r['content'])
3213
+
3214
+ # Search skills from .utim/skills/
3215
+ skills_dir = utim_dir / 'skills'
3216
+ if skills_dir.exists():
3217
+ matched_skills = []
3218
+ for skill_path in skills_dir.glob("**/SKILL.md"):
3219
+ try:
3220
+ with open(skill_path, 'r', encoding='utf-8') as f:
3221
+ content = f.read()
3222
+ if query.lower() in content.lower():
3223
+ skill_name = skill_path.parent.name
3224
+ matched_skills.append((skill_name, content))
3225
+ except Exception:
3226
+ pass
3227
+ if matched_skills:
3228
+ results_str.append("\n### RELEVANT CORE SKILLS / RULES ###")
3229
+ for name, content in matched_skills[:3]:
3230
+ if content.startswith('---'):
3231
+ parts = content.split('---', 2)
3232
+ if len(parts) >= 3:
3233
+ content = parts[2].strip()
3234
+ results_str.append(f"- [{name.upper()}] {content[:300]}...")
3235
+ if "injected_contexts" not in STATE:
3236
+ STATE["injected_contexts"] = []
3237
+ STATE["injected_contexts"].append(content)
3238
+
3239
+ if not results_str:
3240
+ return f"No relevant experiences or skills found for your query."
3241
+
3242
+ return "\n".join(results_str)
3243
+ except Exception as e:
3244
+ return f"[ERROR] Failed to recall experience: {str(e)}"
3245
+ def compress_context(preservation_rules: str) -> str:
3246
+ """
3247
+ Adaptive context compression - lets the model decide how much compression it needs.
3248
+
3249
+ The model can call this tool to compress its working memory when it feels
3250
+ the context is getting too large. It provides preservation rules that guide
3251
+ what to keep vs what to summarize.
3252
+ """
3253
+ return f"Context compression requested. It will be executed at the end of this turn. Preservation rules: {preservation_rules}"
3254
+
3255
+
3256
+
3257
+ # ─── Blender Helper ───────────────────────────────────────────────────────
3258
+ def _blender_run_script(script_path: str, timeout: int = 120) -> str:
3259
+ """Execute a temporary Blender Python script in headless mode.
3260
+
3261
+ The function builds the command using the auto‑detected Blender path
3262
+ from ``config.BLENDER_PATH`` (or environment variable). It respects the
3263
+ UTIM sandbox – if sandbox mode is active the exact command string is
3264
+ auto‑approved before execution.
3265
+ """
3266
+ from utim_cli.config import BLENDER_PATH
3267
+ if not BLENDER_PATH:
3268
+ return "Error: Blender executable not found. Set UTIM_BLENDER_PATH env var or install Blender."
3269
+
3270
+ if os.name == "nt":
3271
+ cmd = f'& "{BLENDER_PATH}" -b -noaudio -P "{script_path}"'
3272
+ else:
3273
+ cmd = f'"{BLENDER_PATH}" -b -noaudio -P "{script_path}"'
3274
+
3275
+ # Auto‑approve in sandbox mode
3276
+ if _SANDBOX_MODE and not is_command_approved(cmd):
3277
+ approve_command(cmd)
3278
+ # Execute via existing run_command utility
3279
+ result = run_command(command=cmd, timeout=timeout)
3280
+ return result
3281
+
3282
+ # ─── Blender Create Object Tool ──────────────────────────────────────────────
3283
+ def blender_create_object(
3284
+ name: str,
3285
+ object_type: str = "MESH",
3286
+ mesh_data: dict | None = None,
3287
+ location: list[float] = None,
3288
+ rotation: list[float] = None,
3289
+ scale: list[float] = None,
3290
+ output_format: str = "blend"
3291
+ ) -> str:
3292
+ """Create a 3‑D object in Blender and save it.
3293
+
3294
+ Parameters
3295
+ ----------
3296
+ name: Identifier for the new object.
3297
+ object_type: Currently only "MESH" is supported.
3298
+ mesh_data: ``{"vertices": [[x,y,z], …], "faces": [[i0,i1,i2,…], …]}``.
3299
+ location, rotation, scale: Transforms applied after creation.
3300
+ output_format: "blend", "obj", or "glb".
3301
+ """
3302
+ import json, pathlib, uuid, os
3303
+ # Normalise optional transforms
3304
+ location = location or [0.0, 0.0, 0.0]
3305
+ rotation = rotation or [0.0, 0.0, 0.0]
3306
+ scale = scale or [1.0, 1.0, 1.0]
3307
+
3308
+ # Prepare temporary script content
3309
+ script_lines = [
3310
+ "import bpy, json, pathlib, sys",
3311
+ "# Clean default scene",
3312
+ "bpy.ops.object.select_all(action='SELECT')",
3313
+ "bpy.ops.object.delete(use_global=False)",
3314
+ ]
3315
+ if object_type.upper() == "MESH" and mesh_data:
3316
+ verts = mesh_data.get('vertices', [])
3317
+ faces = mesh_data.get('faces', [])
3318
+ script_lines += [
3319
+ f"verts = {json.dumps(verts)}",
3320
+ f"faces = {json.dumps(faces)}",
3321
+ "mesh = bpy.data.meshes.new('TempMesh')",
3322
+ "mesh.from_pydata(verts, [], faces)",
3323
+ "obj = bpy.data.objects.new('TempObj', mesh)",
3324
+ "bpy.context.collection.objects.link(obj)",
3325
+ f"obj.location = {location}",
3326
+ f"obj.rotation_euler = {rotation}",
3327
+ f"obj.scale = {scale}",
3328
+ ]
3329
+ else:
3330
+ return "Error: Currently only MESH objects with mesh_data are supported."
3331
+
3332
+ # Determine output path
3333
+ assets_dir = pathlib.Path('.utim_tmp/blender_assets').absolute()
3334
+ assets_dir.mkdir(parents=True, exist_ok=True)
3335
+ filename = f"{name}_{uuid.uuid4().hex[:8]}.{output_format if output_format != 'blend' else 'blend'}"
3336
+ out_path = assets_dir / filename
3337
+ if output_format == "blend":
3338
+ script_lines.append(f"bpy.ops.wm.save_as_mainfile(filepath=r'{out_path}')")
3339
+ elif output_format == "obj":
3340
+ script_lines.append(f"bpy.ops.wm.obj_export(filepath=r'{out_path}', export_selected_objects=False)")
3341
+ elif output_format == "glb":
3342
+ script_lines.append(f"bpy.ops.export_scene.gltf(filepath=r'{out_path}', export_format='GLB')")
3343
+ else:
3344
+ return f"Error: Unsupported output_format '{output_format}'."
3345
+
3346
+ # Write temporary script
3347
+ tmp_dir = pathlib.Path('.utim_tmp/blender')
3348
+ tmp_dir.mkdir(parents=True, exist_ok=True)
3349
+ script_path = tmp_dir / f'create_{uuid.uuid4().hex[:8]}.py'
3350
+ with open(script_path, 'w', encoding='utf-8') as f:
3351
+ f.write('\n'.join(script_lines))
3352
+
3353
+ # Run Blender
3354
+ exec_result = _blender_run_script(str(script_path))
3355
+
3356
+ # Register asset in pattern store (if successful)
3357
+ if "[exit_code: 0]" in exec_result:
3358
+ return f"Success: Created {output_format} asset at {out_path}\n{exec_result}"
3359
+ else:
3360
+ return f"Error creating Blender object:\n{exec_result}"
3361
+
3362
+ # Map tool names to actual Python functions
3363
+ TOOL_FUNCTIONS = {
3364
+ "compress_context": compress_context,
3365
+ "analyze_blast_radius": analyze_blast_radius,
3366
+ "project_res": project_res,
3367
+ "read_file": read_file,
3368
+ "write_file": write_file,
3369
+ "edit_file": edit_file,
3370
+ "run_command": run_command,
3371
+ "list_directory": list_directory,
3372
+ "web_search": web_search,
3373
+ "plan_project": plan_project,
3374
+ "manage_todos": manage_todos,
3375
+ "query_codebase": query_codebase,
3376
+ "analyze_image": analyze_image,
3377
+
3378
+ "blender_create_object": blender_create_object,
3379
+ "blender_agent_create_from_image": blender_agent_create_from_image,
3380
+ "generate_image": generate_image,
3381
+ }