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/__init__.py +40 -0
- utim_cli/agent.py +359 -0
- utim_cli/auth.py +208 -0
- utim_cli/backup.py +101 -0
- utim_cli/billing.py +40 -0
- utim_cli/blender_agent.py +1018 -0
- utim_cli/bootstrap.py +324 -0
- utim_cli/client_utils.py +135 -0
- utim_cli/config.py +194 -0
- utim_cli/context_pruner.py +504 -0
- utim_cli/doctor.py +118 -0
- utim_cli/knowledge_graph.py +462 -0
- utim_cli/logger.py +121 -0
- utim_cli/mcp_clean_wrapper.py +55 -0
- utim_cli/mcp_client.py +198 -0
- utim_cli/mcp_registry.json +1102 -0
- utim_cli/orchestrator.py +3209 -0
- utim_cli/reflection.py +200 -0
- utim_cli/report.py +100 -0
- utim_cli/scrapy_search.py +229 -0
- utim_cli/share.py +320 -0
- utim_cli/share_tui.py +554 -0
- utim_cli/situational_scoring.py +269 -0
- utim_cli/state.py +15 -0
- utim_cli/tools.py +3381 -0
- utim_cli/utim.py +4051 -0
- utim_cli/vector_memory.py +629 -0
- utim_cli/workspace.py +33 -0
- utim_cli-1.0.0.dist-info/METADATA +134 -0
- utim_cli-1.0.0.dist-info/RECORD +34 -0
- utim_cli-1.0.0.dist-info/WHEEL +5 -0
- utim_cli-1.0.0.dist-info/entry_points.txt +2 -0
- utim_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- utim_cli-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
}
|