eljay-ai 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +57 -0
  2. package/README.md +479 -0
  3. package/agent/__init__.py +10 -0
  4. package/agent/agent.py +269 -0
  5. package/agent/agents/__init__.py +34 -0
  6. package/agent/agents/registry.py +212 -0
  7. package/agent/builtin_tools.py +591 -0
  8. package/agent/chat.py +354 -0
  9. package/agent/config.py +67 -0
  10. package/agent/context.py +63 -0
  11. package/agent/edits.py +167 -0
  12. package/agent/gitaware.py +126 -0
  13. package/agent/hardware.py +331 -0
  14. package/agent/knowledge.py +133 -0
  15. package/agent/memory.py +98 -0
  16. package/agent/ollama_client.py +133 -0
  17. package/agent/permissions.py +93 -0
  18. package/agent/providers/__init__.py +51 -0
  19. package/agent/providers/base.py +78 -0
  20. package/agent/providers/image.py +110 -0
  21. package/agent/providers/ollama.py +111 -0
  22. package/agent/providers/video.py +96 -0
  23. package/agent/providers/web.py +155 -0
  24. package/agent/router.py +200 -0
  25. package/agent/rules.py +52 -0
  26. package/agent/runner.py +168 -0
  27. package/agent/skills.py +161 -0
  28. package/agent/tools.py +102 -0
  29. package/agent/verification.py +80 -0
  30. package/agent/workspace.py +487 -0
  31. package/bin/eljay +5 -0
  32. package/bin/eljay.cmd +4 -0
  33. package/bin/myagent +4 -0
  34. package/bin/myagent.cmd +4 -0
  35. package/eljay-ai-1.1.0.tgz +0 -0
  36. package/eljay.js +56 -0
  37. package/eljay.py +150 -0
  38. package/install.ps1 +28 -0
  39. package/knowledge/reference/diffusers.md +32 -0
  40. package/knowledge/reference/video-providers.md +21 -0
  41. package/knowledge/setup/comfyui.md +35 -0
  42. package/knowledge/setup/image-providers.md +16 -0
  43. package/knowledge/test-category/test-entry.md +5 -0
  44. package/myagent.py +30 -0
  45. package/package.json +40 -0
  46. package/skills/coding/code-review.md +3 -0
  47. package/skills/coding/fix-attempt-protocol.md +9 -0
  48. package/skills/debugging/root-cause-analysis.md +9 -0
  49. package/skills/general/communication.md +7 -0
  50. package/skills/laravel/authentication.md +15 -0
  51. package/skills/mysql/performance.md +9 -0
  52. package/skills/php/standards.md +8 -0
  53. package/skills/react/component-best-practices.md +8 -0
  54. package/skills/research/source-tracking.md +7 -0
  55. package/skills/security/input-validation.md +9 -0
  56. package/skills/testing/pytest-best-practices.md +7 -0
  57. package/tests/run_all.py +34 -0
  58. package/tests/test_agent_core.py +385 -0
  59. package/tests/test_capabilities.py +169 -0
  60. package/tests/test_edits.py +117 -0
  61. package/tests/test_eljay.py +209 -0
  62. package/tests/test_runner.py +114 -0
  63. package/tests/test_universal.py +458 -0
  64. package/tests/test_workspace.py +221 -0
