visualagents 0.1.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.
- visualagents/__init__.py +20 -0
- visualagents/__main__.py +3 -0
- visualagents/_autoinstrument/__init__.py +0 -0
- visualagents/_autoinstrument/sitecustomize.py +40 -0
- visualagents/_autoinstrument/visualagents_runtime.py +864 -0
- visualagents/_hook/visualagents_boot.py +42 -0
- visualagents/blueprint.py +276 -0
- visualagents/cli.py +726 -0
- visualagents/collector.py +146 -0
- visualagents/demo/__init__.py +0 -0
- visualagents/demo/blueprint.json +68 -0
- visualagents/demo/research_crew.py +146 -0
- visualagents/detect.py +337 -0
- visualagents/home.py +139 -0
- visualagents/hooks.py +63 -0
- visualagents/hub.py +143 -0
- visualagents/launcher.py +87 -0
- visualagents/llm.py +314 -0
- visualagents/narrator.py +58 -0
- visualagents/patterns.py +81 -0
- visualagents/projects.py +140 -0
- visualagents/runner.py +355 -0
- visualagents/scanner.py +91 -0
- visualagents/sdk.py +18 -0
- visualagents/server.py +209 -0
- visualagents/static_scan.py +677 -0
- visualagents/termui.py +94 -0
- visualagents/tracker.py +501 -0
- visualagents/tui.py +240 -0
- visualagents/web/app.js +1753 -0
- visualagents/web/index.html +117 -0
- visualagents/web/styles.css +551 -0
- visualagents/wizard.py +331 -0
- visualagents-0.1.0.dist-info/METADATA +438 -0
- visualagents-0.1.0.dist-info/RECORD +38 -0
- visualagents-0.1.0.dist-info/WHEEL +4 -0
- visualagents-0.1.0.dist-info/entry_points.txt +3 -0
- visualagents-0.1.0.dist-info/licenses/LICENSE +21 -0
visualagents/detect.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Works out how to start a project, so `visualagents run <folder>` needs no command.
|
|
2
|
+
|
|
3
|
+
Looks at the README's shell snippets, start scripts, Makefile targets, Python entry points (FastAPI,
|
|
4
|
+
Gradio, Streamlit, Chainlit, `__main__` scripts), pyproject console scripts and package.json, then
|
|
5
|
+
points `python` / `uvicorn` / ... at the project's own virtualenv. The chosen command is saved to
|
|
6
|
+
`.visualagents/config.json`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import blueprint as bpmod
|
|
20
|
+
|
|
21
|
+
_SKIP = {".git", "node_modules", "__pycache__", ".visualagents", "dist", "build", ".tox", ".mypy_cache",
|
|
22
|
+
".pytest_cache", "site-packages", ".next", ".cache"}
|
|
23
|
+
_RUN_LINE = re.compile(
|
|
24
|
+
r"^(?:\./[\w.\-/]+\.sh|bash\s+[\w.\-/]+\.sh|sh\s+[\w.\-/]+\.sh|python3?\s+(?!-m\s+(?:venv|pip)\b)\S+"
|
|
25
|
+
r"|uv\s+run\s+\S+|poetry\s+run\s+\S+|streamlit\s+run|chainlit\s+run|uvicorn\s+\S+|fastapi\s+(?:dev|run)|"
|
|
26
|
+
r"gradio\s+\S+|npm\s+run\s+(?:dev|start)|npm\s+start|pnpm\s+(?:run\s+)?(?:dev|start)|"
|
|
27
|
+
r"yarn\s+(?:dev|start)|make\s+(?:run|start|dev|serve)|docker\s+compose\s+up|docker-compose\s+up)\b"
|
|
28
|
+
)
|
|
29
|
+
_NOT_RUN = re.compile(r"\b(pip|install|venv|virtualenv|setup\.py|pytest|test|lint|build|format|mypy|ruff|black|"
|
|
30
|
+
r"stop|kill|clean|uninstall|teardown|down|reset)\b")
|
|
31
|
+
_VENV_TOOLS = {"python", "python3", "uvicorn", "streamlit", "chainlit", "gradio", "fastapi", "flask", "jupyter",
|
|
32
|
+
"hypercorn", "gunicorn", "celery", "panel"}
|
|
33
|
+
_ENTRY_NAMES = ("main.py", "app.py", "run.py", "server.py", "cli.py", "agent.py", "agents.py", "chat.py",
|
|
34
|
+
"webui.py", "demo.py", "start.py", "launch.py")
|
|
35
|
+
_ENTRY_DIRS = (".", "src", "backend", "app", "server", "api")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Candidate:
|
|
40
|
+
command: list[str]
|
|
41
|
+
cwd: str = "."
|
|
42
|
+
source: str = ""
|
|
43
|
+
note: str = ""
|
|
44
|
+
score: int = 0
|
|
45
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
def label(self) -> str:
|
|
48
|
+
where = f" (in {self.cwd}/)" if self.cwd not in (".", "") else ""
|
|
49
|
+
return f"{shlex.join(self.command)}{where}"
|
|
50
|
+
|
|
51
|
+
def to_json(self) -> dict[str, Any]:
|
|
52
|
+
return {"command": self.command, "cwd": self.cwd}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# -- saved choice ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def config_path(project: Path) -> Path:
|
|
58
|
+
return bpmod.state_dir(project) / "config.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_saved(project: Path) -> Candidate | None:
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(config_path(project).read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
return None
|
|
66
|
+
cmd = data.get("command")
|
|
67
|
+
if isinstance(cmd, str):
|
|
68
|
+
cmd = shlex.split(cmd)
|
|
69
|
+
if not cmd:
|
|
70
|
+
return None
|
|
71
|
+
return Candidate(list(cmd), data.get("cwd") or ".", "saved")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_choice(project: Path, cand: Candidate) -> Path:
|
|
75
|
+
path = config_path(project)
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
data = {}
|
|
81
|
+
data.update(cand.to_json())
|
|
82
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
83
|
+
ignore = path.parent / ".gitignore"
|
|
84
|
+
if not ignore.exists():
|
|
85
|
+
ignore.write_text("runs/\n", encoding="utf-8")
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# -- virtualenvs --------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def find_venvs(project: Path, max_depth: int = 3) -> list[Path]:
|
|
92
|
+
"""Virtualenvs inside the project (anything with a pyvenv.cfg), shallowest first."""
|
|
93
|
+
project = Path(project).resolve()
|
|
94
|
+
found: list[Path] = []
|
|
95
|
+
for dirpath, dirnames, _ in os.walk(project):
|
|
96
|
+
here = Path(dirpath)
|
|
97
|
+
depth = len(here.relative_to(project).parts)
|
|
98
|
+
if (here / "pyvenv.cfg").is_file():
|
|
99
|
+
found.append(here)
|
|
100
|
+
dirnames[:] = []
|
|
101
|
+
continue
|
|
102
|
+
if depth >= max_depth:
|
|
103
|
+
dirnames[:] = []
|
|
104
|
+
continue
|
|
105
|
+
dirnames[:] = sorted(d for d in dirnames if d not in _SKIP)
|
|
106
|
+
found.sort(key=lambda p: (len(p.relative_to(project).parts), p.name not in (".venv", "venv")))
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def venv_bin(venv: Path, name: str) -> Path:
|
|
111
|
+
return venv / ("Scripts" if os.name == "nt" else "bin") / (name + (".exe" if os.name == "nt" else ""))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def best_venv(project: Path, cwd: str = ".") -> Path | None:
|
|
115
|
+
"""The virtualenv closest to where the command runs (backend/venv for a command run in backend/)."""
|
|
116
|
+
project = Path(project).resolve()
|
|
117
|
+
venvs = find_venvs(project)
|
|
118
|
+
if not venvs:
|
|
119
|
+
return None
|
|
120
|
+
here = (project / cwd).resolve()
|
|
121
|
+
|
|
122
|
+
def distance(v: Path) -> tuple[int, int]:
|
|
123
|
+
parent = v.parent
|
|
124
|
+
if here == parent or parent in here.parents:
|
|
125
|
+
return (0, len(here.relative_to(parent).parts))
|
|
126
|
+
if here in parent.parents:
|
|
127
|
+
return (1, len(parent.relative_to(here).parts))
|
|
128
|
+
return (2, len(v.relative_to(project).parts))
|
|
129
|
+
|
|
130
|
+
return sorted(venvs, key=distance)[0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve(project: Path, cand: Candidate) -> tuple[list[str], list[str]]:
|
|
134
|
+
"""Point the command at the project's environment. Returns (argv, notes for the user)."""
|
|
135
|
+
project = Path(project).resolve()
|
|
136
|
+
argv = list(cand.command)
|
|
137
|
+
notes: list[str] = []
|
|
138
|
+
if not argv:
|
|
139
|
+
return argv, notes
|
|
140
|
+
head = Path(argv[0]).name
|
|
141
|
+
venv = best_venv(project, cand.cwd)
|
|
142
|
+
if head in _VENV_TOOLS and "/" not in argv[0]:
|
|
143
|
+
if venv is not None and venv_bin(venv, head if head != "python3" else "python").exists():
|
|
144
|
+
argv[0] = str(venv_bin(venv, head if head != "python3" else "python"))
|
|
145
|
+
notes.append(f"using the project's virtualenv: {venv.relative_to(project)}")
|
|
146
|
+
elif (project / "uv.lock").exists():
|
|
147
|
+
argv = ["uv", "run", *argv]
|
|
148
|
+
notes.append("using uv (uv.lock found)")
|
|
149
|
+
elif (project / "poetry.lock").exists():
|
|
150
|
+
argv = ["poetry", "run", *argv]
|
|
151
|
+
notes.append("using poetry (poetry.lock found)")
|
|
152
|
+
else:
|
|
153
|
+
if head in ("python", "python3"):
|
|
154
|
+
argv[0] = working_python(head)
|
|
155
|
+
if (project / "requirements.txt").exists() or (project / "pyproject.toml").exists():
|
|
156
|
+
notes.append("no virtualenv found - create one if imports fail: "
|
|
157
|
+
"python3 -m venv .venv && .venv/bin/pip install -r requirements.txt")
|
|
158
|
+
elif argv[0].endswith(".py") and (project / cand.cwd / argv[0]).exists():
|
|
159
|
+
py = venv_bin(venv, "python") if venv else None
|
|
160
|
+
argv = [str(py) if py and py.exists() else working_python("python3"), *argv]
|
|
161
|
+
return argv, notes
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def working_python(preferred: str = "python3") -> str:
|
|
165
|
+
"""First of python3 / python on PATH that actually starts (pyenv shims can point at missing versions)."""
|
|
166
|
+
import shutil
|
|
167
|
+
import subprocess
|
|
168
|
+
import sys
|
|
169
|
+
|
|
170
|
+
for name in dict.fromkeys([preferred, "python3", "python"]):
|
|
171
|
+
exe = shutil.which(name)
|
|
172
|
+
if not exe:
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
if subprocess.run([exe, "-c", "import sys"], capture_output=True, timeout=10).returncode == 0:
|
|
176
|
+
return name if exe == shutil.which(name) else exe
|
|
177
|
+
except (OSError, subprocess.SubprocessError):
|
|
178
|
+
continue
|
|
179
|
+
return sys.executable
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# -- detection ------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def detect(project: Path) -> list[Candidate]:
|
|
185
|
+
project = Path(project).resolve()
|
|
186
|
+
cands: list[Candidate] = []
|
|
187
|
+
cands += _from_readme(project)
|
|
188
|
+
cands += _from_scripts(project)
|
|
189
|
+
cands += _from_python_entries(project)
|
|
190
|
+
cands += _from_pyproject(project)
|
|
191
|
+
cands += _from_makefile(project)
|
|
192
|
+
cands += _from_package_json(project)
|
|
193
|
+
seen: set[tuple[str, str]] = set()
|
|
194
|
+
out = []
|
|
195
|
+
for c in sorted(cands, key=lambda c: -c.score):
|
|
196
|
+
key = (shlex.join(c.command), os.path.normpath(c.cwd))
|
|
197
|
+
if key in seen:
|
|
198
|
+
continue
|
|
199
|
+
seen.add(key)
|
|
200
|
+
out.append(c)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _from_readme(project: Path) -> list[Candidate]:
|
|
205
|
+
out: list[Candidate] = []
|
|
206
|
+
for name in ("README.md", "readme.md", "README.MD", "README.rst", "README"):
|
|
207
|
+
readme = project / name
|
|
208
|
+
if readme.is_file():
|
|
209
|
+
break
|
|
210
|
+
else:
|
|
211
|
+
return out
|
|
212
|
+
try:
|
|
213
|
+
text = readme.read_text(encoding="utf-8", errors="replace")
|
|
214
|
+
except OSError:
|
|
215
|
+
return out
|
|
216
|
+
shells = {"", "bash", "sh", "shell", "console", "zsh", "text", "cmd", "terminal", "powershell"}
|
|
217
|
+
blocks = [body for lang, body in re.findall(r"^[ \t]*```[ \t]*([\w+-]*)[^\n]*\n(.*?)^[ \t]*```", text, re.S | re.M)
|
|
218
|
+
if lang.lower() in shells]
|
|
219
|
+
order = 0
|
|
220
|
+
for block in blocks:
|
|
221
|
+
cwd = "."
|
|
222
|
+
for raw in block.splitlines():
|
|
223
|
+
line = raw.strip()
|
|
224
|
+
line = re.sub(r"^(\$|>|%)\s+", "", line)
|
|
225
|
+
if not line or line.startswith("#"):
|
|
226
|
+
continue
|
|
227
|
+
for part in [p.strip() for p in re.split(r"\s*&&\s*", line)]:
|
|
228
|
+
m_cd = re.match(r"^cd\s+(\S+)$", part)
|
|
229
|
+
if m_cd:
|
|
230
|
+
target = m_cd.group(1).strip("'\"")
|
|
231
|
+
if not target.startswith(("/", "~", "$")) and "<" not in target:
|
|
232
|
+
cwd = os.path.normpath(os.path.join(cwd, target))
|
|
233
|
+
continue
|
|
234
|
+
if not _RUN_LINE.match(part) or _NOT_RUN.search(part.split(" #")[0]):
|
|
235
|
+
continue
|
|
236
|
+
cmd_text = part.split(" #")[0].strip()
|
|
237
|
+
try:
|
|
238
|
+
argv = shlex.split(cmd_text)
|
|
239
|
+
except ValueError:
|
|
240
|
+
continue
|
|
241
|
+
if not (project / cwd).is_dir() or not _files_exist(project / cwd, argv):
|
|
242
|
+
continue
|
|
243
|
+
order += 1
|
|
244
|
+
out.append(Candidate(argv, cwd, "README", score=100 - order))
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _files_exist(base: Path, argv: list[str]) -> bool:
|
|
249
|
+
for tok in argv[:3]:
|
|
250
|
+
if tok.endswith((".py", ".sh")) and not (base / tok).exists():
|
|
251
|
+
return False
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _from_scripts(project: Path) -> list[Candidate]:
|
|
256
|
+
out = []
|
|
257
|
+
for name in ("start.sh", "run.sh", "dev.sh", "launch.sh", "serve.sh", "main.sh", "start", "run"): # never stop.sh etc.
|
|
258
|
+
p = project / name
|
|
259
|
+
if p.is_file() and (name.endswith(".sh") or os.access(p, os.X_OK)):
|
|
260
|
+
cmd = [f"./{name}"] if os.access(p, os.X_OK) else ["bash", name]
|
|
261
|
+
out.append(Candidate(cmd, ".", "start script", score=90 if name.startswith("start") else 80))
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _from_python_entries(project: Path) -> list[Candidate]:
|
|
266
|
+
out = []
|
|
267
|
+
for d in _ENTRY_DIRS:
|
|
268
|
+
base = project / d
|
|
269
|
+
if not base.is_dir():
|
|
270
|
+
continue
|
|
271
|
+
files = [base / n for n in _ENTRY_NAMES] + sorted(base.glob("*_app.py")) + sorted(base.glob("app_*.py"))
|
|
272
|
+
for f in files:
|
|
273
|
+
if not f.is_file():
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
src = f.read_text(encoding="utf-8", errors="replace")
|
|
277
|
+
except OSError:
|
|
278
|
+
continue
|
|
279
|
+
rel_dir = os.path.relpath(f.parent, project)
|
|
280
|
+
if re.search(r"^\s*(import streamlit|from streamlit)", src, re.M):
|
|
281
|
+
out.append(Candidate(["streamlit", "run", f.name], rel_dir, "Streamlit app", score=70))
|
|
282
|
+
elif re.search(r"^\s*(import chainlit|from chainlit)", src, re.M):
|
|
283
|
+
out.append(Candidate(["chainlit", "run", f.name], rel_dir, "Chainlit app", score=70))
|
|
284
|
+
elif "__main__" in src:
|
|
285
|
+
kind = "Gradio app" if "gradio" in src else "script"
|
|
286
|
+
out.append(Candidate(["python", f.name], rel_dir, kind, score=65 if kind != "script" else 60))
|
|
287
|
+
else:
|
|
288
|
+
m = re.search(r"^(\w+)\s*=\s*FastAPI\(", src, re.M)
|
|
289
|
+
if m:
|
|
290
|
+
out.append(Candidate(["python", "-m", "uvicorn", f"{f.stem}:{m.group(1)}", "--port", "8000"],
|
|
291
|
+
rel_dir, "FastAPI app", score=68))
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _from_pyproject(project: Path) -> list[Candidate]:
|
|
296
|
+
p = project / "pyproject.toml"
|
|
297
|
+
if not p.is_file():
|
|
298
|
+
return []
|
|
299
|
+
try:
|
|
300
|
+
import tomllib # type: ignore
|
|
301
|
+
|
|
302
|
+
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
303
|
+
except Exception:
|
|
304
|
+
return []
|
|
305
|
+
scripts = (data.get("project") or {}).get("scripts") or {}
|
|
306
|
+
scripts.update(((data.get("tool") or {}).get("poetry") or {}).get("scripts") or {})
|
|
307
|
+
return [Candidate([name], ".", "pyproject script", score=55) for name in list(scripts)[:4]]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _from_makefile(project: Path) -> list[Candidate]:
|
|
311
|
+
p = project / "Makefile"
|
|
312
|
+
if not p.is_file():
|
|
313
|
+
return []
|
|
314
|
+
try:
|
|
315
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
316
|
+
except OSError:
|
|
317
|
+
return []
|
|
318
|
+
return [Candidate(["make", t], ".", "Makefile", score=50)
|
|
319
|
+
for t in ("run", "start", "dev", "serve") if re.search(rf"^{t}\s*:", text, re.M)]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _from_package_json(project: Path) -> list[Candidate]:
|
|
323
|
+
out = []
|
|
324
|
+
for d in (".", "frontend", "web", "app", "ui"):
|
|
325
|
+
p = project / d / "package.json"
|
|
326
|
+
if not p.is_file():
|
|
327
|
+
continue
|
|
328
|
+
try:
|
|
329
|
+
scripts = json.loads(p.read_text(encoding="utf-8")).get("scripts") or {}
|
|
330
|
+
except (OSError, ValueError):
|
|
331
|
+
continue
|
|
332
|
+
for name in ("dev", "start"):
|
|
333
|
+
if name in scripts:
|
|
334
|
+
out.append(Candidate(["npm", "run", name], os.path.normpath(d), "package.json",
|
|
335
|
+
score=45 if d == "." else 30))
|
|
336
|
+
break
|
|
337
|
+
return out
|
visualagents/home.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""User-level VisualAgents state: ~/.visualagents (override with VISUALAGENTS_HOME).
|
|
2
|
+
|
|
3
|
+
daemon.json where the background dashboard listens (url, token, pid)
|
|
4
|
+
registry.json hooked projects: id -> {path, envs}
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def home() -> Path:
|
|
17
|
+
return Path(os.environ.get("VISUALAGENTS_HOME") or Path.home() / ".visualagents")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
23
|
+
except (OSError, ValueError):
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write(path: Path, data: dict[str, Any]) -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
tmp = path.with_suffix(".tmp")
|
|
30
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
31
|
+
os.chmod(tmp, 0o600)
|
|
32
|
+
tmp.replace(path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -- daemon ----------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def daemon_file() -> Path:
|
|
38
|
+
return home() / "daemon.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_daemon() -> dict[str, Any]:
|
|
42
|
+
"""The running daemon's {url, token, pid}, or {} when none is running."""
|
|
43
|
+
info = _read(daemon_file())
|
|
44
|
+
pid = info.get("pid")
|
|
45
|
+
if not pid or not _alive(int(pid)):
|
|
46
|
+
return {}
|
|
47
|
+
return info
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_daemon(url: str, token: str, pid: int) -> None:
|
|
51
|
+
_write(daemon_file(), {"url": url, "token": token, "pid": pid})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clear_daemon(pid: int | None = None) -> None:
|
|
55
|
+
info = _read(daemon_file())
|
|
56
|
+
if pid is None or info.get("pid") == pid:
|
|
57
|
+
try:
|
|
58
|
+
daemon_file().unlink()
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _alive(pid: int) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
os.kill(pid, 0)
|
|
66
|
+
except ProcessLookupError:
|
|
67
|
+
return False
|
|
68
|
+
except PermissionError:
|
|
69
|
+
return True
|
|
70
|
+
except OSError:
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# -- registry of hooked projects ----------------------------------------------
|
|
76
|
+
|
|
77
|
+
def registry() -> dict[str, dict[str, Any]]:
|
|
78
|
+
return _read(home() / "registry.json").get("projects", {})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_registry(projects: dict[str, dict[str, Any]]) -> None:
|
|
82
|
+
_write(home() / "registry.json", {"projects": projects})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def project_id(path: Path, projects: dict[str, dict[str, Any]] | None = None) -> str:
|
|
86
|
+
"""Short, URL-friendly id for a project folder (stable across calls)."""
|
|
87
|
+
path = Path(path).resolve()
|
|
88
|
+
projects = registry() if projects is None else projects
|
|
89
|
+
for pid, info in projects.items():
|
|
90
|
+
if info.get("path") == str(path):
|
|
91
|
+
return pid
|
|
92
|
+
base = re.sub(r"[^a-z0-9]+", "-", path.name.lower()).strip("-") or "project"
|
|
93
|
+
pid, n = base, 2
|
|
94
|
+
while pid in projects and projects[pid].get("path") != str(path):
|
|
95
|
+
pid, n = f"{base}-{n}", n + 1
|
|
96
|
+
return pid
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# -- saved settings and keys (set by the setup wizard) ------------------------------
|
|
100
|
+
|
|
101
|
+
def settings() -> dict[str, Any]:
|
|
102
|
+
return _read(home() / "settings.json")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save_settings(data: dict[str, Any]) -> None:
|
|
106
|
+
current = settings()
|
|
107
|
+
current.update(data)
|
|
108
|
+
_write(home() / "settings.json", current)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def remember_project(path: Path) -> None:
|
|
112
|
+
recent = [p for p in settings().get("recent", []) if p != str(path)]
|
|
113
|
+
save_settings({"recent": [str(path), *recent][:8]})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def saved_keys() -> dict[str, str]:
|
|
117
|
+
return {k: v for k, v in _read(home() / "keys.json").items() if isinstance(v, str) and v}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def save_key(var: str, value: str) -> Path:
|
|
121
|
+
keys = saved_keys()
|
|
122
|
+
keys[var] = value
|
|
123
|
+
path = home() / "keys.json"
|
|
124
|
+
_write(path, keys)
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def apply_saved() -> None:
|
|
129
|
+
"""Fill in saved keys / provider / models for anything the environment and project .env left unset."""
|
|
130
|
+
for var, value in saved_keys().items():
|
|
131
|
+
if not os.environ.get(var):
|
|
132
|
+
os.environ[var] = value
|
|
133
|
+
s = settings()
|
|
134
|
+
provider = s.get("provider")
|
|
135
|
+
if provider and not os.environ.get("VISUALAGENTS_PROVIDER"):
|
|
136
|
+
os.environ["VISUALAGENTS_PROVIDER"] = provider
|
|
137
|
+
models = s.get("models", {})
|
|
138
|
+
if provider and provider in models and not os.environ.get("VISUALAGENTS_MODEL"):
|
|
139
|
+
os.environ["VISUALAGENTS_MODEL"] = models[provider]
|
visualagents/hooks.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Install / remove the startup hook that lets a project's Python processes report to the dashboard.
|
|
2
|
+
|
|
3
|
+
`visualagents hook <folder>` writes `visualagents_hook.pth` into the site-packages of each virtualenv in
|
|
4
|
+
the folder (or an interpreter you name). Python runs .pth "import" lines at startup, so every process from
|
|
5
|
+
that environment - started by a script, an IDE, a notebook kernel - loads VisualAgents' runtime, which stays
|
|
6
|
+
silent until the process makes an LLM / agent call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
PTH_NAME = "visualagents_hook.pth"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def boot_path() -> Path:
|
|
19
|
+
return Path(str(resources.files("visualagents") / "_hook" / "visualagents_boot.py")).resolve()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def site_packages(python: Path) -> Path:
|
|
23
|
+
out = subprocess.run(
|
|
24
|
+
[str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"],
|
|
25
|
+
capture_output=True, text=True, timeout=30, check=True,
|
|
26
|
+
)
|
|
27
|
+
return Path(out.stdout.strip())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pth_line(project: Path) -> str:
|
|
31
|
+
boot = str(boot_path())
|
|
32
|
+
return (
|
|
33
|
+
f"import os; _va = {boot!r}; os.path.exists(_va) and exec(compile(open(_va, encoding='utf-8').read(), _va, "
|
|
34
|
+
f"'exec'), {{'__name__': 'visualagents_boot', '__file__': _va, 'VISUALAGENTS_PROJECT': {str(project)!r}}})"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def install(python: Path, project: Path) -> Path:
|
|
39
|
+
site = site_packages(python)
|
|
40
|
+
site.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
target = site / PTH_NAME
|
|
42
|
+
target.write_text(
|
|
43
|
+
f"# VisualAgents hook for {project} - remove with: visualagents unhook {project}\n{pth_line(project)}\n",
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
)
|
|
46
|
+
return target
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def uninstall(site: Path) -> bool:
|
|
50
|
+
target = Path(site) / PTH_NAME
|
|
51
|
+
if target.exists():
|
|
52
|
+
target.unlink()
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hooked_project(site: Path) -> str | None:
|
|
58
|
+
"""Which project an installed hook points at (None when not hooked)."""
|
|
59
|
+
try:
|
|
60
|
+
first = (Path(site) / PTH_NAME).read_text(encoding="utf-8").splitlines()[0]
|
|
61
|
+
except (OSError, IndexError):
|
|
62
|
+
return None
|
|
63
|
+
return first.split("hook for ", 1)[-1].split(" - remove", 1)[0] if "hook for " in first else ""
|
visualagents/hub.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Thread-safe fan-out between inputs (process output, SDK events, LLM narration) and UI clients.
|
|
2
|
+
|
|
3
|
+
Raw inputs are also recorded to `.visualagents/runs/<timestamp>.jsonl` so any run can be replayed
|
|
4
|
+
later - with a newer blueprint or smarter patterns - via `visualagents replay`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import deque
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .tracker import Tracker
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Hub:
|
|
21
|
+
def __init__(self, blueprint: dict[str, Any], *, record_dir: Path | None = None, meta: dict[str, Any] | None = None):
|
|
22
|
+
self.blueprint = blueprint
|
|
23
|
+
self.tracker = Tracker(blueprint)
|
|
24
|
+
self.meta = meta or {}
|
|
25
|
+
self.token = secrets.token_urlsafe(16)
|
|
26
|
+
self.cond = threading.Condition()
|
|
27
|
+
self.events: deque[dict[str, Any]] = deque(maxlen=20000)
|
|
28
|
+
self.state_version = 0
|
|
29
|
+
self.recent_lines: deque[str] = deque(maxlen=60)
|
|
30
|
+
self.procs: set[int] = set() # hooked processes currently reporting (daemon mode)
|
|
31
|
+
self.launcher = None # set when the dashboard may start the project (launcher.Launcher)
|
|
32
|
+
self.lines_seen = 0
|
|
33
|
+
self.started_at = time.time()
|
|
34
|
+
self._record = None
|
|
35
|
+
self.record_path: Path | None = None
|
|
36
|
+
if record_dir is not None:
|
|
37
|
+
record_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self.record_path = record_dir / time.strftime("%Y%m%d-%H%M%S.jsonl")
|
|
39
|
+
self._record = open(self.record_path, "a", encoding="utf-8")
|
|
40
|
+
self._write_record({"type": "meta", "blueprint": blueprint, "meta": self.meta})
|
|
41
|
+
|
|
42
|
+
# -- inputs --------------------------------------------------------------
|
|
43
|
+
def feed_line(self, line: str, stream: str = "stdout") -> None:
|
|
44
|
+
with self.cond:
|
|
45
|
+
self._write_record({"type": "line", "stream": stream, "text": line})
|
|
46
|
+
self.lines_seen += 1
|
|
47
|
+
self.recent_lines.append(line)
|
|
48
|
+
self._publish(self.tracker.ingest_line(line, stream))
|
|
49
|
+
|
|
50
|
+
def feed_structured(self, payload: dict[str, Any]) -> None:
|
|
51
|
+
typ = payload.get("type")
|
|
52
|
+
with self.cond:
|
|
53
|
+
if typ == "process_start":
|
|
54
|
+
pid = int(payload.get("pid") or 0)
|
|
55
|
+
if not self.procs and self.tracker.run["status"] != "running":
|
|
56
|
+
self._write_record({"type": "run_start", "command": payload.get("text", "")})
|
|
57
|
+
self._publish(self.tracker.run_started(str(payload.get("text") or "")))
|
|
58
|
+
self.procs.add(pid)
|
|
59
|
+
self._publish([self.tracker._event("status", None, f"Process {pid} attached: {payload.get('text', '')}",
|
|
60
|
+
source="system")])
|
|
61
|
+
return
|
|
62
|
+
if typ == "process_end":
|
|
63
|
+
pid = int(payload.get("pid") or 0)
|
|
64
|
+
if pid not in self.procs:
|
|
65
|
+
return
|
|
66
|
+
self.procs.discard(pid)
|
|
67
|
+
if not self.procs:
|
|
68
|
+
self._write_record({"type": "run_end", "code": payload.get("code", 0)})
|
|
69
|
+
self._publish(self.tracker.run_finished(payload.get("code", 0)))
|
|
70
|
+
return
|
|
71
|
+
self._write_record({"type": "structured", "payload": payload})
|
|
72
|
+
self._publish(self.tracker.ingest_structured(payload))
|
|
73
|
+
|
|
74
|
+
def narrate(self, agent: str | None, activity: str) -> None:
|
|
75
|
+
with self.cond:
|
|
76
|
+
self._write_record({"type": "narration", "agent": agent, "activity": activity})
|
|
77
|
+
self._publish(self.tracker.apply_narration(agent, activity))
|
|
78
|
+
|
|
79
|
+
def run_started(self, command: str = "") -> None:
|
|
80
|
+
with self.cond:
|
|
81
|
+
self._write_record({"type": "run_start", "command": command})
|
|
82
|
+
self._publish(self.tracker.run_started(command))
|
|
83
|
+
|
|
84
|
+
def run_finished(self, code: int | None) -> None:
|
|
85
|
+
with self.cond:
|
|
86
|
+
self._write_record({"type": "run_end", "code": code})
|
|
87
|
+
self._publish(self.tracker.run_finished(code))
|
|
88
|
+
|
|
89
|
+
def system(self, text: str) -> None:
|
|
90
|
+
with self.cond:
|
|
91
|
+
self._publish([self.tracker._event("status", None, text, source="system")])
|
|
92
|
+
|
|
93
|
+
def reset_view(self) -> None:
|
|
94
|
+
with self.cond:
|
|
95
|
+
run = dict(self.tracker.run)
|
|
96
|
+
self.tracker.reset()
|
|
97
|
+
self.tracker.run.update(run)
|
|
98
|
+
self._publish([self.tracker._event("status", None, "View reset", source="system")])
|
|
99
|
+
|
|
100
|
+
# -- outputs -------------------------------------------------------------
|
|
101
|
+
def _publish(self, events: list[dict[str, Any]]) -> None:
|
|
102
|
+
for ev in events:
|
|
103
|
+
self.events.append(ev)
|
|
104
|
+
self.state_version += 1
|
|
105
|
+
self.cond.notify_all()
|
|
106
|
+
|
|
107
|
+
def snapshot(self, max_events: int = 6000) -> dict[str, Any]:
|
|
108
|
+
with self.cond:
|
|
109
|
+
return {
|
|
110
|
+
"blueprint": self.blueprint,
|
|
111
|
+
"state": self.tracker.snapshot(),
|
|
112
|
+
"events": list(self.events)[-max_events:],
|
|
113
|
+
"meta": {**self.meta, "lines": self.lines_seen, "record": str(self.record_path or ""),
|
|
114
|
+
"launch": self.launcher.info() if self.launcher else None},
|
|
115
|
+
"version": self.state_version,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def wait(self, last_seq: int, last_version: int, timeout: float = 15.0):
|
|
119
|
+
"""Block until something newer than (last_seq, last_version) exists. Returns (events, state, version)."""
|
|
120
|
+
with self.cond:
|
|
121
|
+
if self.state_version == last_version:
|
|
122
|
+
self.cond.wait(timeout)
|
|
123
|
+
events = [e for e in self.events if e["seq"] > last_seq] if self.events and self.events[-1]["seq"] > last_seq else []
|
|
124
|
+
state = self.tracker.snapshot() if self.state_version != last_version else None
|
|
125
|
+
return events, state, self.state_version
|
|
126
|
+
|
|
127
|
+
def _write_record(self, obj: dict[str, Any]) -> None:
|
|
128
|
+
if self._record is None:
|
|
129
|
+
return
|
|
130
|
+
obj["t"] = round(time.time() - self.started_at, 3)
|
|
131
|
+
try:
|
|
132
|
+
self._record.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
133
|
+
self._record.flush()
|
|
134
|
+
except (OSError, ValueError):
|
|
135
|
+
self._record = None
|
|
136
|
+
|
|
137
|
+
def close(self) -> None:
|
|
138
|
+
if self._record is not None:
|
|
139
|
+
try:
|
|
140
|
+
self._record.close()
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
self._record = None
|