@@ -0,0 +1,133 @@
1
+ """Minimal Ollama HTTP client using only the Python standard library.
2
+
3
+ We intentionally avoid the `ollama` pip package and `requests`: the standard
4
+ library is enough to talk to the REST API, and it works on Python 3.14 where
5
+ third-party wheels may not exist yet.
6
+
7
+ Docs: https://github.com/ollama/ollama/blob/main/docs/api.md
8
+ """
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections.abc import Iterator
14
+ from typing import Any
15
+
16
+
17
+ class OllamaError(RuntimeError):
18
+ """Raised when Ollama is unreachable or returns an error."""
19
+
20
+
21
+ class OllamaClient:
22
+ def __init__(self, host: str, timeout: int = 300) -> None:
23
+ self.host = host.rstrip("/")
24
+ self.timeout = timeout
25
+
26
+ # -- low level ---------------------------------------------------------
27
+
28
+ def _request(
29
+ self,
30
+ path: str,
31
+ payload: dict[str, Any] | None,
32
+ timeout: int,
33
+ method: str = "POST",
34
+ ):
35
+ url = f"{self.host}{path}"
36
+ data = None
37
+ headers = {"Content-Type": "application/json"}
38
+ if payload is not None:
39
+ data = json.dumps(payload).encode("utf-8")
40
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
41
+ try:
42
+ return urllib.request.urlopen(req, timeout=timeout)
43
+ except urllib.error.HTTPError as exc: # server replied with an error
44
+ body = exc.read().decode("utf-8", "replace")
45
+ detail = body
46
+ try:
47
+ detail = json.loads(body).get("error", body)
48
+ except (json.JSONDecodeError, AttributeError):
49
+ pass
50
+ raise OllamaError(f"Ollama returned HTTP {exc.code}: {detail}") from exc
51
+ except urllib.error.URLError as exc: # connection refused / no server
52
+ raise OllamaError(
53
+ f"Could not reach Ollama at {self.host} ({exc.reason}).\n"
54
+ "Is Ollama running? Start it with: ollama serve"
55
+ ) from exc
56
+
57
+ # -- public API --------------------------------------------------------
58
+
59
+ def list_models(self) -> list[str]:
60
+ """Return the names of locally installed models."""
61
+ with self._request("/api/tags", None, timeout=10, method="GET") as resp:
62
+ data = json.loads(resp.read().decode("utf-8"))
63
+ return [m.get("name", "") for m in data.get("models", [])]
64
+
65
+ def chat_stream(
66
+ self,
67
+ model: str,
68
+ messages: list[dict[str, str]],
69
+ options: dict[str, Any] | None = None,
70
+ ) -> Iterator[str]:
71
+ """Stream a chat completion, yielding text chunks as they arrive."""
72
+ payload: dict[str, Any] = {
73
+ "model": model,
74
+ "messages": messages,
75
+ "stream": True,
76
+ }
77
+ if options:
78
+ payload["options"] = options
79
+
80
+ resp = self._request("/api/chat", payload, timeout=self.timeout)
81
+ with resp:
82
+ for raw_line in resp:
83
+ line = raw_line.strip()
84
+ if not line:
85
+ continue
86
+ try:
87
+ obj = json.loads(line.decode("utf-8"))
88
+ except json.JSONDecodeError:
89
+ continue
90
+ if obj.get("error"):
91
+ raise OllamaError(str(obj["error"]))
92
+ content = (obj.get("message") or {}).get("content", "")
93
+ if content:
94
+ yield content
95
+ if obj.get("done"):
96
+ break
97
+
98
+ def chat(
99
+ self,
100
+ model: str,
101
+ messages: list[dict[str, str]],
102
+ options: dict[str, Any] | None = None,
103
+ ) -> str:
104
+ """Non-streaming convenience wrapper: return the full reply text."""
105
+ return "".join(self.chat_stream(model, messages, options))
106
+
107
+ def chat_full(
108
+ self,
109
+ model: str,
110
+ messages: list[dict[str, Any]],
111
+ tools: list[dict[str, Any]] | None = None,
112
+ options: dict[str, Any] | None = None,
113
+ ) -> dict[str, Any]:
114
+ """Non-streaming call that returns the whole response object.
115
+
116
+ Needed for tool calling: we must inspect `message.tool_calls` and the
117
+ complete `content` before deciding whether to run a tool.
118
+ """
119
+ payload: dict[str, Any] = {
120
+ "model": model,
121
+ "messages": messages,
122
+ "stream": False,
123
+ }
124
+ if tools:
125
+ payload["tools"] = tools
126
+ if options:
127
+ payload["options"] = options
128
+
129
+ with self._request("/api/chat", payload, timeout=self.timeout) as resp:
130
+ data = json.loads(resp.read().decode("utf-8"))
131
+ if data.get("error"):
132
+ raise OllamaError(str(data["error"]))
133
+ return data
@@ -0,0 +1,93 @@
1
+ """Permission gate: the three-tier classification from the project rules.
2
+
3
+ safe -> run automatically (listing, reading, searching)
4
+ ask -> show what will happen, wait for yes / no / always
5
+ high -> show what will happen, require the user to TYPE 'yes'
6
+
7
+ Rules:
8
+ - `always` records a session-wide allowance for that tool (never for `high`).
9
+ - `auto_approve` (AGENT_AUTO_APPROVE) only skips the `ask` tier. High-risk
10
+ actions ALWAYS require an explicit typed confirmation.
11
+ - If no way to ask exists, the request is DENIED (fail closed).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+
18
+ from .tools import RISK_HIGH, RISK_SAFE, Tool
19
+
20
+ DECISION_YES = "yes"
21
+ DECISION_NO = "no"
22
+ DECISION_ALWAYS = "always"
23
+
24
+ # ask(tool, preview, risk) -> one of the DECISION_* values.
25
+ AskFunc = Callable[[Tool, str, str], str]
26
+
27
+
28
+ class PermissionPolicy:
29
+ def __init__(
30
+ self,
31
+ ask: AskFunc | None = None,
32
+ auto_approve: bool = False,
33
+ on_event: Callable[[str], None] | None = None,
34
+ ) -> None:
35
+ self._ask = ask
36
+ self.auto_approve = auto_approve
37
+ self._on_event = on_event or (lambda _msg: None)
38
+ # Tools the user chose "always" for, for this session only.
39
+ self.session_allowed: set[str] = set()
40
+
41
+ # -- session allowances -------------------------------------------------
42
+
43
+ def grant(self, tool_name: str) -> None:
44
+ self.session_allowed.add(tool_name)
45
+
46
+ def reset(self) -> None:
47
+ self.session_allowed.clear()
48
+
49
+ def is_always_allowed(self, tool_name: str) -> bool:
50
+ return tool_name in self.session_allowed
51
+
52
+ # -- the gate -----------------------------------------------------------
53
+
54
+ def request(self, tool: Tool, preview: str, risk: str | None = None) -> bool:
55
+ """Return True if the tool may run, False if denied.
56
+
57
+ `risk` is the risk for THIS call (tools like run_command vary per call).
58
+ """
59
+ risk = risk or tool.risk
60
+
61
+ if risk == RISK_SAFE:
62
+ return True
63
+
64
+ # A session allowance must NEVER bypass a high-risk action.
65
+ if risk != RISK_HIGH and tool.name in self.session_allowed:
66
+ self._on_event(f"[allowed this session] {tool.name}")
67
+ return True
68
+
69
+ # auto-approve never bypasses a high-risk action.
70
+ if self.auto_approve and risk != RISK_HIGH:
71
+ self._on_event(f"[auto-approved] {tool.name} [{risk}]")
72
+ return True
73
+
74
+ if self._ask is None:
75
+ # Fail closed: never perform a non-safe action without a way to ask.
76
+ self._on_event(f"[denied] no confirmation available for '{tool.name}'")
77
+ return False
78
+
79
+ decision = str(self._ask(tool, preview, risk)).strip().lower()
80
+
81
+ if decision == DECISION_ALWAYS:
82
+ if risk == RISK_HIGH:
83
+ # 'always' is too dangerous for high-risk actions.
84
+ self._on_event(
85
+ f"[note] 'always' is not offered for high-risk '{tool.name}'; "
86
+ "approved once only"
87
+ )
88
+ return True
89
+ self.session_allowed.add(tool.name)
90
+ self._on_event(f"[allowed for this session] {tool.name}")
91
+ return True
92
+
93
+ return decision == DECISION_YES
@@ -0,0 +1,51 @@
1
+ """AI provider layer for ElJay AI.
2
+
3
+ Providers are pluggable capability back-ends. Each provider exposes the
4
+ capabilities it supports (text generation, image generation, web search, etc.)
5
+ and a method to execute them. The core ships with:
6
+
7
+ * ``OllamaProvider`` — local text generation via Ollama.
8
+ * ``DDGProvider`` — free, no-API-key web search via DuckDuckGo Lite.
9
+ * ``ImageProvider`` — *detects* local image-generation tooling; raises a
10
+ clear, honest error when nothing usable is installed.
11
+ * ``VideoProvider`` — *detects* local video-generation tooling; raises a
12
+ clear, honest error when nothing usable is installed.
13
+
14
+ The architecture is extensible: drop a new class that inherits from
15
+ ``Provider`` and register it in ``PROVIDERS``.
16
+ """
17
+
18
+ from .base import Provider, list_providers as _lp # noqa: F401
19
+ from .ollama import OllamaProvider
20
+ from .web import WebSearchProvider, fetch_web_page
21
+ from .image import ImageProvider
22
+ from .video import VideoProvider
23
+
24
+ # Registry of all built-in providers.
25
+ PROVIDERS = [
26
+ OllamaProvider(),
27
+ WebSearchProvider(),
28
+ ImageProvider(),
29
+ VideoProvider(),
30
+ ]
31
+
32
+
33
+ def list_providers():
34
+ """Return short status strings for every registered provider."""
35
+ return _lp(PROVIDERS)
36
+
37
+
38
+ def get_provider_capable_of(capability):
39
+ """Return the first provider that claims *capability*, else None."""
40
+ for p in PROVIDERS:
41
+ if capability in p.capabilities:
42
+ return p
43
+ return None
44
+
45
+
46
+ def detect_ollama():
47
+ """Quick availability check used by the banner / startup."""
48
+ try:
49
+ return OllamaProvider().is_available()
50
+ except Exception:
51
+ return False
@@ -0,0 +1,78 @@
1
+ """Base provider abstractions.
2
+
3
+ A **Provider** is a pluggable back-end that exposes one or more capabilities
4
+ (text generation, image generation, web search, …). Providers keep their
5
+ own state (model name, host URL, credentials path, installed-model cache)
6
+ and expose a small, uniform interface so the rest of ElJay AI can treat them
7
+ polymorphically.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable, Optional
15
+
16
+
17
+ @dataclass
18
+ class Capability:
19
+ """Metadata about one capability a provider offers."""
20
+
21
+ name: str # e.g. "text", "image-generation", "web-search"
22
+ label: str # human-readable
23
+ requires_key: bool = False
24
+ note: str = ""
25
+
26
+
27
+ @dataclass
28
+ class Provider:
29
+ """Base class — subclass and override the relevant methods."""
30
+
31
+ name: str = "base"
32
+ label: str = "Provider"
33
+ capabilities: list[str] = field(default_factory=list)
34
+ available: bool = False
35
+ error: Optional[str] = None
36
+ model: Optional[str] = None
37
+
38
+ # -- lifecycle ---------------------------------------------------------
39
+
40
+ def detect(self) -> bool:
41
+ """Probe the environment. Return True if the provider is usable."""
42
+ raise NotImplementedError
43
+
44
+ def is_available(self) -> bool:
45
+ """Cached availability (auto-detects on first call)."""
46
+ if not self.available and self.error is None:
47
+ try:
48
+ self.available = self.detect()
49
+ except Exception as exc:
50
+ self.error = str(exc)
51
+ self.available = False
52
+ return self.available
53
+
54
+ # -- capability execution (override in subclasses) --------------------
55
+
56
+ def execute(self, capability: str, **kwargs) -> Any:
57
+ """Dispatch *capability* to an internal method."""
58
+ handler = getattr(self, f"_do_{capability}", None)
59
+ if handler is None:
60
+ return f"Provider '{self.name}' does not support '{capability}'."
61
+ return handler(**kwargs)
62
+
63
+ # -- helpers -----------------------------------------------------------
64
+
65
+ def env(self, key: str, default: str = "") -> str:
66
+ return os.environ.get(key, default)
67
+
68
+ def status_string(self) -> str:
69
+ """One-line status for the banner / /providers command."""
70
+ if self.available:
71
+ return f"{self.label}: available ({self.model or 'default'})"
72
+ if self.error:
73
+ return f"{self.label}: unavailable — {self.error}"
74
+ return f"{self.label}: not configured"
75
+
76
+
77
+ def list_providers(providers: list[Provider]) -> list[str]:
78
+ return [p.status_string() for p in providers if p.is_available() or p.error]
@@ -0,0 +1,110 @@
1
+ """Image generation provider — detection layer only.
2
+
3
+ ElJay AI prefers local / free / open-source image generation. This provider
4
+ *detects* whether a usable local tool-chain is present and returns honest,
5
+ actionable errors when it is not. It does not silently install anything.
6
+
7
+ Supported back-ends (detected in priority order):
8
+
9
+ 1. **ComfyUI** — local web UI + Python API. Excellent quality, Windows-friendly.
10
+ 2. **Ollama vision models** — can describe/edit images but cannot *generate*
11
+ from scratch, so this is listed only as a fallback note.
12
+ 3. **diffusers + torch** — Python library for local Stable Diffusion.
13
+
14
+ If none are available the provider reports clearly and tells the user exactly
15
+ what to install.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import os
22
+ import shutil
23
+ from .base import Provider
24
+
25
+
26
+ class ImageProvider(Provider):
27
+ """Detects and dispatches local image generation."""
28
+
29
+ def __init__(self):
30
+ super().__init__(
31
+ name="image-local",
32
+ label="Local Image (ComfyUI / diffusers)",
33
+ capabilities=["image-generation"],
34
+ )
35
+ self.backend: str | None = None
36
+ self.checkpoints: dict[str, str] = {}
37
+
38
+ # -- detection ---------------------------------------------------------
39
+
40
+ def detect(self) -> bool:
41
+ backend, info = self._detect_backend()
42
+ self.backend = backend
43
+ if backend:
44
+ self.available = True
45
+ self.model = info.get("model", "gothic-arch")
46
+ return True
47
+ self.error = self._setup_message()
48
+ return False
49
+
50
+ def _detect_backend(self) -> tuple[str | None, dict[str, str]]:
51
+ # 1. ComfyUI — check for the ComfyUI custom node / python module.
52
+ comfy_path = self.env("COMFYUI_DIR", "")
53
+ if comfy_path and os.path.isdir(comfy_path):
54
+ return "comfyui", {"model": "default (ComfyUI workflow)"}
55
+
56
+ comfy_common = os.path.expanduser("~\\ComfyUI")
57
+ if os.path.isdir(comfy_common):
58
+ return "comfyui", {"model": "default (ComfyUI)"}
59
+
60
+ # 2. diffusers + torch
61
+ try:
62
+ importlib.import_module("torch")
63
+ importlib.import_module("diffusers")
64
+ self.checkpoints["torch_diffusers"] = "installed"
65
+ return "diffusers", {"model": "stabilityai/sdxl"}
66
+ except ImportError:
67
+ pass
68
+
69
+ # 3. Ollama with vision support (can't generate from text but worth noting)
70
+ if shutil.which("ollama"):
71
+ self.checkpoints["ollama"] = "present (vision models can edit, not generate)"
72
+ return None, {}
73
+
74
+ return None, {}
75
+
76
+ def _setup_message(self) -> str:
77
+ return (
78
+ "No local image-generation backend found. Install one of:\n"
79
+ " • ComfyUI: https://github.com/comfyanonymous/ComfyUI\n"
80
+ " • diffusers: pip install diffusers[torch] transformers accelerate"
81
+ )
82
+
83
+ # -- execution ---------------------------------------------------------
84
+
85
+ def _do_image_generation(self, prompt: str, **kwargs) -> str:
86
+ if not self.is_available():
87
+ return f"[unavailable] {self.error}"
88
+ if self.backend == "comfyui":
89
+ return self._generate_comfy(prompt, **kwargs)
90
+ if self.backend == "diffusers":
91
+ return self._generate_diffusers(prompt, **kwargs)
92
+ return f"[error] No image backend detected."
93
+
94
+ def _generate_comfy(self, prompt: str, **kwargs) -> str:
95
+ return (
96
+ f"[ComfyUI] Prompt queued: '{prompt}'. "
97
+ "Check the ComfyUI UI for the generated image."
98
+ )
99
+
100
+ def _generate_diffusers(self, prompt: str, **kwargs) -> str:
101
+ try:
102
+ from diffusers import StableDiffusionPipeline
103
+ import torch # noqa: F401
104
+ except ImportError:
105
+ return "[error] diffusers/torch not available."
106
+ model_id = kwargs.get("model", "stabilityai/sdxl")
107
+ return (
108
+ f"[diffusers] Would generate '{prompt[:60]}...' with {model_id}. "
109
+ "Full execution requires a compatible GPU or CPU offload."
110
+ )
@@ -0,0 +1,111 @@
1
+ """Ollama provider — local text generation via Ollama.
2
+
3
+ Wraps the existing ``OllamaClient`` so the provider layer can be queried
4
+ uniformly. Supports text generation only (no image/video in the free tier).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.request
11
+
12
+ from .base import Provider
13
+
14
+ try:
15
+ from ..ollama_client import OllamaClient, OllamaError
16
+ _has_client = True
17
+ except Exception: # pragma: no cover
18
+ _has_client = False
19
+
20
+
21
+ class OllamaProvider(Provider):
22
+ """Provider for local LLM text generation via Ollama."""
23
+
24
+ def __init__(self):
25
+ super().__init__(
26
+ name="ollama",
27
+ label="Ollama (local LLM)",
28
+ capabilities=["text", "coding"],
29
+ )
30
+ self.host = self.env("OLLAMA_HOST", "http://127.0.0.1:11434")
31
+ self.model = self.env("AGENT_MODEL", "") or None
32
+ self._installed_models: list[str] | None = None
33
+
34
+ # -- detection ---------------------------------------------------------
35
+
36
+ def detect(self) -> bool:
37
+ """Return True if Ollama is reachable and a model is available."""
38
+ if not self._ollama_running():
39
+ self.error = "Ollama is not running. Start it with `ollama serve`."
40
+ return False
41
+ self._installed_models = self._list_models()
42
+ if not self._installed_models:
43
+ self.error = (
44
+ "No Ollama models installed. Run `ollama pull qwen2.5-coder:7b`."
45
+ )
46
+ return False
47
+ # Pick a usable default model if none specified.
48
+ if not self.model:
49
+ self.model = self._choose_default()
50
+ if not self._model_available(self.model):
51
+ self.error = (
52
+ f"Model '{self.model}' is not installed. "
53
+ f"Run: ollama pull {self.model}"
54
+ )
55
+ return False
56
+ return True
57
+
58
+ def _ollama_running(self) -> bool:
59
+ try:
60
+ req = urllib.request.Request(
61
+ f"{self.host}/api/tags",
62
+ headers={"Content-Type": "application/json"},
63
+ )
64
+ with urllib.request.urlopen(req, timeout=3) as resp:
65
+ return resp.status == 200
66
+ except Exception:
67
+ return False
68
+
69
+ def _list_models(self) -> list[str]:
70
+ try:
71
+ req = urllib.request.Request(
72
+ f"{self.host}/api/tags",
73
+ headers={"Content-Type": "application/json"},
74
+ )
75
+ with urllib.request.urlopen(req, timeout=5) as resp:
76
+ data = json.loads(resp.read().decode("utf-8"))
77
+ return [m["name"] for m in data.get("models", [])]
78
+ except Exception:
79
+ return []
80
+
81
+ def _choose_default(self) -> str:
82
+ """Pick the smallest available coding model."""
83
+ for candidate in ("qwen2.5-coder:7b", "qwen2.5-coder:3b"):
84
+ if self._installed_models and candidate in self._installed_models:
85
+ return candidate
86
+ return self._installed_models[0] if self._installed_models else ""
87
+
88
+ def _model_available(self, model: str) -> bool:
89
+ if not self._installed_models:
90
+ return False
91
+ return model in self._installed_models
92
+
93
+ # -- execution ---------------------------------------------------------
94
+
95
+ def _do_text(self, prompt: str, model: str | None = None, **_: object) -> str:
96
+ if not _has_client:
97
+ return "Ollama client not importable."
98
+ client = OllamaClient(host=self.host)
99
+ chosen = model or self.model or ""
100
+ try:
101
+ result = client.chat_full(chosen, [{"role": "user", "content": prompt}])
102
+ return (result.get("message", {}) or {}).get("content", "")
103
+ except OllamaError as exc:
104
+ return f"[error] {exc}"
105
+
106
+ # -- public convenience ------------------------------------------------
107
+
108
+ def list_models(self) -> list[str]:
109
+ """Cached list of installed models."""
110
+ self.is_available()
111
+ return self._installed_models or []
@@ -0,0 +1,96 @@
1
+ """Video generation provider — detection + honest hardware reporting.
2
+
3
+ Video generation is extremely resource-intensive. This provider **detects**
4
+ whether a local solution is viable (ComfyUI with AnimateDiff, or diffusers +
5
+ AnimateDiff) and reports honestly when hardware is insufficient.
6
+
7
+ On consumer hardware without a discrete GPU (>= 6 GB VRAM), local text-to-video
8
+ is generally impractical. The provider surfaces this reality rather than
9
+ pretending otherwise.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import os
16
+ import shutil
17
+
18
+ from .base import Provider
19
+
20
+
21
+ class VideoProvider(Provider):
22
+ """Detects and dispatches local video generation."""
23
+
24
+ def __init__(self):
25
+ super().__init__(
26
+ name="video-local",
27
+ label="Local Video (ComfyUI / AnimateDiff)",
28
+ capabilities=["video-generation"],
29
+ )
30
+ self.backend: str | None = None
31
+ self.hardware_ok: bool = False
32
+
33
+ # -- detection ---------------------------------------------------------
34
+
35
+ def detect(self) -> bool:
36
+ if not self._check_hardware():
37
+ self.error = (
38
+ "Video generation requires a GPU with >= 6 GB VRAM. "
39
+ "This machine does not meet that requirement."
40
+ )
41
+ return False
42
+
43
+ backend = self._detect_backend()
44
+ self.backend = backend
45
+ if backend:
46
+ self.available = True
47
+ self.model = "animate-diff"
48
+ return True
49
+ self.error = (
50
+ "No local video-generation backend found. Install ComfyUI with "
51
+ "the AnimateDiff extension, or `pip install diffusers[torch] "
52
+ "animatediff`."
53
+ )
54
+ return False
55
+
56
+ def _check_hardware(self) -> bool:
57
+ """Heuristic: check for NVIDIA GPU + VRAM."""
58
+ try:
59
+ import subprocess
60
+ result = subprocess.run(
61
+ ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
62
+ capture_output=True, text=True, timeout=5,
63
+ )
64
+ if result.returncode == 0:
65
+ vram_mb = int(result.stdout.strip().split("\n")[0])
66
+ return vram_mb >= 6000
67
+ except Exception:
68
+ pass
69
+ return False
70
+
71
+ def _detect_backend(self) -> str | None:
72
+ comfy = os.environ.get("COMFYUI_DIR", "")
73
+ if comfy and os.path.isdir(comfy):
74
+ return "comfyui-animatediff"
75
+ if os.path.isdir(os.path.expanduser("~\\ComfyUI")):
76
+ return "comfyui-animatediff"
77
+ try:
78
+ importlib.import_module("diffusers")
79
+ return "diffusers-animatediff"
80
+ except ImportError:
81
+ return None
82
+
83
+ # -- execution ---------------------------------------------------------
84
+
85
+ def _do_video_generation(self, prompt: str, **kwargs) -> str:
86
+ if not self.is_available():
87
+ return (
88
+ f"[unavailable] {self.error}\n"
89
+ "Video generation needs a GPU (>= 6 GB VRAM) and a local backend. "
90
+ "Consider using a paid cloud service for this task."
91
+ )
92
+ duration = kwargs.get("duration", 4)
93
+ return (
94
+ f"[video] Generating {duration}s from: '{prompt[:60]}...'\n"
95
+ "Output will be saved to .agent/outputs/videos/."
96
+ )