elidia-agent-cli 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.
Files changed (93) hide show
  1. elidia/__init__.py +0 -0
  2. elidia/__main__.py +4 -0
  3. elidia/agent/__init__.py +3 -0
  4. elidia/agent/loop.py +449 -0
  5. elidia/agent/personas.py +130 -0
  6. elidia/agent/portal.py +115 -0
  7. elidia/api/__init__.py +0 -0
  8. elidia/api/client.py +221 -0
  9. elidia/api/streaming.py +55 -0
  10. elidia/auth/__init__.py +0 -0
  11. elidia/auth/keychain.py +86 -0
  12. elidia/cache/__init__.py +1 -0
  13. elidia/cache/lru.py +158 -0
  14. elidia/cli/__init__.py +0 -0
  15. elidia/cli/commands.py +122 -0
  16. elidia/cli/main.py +293 -0
  17. elidia/cli/pager.py +104 -0
  18. elidia/cli/progress.py +173 -0
  19. elidia/cli/renderer.py +32 -0
  20. elidia/cli/repl.py +1234 -0
  21. elidia/cli/themes.py +214 -0
  22. elidia/config/__init__.py +0 -0
  23. elidia/config/defaults.py +5 -0
  24. elidia/config/rules.py +48 -0
  25. elidia/config/settings.py +217 -0
  26. elidia/creative/__init__.py +1 -0
  27. elidia/creative/audio.py +213 -0
  28. elidia/creative/display.py +260 -0
  29. elidia/creative/image.py +142 -0
  30. elidia/creative/local.py +163 -0
  31. elidia/creative/video.py +181 -0
  32. elidia/daemon/__init__.py +0 -0
  33. elidia/daemon/manager.py +349 -0
  34. elidia/db/__init__.py +0 -0
  35. elidia/db/database.py +87 -0
  36. elidia/mcp/__init__.py +0 -0
  37. elidia/mcp/client.py +198 -0
  38. elidia/mcp/config.py +93 -0
  39. elidia/mcp/registry.py +101 -0
  40. elidia/mcp/types.py +43 -0
  41. elidia/memory/__init__.py +3 -0
  42. elidia/memory/auto.py +164 -0
  43. elidia/memory/compaction.py +101 -0
  44. elidia/memory/embeddings.py +47 -0
  45. elidia/memory/outcomes.py +131 -0
  46. elidia/memory/patterns.py +79 -0
  47. elidia/memory/store.py +338 -0
  48. elidia/models/__init__.py +0 -0
  49. elidia/models/adaptive.py +53 -0
  50. elidia/models/router.py +115 -0
  51. elidia/modes/__init__.py +1 -0
  52. elidia/modes/autonomous.py +318 -0
  53. elidia/modes/budget.py +182 -0
  54. elidia/modes/classifier.py +169 -0
  55. elidia/modes/consensus.py +210 -0
  56. elidia/modes/deep_think.py +190 -0
  57. elidia/modes/swarm.py +229 -0
  58. elidia/modes/thinking.py +153 -0
  59. elidia/permissions/__init__.py +0 -0
  60. elidia/permissions/audit.py +138 -0
  61. elidia/permissions/manager.py +199 -0
  62. elidia/permissions/trust.py +63 -0
  63. elidia/rag/__init__.py +4 -0
  64. elidia/rag/chunker.py +216 -0
  65. elidia/rag/engine.py +345 -0
  66. elidia/rag/ingest.py +154 -0
  67. elidia/rag/portal_bridge.py +84 -0
  68. elidia/rag/watcher.py +98 -0
  69. elidia/research/__init__.py +1 -0
  70. elidia/research/export.py +237 -0
  71. elidia/research/orchestrator.py +310 -0
  72. elidia/research/sources.py +223 -0
  73. elidia/session/__init__.py +0 -0
  74. elidia/session/history.py +119 -0
  75. elidia/session/manager.py +82 -0
  76. elidia/tools/__init__.py +28 -0
  77. elidia/tools/base.py +70 -0
  78. elidia/tools/fetch.py +100 -0
  79. elidia/tools/filesystem.py +204 -0
  80. elidia/tools/git.py +156 -0
  81. elidia/tools/search.py +99 -0
  82. elidia/tools/terminal.py +77 -0
  83. elidia/widgets/__init__.py +1 -0
  84. elidia/widgets/protocol.py +248 -0
  85. elidia/widgets/renderer.py +225 -0
  86. elidia/workflow/__init__.py +1 -0
  87. elidia/workflow/engine.py +377 -0
  88. elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
  89. elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
  90. elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  91. elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
  93. elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,163 @@
1
+ """Local creative generation — runs models on the user's machine.
2
+
3
+ Uses diffusers for image generation (SD-turbo, SDXL-lightning) when
4
+ available. Falls back gracefully when dependencies are not installed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "local"
17
+
18
+ LOCAL_IMAGE_MODELS = {
19
+ "sd-turbo": {
20
+ "repo": "stabilityai/sd-turbo",
21
+ "steps": 4,
22
+ "guidance": 0.0,
23
+ "description": "Fast 4-step image generation (512x512)",
24
+ },
25
+ "sdxl-lightning-4step": {
26
+ "repo": "ByteDance/SDXL-Lightning",
27
+ "steps": 4,
28
+ "guidance": 0.0,
29
+ "description": "Fast SDXL variant (1024x1024)",
30
+ },
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class LocalImageResult:
36
+ file_path: str
37
+ model: str
38
+ prompt: str
39
+ width: int
40
+ height: int
41
+ steps: int
42
+ elapsed_ms: int = 0
43
+ size_bytes: int = 0
44
+
45
+
46
+ def check_local_available() -> dict[str, bool]:
47
+ logger.debug("Entered into check_local_available")
48
+ status: dict[str, bool] = {}
49
+
50
+ try:
51
+ import torch # noqa: F401
52
+ status["torch"] = True
53
+ except ImportError:
54
+ status["torch"] = False
55
+
56
+ try:
57
+ import diffusers # noqa: F401
58
+ status["diffusers"] = True
59
+ except ImportError:
60
+ status["diffusers"] = False
61
+
62
+ status["gpu"] = False
63
+ if status["torch"]:
64
+ try:
65
+ import torch
66
+ status["gpu"] = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
67
+ except Exception:
68
+ pass
69
+
70
+ status["ready"] = status["torch"] and status["diffusers"]
71
+ return status
72
+
73
+
74
+ async def generate_local_image(
75
+ prompt: str,
76
+ model: str = "sd-turbo",
77
+ width: int = 512,
78
+ height: int = 512,
79
+ output_dir: Path | None = None,
80
+ num_steps: int | None = None,
81
+ ) -> LocalImageResult:
82
+ logger.debug(f"Entered into generate_local_image: model={model}, prompt={prompt[:60]!r}")
83
+ start = time.monotonic()
84
+
85
+ avail = check_local_available()
86
+ if not avail["ready"]:
87
+ missing = []
88
+ if not avail["torch"]:
89
+ missing.append("torch")
90
+ if not avail["diffusers"]:
91
+ missing.append("diffusers")
92
+ raise RuntimeError(
93
+ f"Local generation requires: {', '.join(missing)}. "
94
+ f"Install with: pip install {' '.join(missing)}"
95
+ )
96
+
97
+ model_info = LOCAL_IMAGE_MODELS.get(model)
98
+ if not model_info:
99
+ raise RuntimeError(f"Unknown local model: {model}. Available: {list(LOCAL_IMAGE_MODELS.keys())}")
100
+
101
+ steps = num_steps or model_info["steps"]
102
+ guidance = model_info["guidance"]
103
+
104
+ save_dir = output_dir or DEFAULT_OUTPUT_DIR
105
+ save_dir.mkdir(parents=True, exist_ok=True)
106
+
107
+ import torch
108
+ from diffusers import AutoPipelineForText2Image
109
+
110
+ device = "cuda" if torch.cuda.is_available() else "mps" if (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()) else "cpu"
111
+ dtype = torch.float16 if device != "cpu" else torch.float32
112
+
113
+ logger.info(f"Loading {model_info['repo']} on {device} ({dtype})")
114
+ pipe = AutoPipelineForText2Image.from_pretrained(
115
+ model_info["repo"],
116
+ torch_dtype=dtype,
117
+ variant="fp16" if dtype == torch.float16 else None,
118
+ )
119
+ pipe = pipe.to(device)
120
+
121
+ image = pipe(
122
+ prompt=prompt,
123
+ num_inference_steps=steps,
124
+ guidance_scale=guidance,
125
+ width=width,
126
+ height=height,
127
+ ).images[0]
128
+
129
+ timestamp = int(time.time())
130
+ safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
131
+ filename = f"{timestamp}_local_{safe_prompt}.png"
132
+ file_path = save_dir / filename
133
+ image.save(str(file_path))
134
+
135
+ size_bytes = file_path.stat().st_size
136
+ elapsed = int((time.monotonic() - start) * 1000)
137
+ logger.info(f"Local image generated: {file_path} ({size_bytes} bytes, {elapsed}ms)")
138
+
139
+ return LocalImageResult(
140
+ file_path=str(file_path),
141
+ model=model,
142
+ prompt=prompt,
143
+ width=width,
144
+ height=height,
145
+ steps=steps,
146
+ elapsed_ms=elapsed,
147
+ size_bytes=size_bytes,
148
+ )
149
+
150
+
151
+ def list_local_models() -> list[dict[str, Any]]:
152
+ logger.debug("Entered into list_local_models")
153
+ avail = check_local_available()
154
+ return [
155
+ {
156
+ "model": name,
157
+ "repo": info["repo"],
158
+ "description": info["description"],
159
+ "steps": info["steps"],
160
+ "available": avail["ready"],
161
+ }
162
+ for name, info in LOCAL_IMAGE_MODELS.items()
163
+ ]
@@ -0,0 +1,181 @@
1
+ """Video generation via AiUtils Developer API.
2
+
3
+ Supports text-to-video and image-to-video through models like Kling and
4
+ Minimax. Video generation is asynchronous — submit a job, poll for completion.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import base64
10
+ import logging
11
+ import time
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from elidia.api.client import AiUtilsClient
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ VIDEO_MODELS = {
23
+ "kling-v2": {"vendor": "kling", "max_duration": 10, "supports_image": True},
24
+ "kling-v1.6": {"vendor": "kling", "max_duration": 5, "supports_image": True},
25
+ "minimax-video-01": {"vendor": "minimax", "max_duration": 6, "supports_image": False},
26
+ }
27
+
28
+ DEFAULT_MODEL = "kling-v2"
29
+ DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "videos"
30
+
31
+
32
+ @dataclass
33
+ class VideoResult:
34
+ file_path: str
35
+ model: str
36
+ prompt: str
37
+ duration: int
38
+ format: str = "mp4"
39
+ size_bytes: int = 0
40
+ elapsed_ms: int = 0
41
+ job_id: str = ""
42
+
43
+
44
+ async def generate_video(
45
+ client: AiUtilsClient,
46
+ prompt: str,
47
+ model: str = DEFAULT_MODEL,
48
+ duration: int = 5,
49
+ image_path: str | None = None,
50
+ output_dir: Path | None = None,
51
+ poll_interval: float = 5.0,
52
+ max_wait: float = 300.0,
53
+ ) -> VideoResult:
54
+ logger.debug(f"Entered into generate_video: model={model}, prompt={prompt[:60]!r}")
55
+ start = time.monotonic()
56
+
57
+ save_dir = output_dir or DEFAULT_OUTPUT_DIR
58
+ save_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ model_info = VIDEO_MODELS.get(model, VIDEO_MODELS[DEFAULT_MODEL])
61
+ duration = min(duration, model_info["max_duration"])
62
+
63
+ payload: dict[str, Any] = {
64
+ "model": model,
65
+ "prompt": prompt,
66
+ "duration": duration,
67
+ }
68
+
69
+ if image_path and model_info["supports_image"]:
70
+ img_bytes = Path(image_path).read_bytes()
71
+ payload["image"] = base64.b64encode(img_bytes).decode("ascii")
72
+
73
+ headers = {
74
+ "Authorization": f"Bearer {client._api_key}",
75
+ "Content-Type": "application/json",
76
+ }
77
+
78
+ async with httpx.AsyncClient(
79
+ base_url=client._base_url,
80
+ headers=headers,
81
+ timeout=httpx.Timeout(30.0, connect=10.0),
82
+ ) as http:
83
+ response = await http.post("/videos/generations", json=payload)
84
+
85
+ if response.status_code == 401:
86
+ raise RuntimeError("Invalid API key for video generation")
87
+ if response.status_code == 402:
88
+ raise RuntimeError("Insufficient balance for video generation")
89
+ response.raise_for_status()
90
+
91
+ data = response.json()
92
+ job_id = data.get("id", data.get("job_id", ""))
93
+
94
+ if not job_id:
95
+ video_url = data.get("url", "")
96
+ if video_url:
97
+ return await _download_and_save(
98
+ video_url, prompt, model, duration, save_dir, start, job_id
99
+ )
100
+ raise RuntimeError("No job ID or video URL returned")
101
+
102
+ logger.info(f"Video job submitted: {job_id}")
103
+
104
+ waited = 0.0
105
+ while waited < max_wait:
106
+ await asyncio.sleep(poll_interval)
107
+ waited += poll_interval
108
+
109
+ status_resp = await http.get(f"/videos/generations/{job_id}")
110
+ if status_resp.status_code >= 400:
111
+ logger.warning(f"Poll error {status_resp.status_code} for job {job_id}")
112
+ continue
113
+
114
+ status_data = status_resp.json()
115
+ state = status_data.get("status", "").lower()
116
+
117
+ if state in ("completed", "succeeded", "done"):
118
+ video_url = status_data.get("url", status_data.get("video_url", ""))
119
+ if not video_url:
120
+ results = status_data.get("data", status_data.get("results", []))
121
+ if results and isinstance(results, list):
122
+ video_url = results[0].get("url", "")
123
+
124
+ if video_url:
125
+ return await _download_and_save(
126
+ video_url, prompt, model, duration, save_dir, start, job_id
127
+ )
128
+ raise RuntimeError(f"Job {job_id} completed but no video URL found")
129
+
130
+ elif state in ("failed", "error"):
131
+ error = status_data.get("error", status_data.get("message", "unknown error"))
132
+ raise RuntimeError(f"Video generation failed: {error}")
133
+
134
+ logger.debug(f"Job {job_id} status: {state}, waited {waited:.0f}s")
135
+
136
+ raise RuntimeError(f"Video generation timed out after {max_wait}s (job: {job_id})")
137
+
138
+
139
+ async def _download_and_save(
140
+ url: str,
141
+ prompt: str,
142
+ model: str,
143
+ duration: int,
144
+ save_dir: Path,
145
+ start: float,
146
+ job_id: str,
147
+ ) -> VideoResult:
148
+ logger.debug(f"Entered into _download_and_save: url={url[:80]}")
149
+ async with httpx.AsyncClient(timeout=60.0) as dl:
150
+ vid_resp = await dl.get(url)
151
+ vid_resp.raise_for_status()
152
+ vid_bytes = vid_resp.content
153
+
154
+ timestamp = int(time.time())
155
+ safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
156
+ filename = f"{timestamp}_{safe_prompt}.mp4"
157
+ file_path = save_dir / filename
158
+ file_path.write_bytes(vid_bytes)
159
+
160
+ elapsed = int((time.monotonic() - start) * 1000)
161
+ logger.info(f"Video saved: {file_path} ({len(vid_bytes)} bytes, {elapsed}ms)")
162
+
163
+ return VideoResult(
164
+ file_path=str(file_path),
165
+ model=model,
166
+ prompt=prompt,
167
+ duration=duration,
168
+ format="mp4",
169
+ size_bytes=len(vid_bytes),
170
+ elapsed_ms=elapsed,
171
+ job_id=job_id,
172
+ )
173
+
174
+
175
+ def list_video_models() -> list[dict[str, Any]]:
176
+ logger.debug("Entered into list_video_models")
177
+ return [
178
+ {"model": name, "vendor": info["vendor"], "max_duration": info["max_duration"],
179
+ "supports_image": info["supports_image"]}
180
+ for name, info in VIDEO_MODELS.items()
181
+ ]
File without changes
@@ -0,0 +1,349 @@
1
+ """Daemon mode — background task manager with watchers, schedules, and webhooks.
2
+
3
+ Manages long-running background tasks:
4
+ - File watchers: monitor directories for changes
5
+ - Scheduled tasks: run at intervals or cron-like expressions
6
+ - Webhook listener: HTTP endpoint that triggers workflows
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import time
14
+ from collections.abc import Callable, Coroutine
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class DaemonTask:
24
+ id: str
25
+ name: str
26
+ type: str # "watcher", "schedule", "webhook"
27
+ config: dict[str, Any] = field(default_factory=dict)
28
+ status: str = "stopped" # "running", "stopped", "error"
29
+ last_run: float = 0.0
30
+ run_count: int = 0
31
+ error: str = ""
32
+
33
+
34
+ @dataclass
35
+ class DaemonEvent:
36
+ kind: str # "task_start", "task_stop", "task_trigger", "task_error", "status"
37
+ data: Any = None
38
+
39
+
40
+ class DaemonManager:
41
+ """Manages background daemon tasks."""
42
+
43
+ def __init__(self) -> None:
44
+ logger.debug("Entered into DaemonManager.__init__")
45
+ self._tasks: dict[str, DaemonTask] = {}
46
+ self._handles: dict[str, asyncio.Task[None]] = {}
47
+ self._callbacks: dict[str, Callable[..., Coroutine[Any, Any, None]]] = {}
48
+ self._running = False
49
+ self._webhook_server: asyncio.Server | None = None
50
+
51
+ async def start(self) -> None:
52
+ logger.debug("Entered into DaemonManager.start")
53
+ self._running = True
54
+ for task_id, task in self._tasks.items():
55
+ if task.status == "stopped":
56
+ await self._start_task(task)
57
+
58
+ async def stop(self) -> None:
59
+ logger.debug("Entered into DaemonManager.stop")
60
+ self._running = False
61
+ for task_id in list(self._handles.keys()):
62
+ await self._stop_task(task_id)
63
+ if self._webhook_server:
64
+ self._webhook_server.close()
65
+ await self._webhook_server.wait_closed()
66
+ self._webhook_server = None
67
+
68
+ def add_watcher(
69
+ self,
70
+ name: str,
71
+ path: str,
72
+ patterns: list[str] | None = None,
73
+ interval: float = 2.0,
74
+ callback: Callable[..., Coroutine[Any, Any, None]] | None = None,
75
+ ) -> str:
76
+ logger.debug(f"Entered into add_watcher: name={name}, path={path}")
77
+ task_id = f"watch_{name}_{int(time.time())}"
78
+ task = DaemonTask(
79
+ id=task_id,
80
+ name=name,
81
+ type="watcher",
82
+ config={
83
+ "path": path,
84
+ "patterns": patterns or ["*"],
85
+ "interval": interval,
86
+ },
87
+ )
88
+ self._tasks[task_id] = task
89
+ if callback:
90
+ self._callbacks[task_id] = callback
91
+ return task_id
92
+
93
+ def add_schedule(
94
+ self,
95
+ name: str,
96
+ interval_seconds: float,
97
+ command: str = "",
98
+ callback: Callable[..., Coroutine[Any, Any, None]] | None = None,
99
+ ) -> str:
100
+ logger.debug(f"Entered into add_schedule: name={name}, interval={interval_seconds}")
101
+ task_id = f"sched_{name}_{int(time.time())}"
102
+ task = DaemonTask(
103
+ id=task_id,
104
+ name=name,
105
+ type="schedule",
106
+ config={
107
+ "interval": interval_seconds,
108
+ "command": command,
109
+ },
110
+ )
111
+ self._tasks[task_id] = task
112
+ if callback:
113
+ self._callbacks[task_id] = callback
114
+ return task_id
115
+
116
+ def add_webhook(
117
+ self,
118
+ name: str,
119
+ path: str = "/webhook",
120
+ port: int = 8765,
121
+ callback: Callable[..., Coroutine[Any, Any, None]] | None = None,
122
+ ) -> str:
123
+ logger.debug(f"Entered into add_webhook: name={name}, path={path}, port={port}")
124
+ task_id = f"hook_{name}_{int(time.time())}"
125
+ task = DaemonTask(
126
+ id=task_id,
127
+ name=name,
128
+ type="webhook",
129
+ config={
130
+ "path": path,
131
+ "port": port,
132
+ },
133
+ )
134
+ self._tasks[task_id] = task
135
+ if callback:
136
+ self._callbacks[task_id] = callback
137
+ return task_id
138
+
139
+ async def remove_task(self, task_id: str) -> bool:
140
+ logger.debug(f"Entered into remove_task: id={task_id}")
141
+ if task_id in self._handles:
142
+ await self._stop_task(task_id)
143
+ if task_id in self._tasks:
144
+ del self._tasks[task_id]
145
+ self._callbacks.pop(task_id, None)
146
+ return True
147
+ return False
148
+
149
+ def list_tasks(self) -> list[DaemonTask]:
150
+ logger.debug("Entered into list_tasks")
151
+ return list(self._tasks.values())
152
+
153
+ def get_status(self) -> dict[str, Any]:
154
+ logger.debug("Entered into get_status")
155
+ return {
156
+ "running": self._running,
157
+ "task_count": len(self._tasks),
158
+ "active": sum(1 for t in self._tasks.values() if t.status == "running"),
159
+ "tasks": [
160
+ {
161
+ "id": t.id,
162
+ "name": t.name,
163
+ "type": t.type,
164
+ "status": t.status,
165
+ "run_count": t.run_count,
166
+ "last_run": t.last_run,
167
+ "error": t.error,
168
+ }
169
+ for t in self._tasks.values()
170
+ ],
171
+ }
172
+
173
+ async def _start_task(self, task: DaemonTask) -> None:
174
+ logger.debug(f"Entered into _start_task: id={task.id}, type={task.type}")
175
+ if task.id in self._handles:
176
+ return
177
+
178
+ if task.type == "watcher":
179
+ self._handles[task.id] = asyncio.create_task(self._run_watcher(task))
180
+ elif task.type == "schedule":
181
+ self._handles[task.id] = asyncio.create_task(self._run_schedule(task))
182
+ elif task.type == "webhook":
183
+ self._handles[task.id] = asyncio.create_task(self._run_webhook(task))
184
+
185
+ task.status = "running"
186
+
187
+ async def _stop_task(self, task_id: str) -> None:
188
+ logger.debug(f"Entered into _stop_task: id={task_id}")
189
+ handle = self._handles.pop(task_id, None)
190
+ if handle and not handle.done():
191
+ handle.cancel()
192
+ try:
193
+ await handle
194
+ except asyncio.CancelledError:
195
+ pass
196
+ if task_id in self._tasks:
197
+ self._tasks[task_id].status = "stopped"
198
+
199
+ async def _run_watcher(self, task: DaemonTask) -> None:
200
+ logger.debug(f"Entered into _run_watcher: id={task.id}")
201
+ watch_path = Path(task.config["path"])
202
+ interval = task.config.get("interval", 2.0)
203
+ patterns = task.config.get("patterns", ["*"])
204
+
205
+ file_states: dict[str, float] = {}
206
+ if watch_path.is_dir():
207
+ for pattern in patterns:
208
+ for f in watch_path.rglob(pattern):
209
+ if f.is_file():
210
+ file_states[str(f)] = f.stat().st_mtime
211
+
212
+ try:
213
+ while self._running:
214
+ await asyncio.sleep(interval)
215
+ if not watch_path.exists():
216
+ continue
217
+
218
+ current: dict[str, float] = {}
219
+ for pattern in patterns:
220
+ for f in watch_path.rglob(pattern):
221
+ if f.is_file():
222
+ current[str(f)] = f.stat().st_mtime
223
+
224
+ changed = [p for p, mt in current.items() if file_states.get(p) != mt]
225
+ deleted = [p for p in file_states if p not in current]
226
+
227
+ if changed or deleted:
228
+ task.run_count += 1
229
+ task.last_run = time.time()
230
+ callback = self._callbacks.get(task.id)
231
+ if callback:
232
+ try:
233
+ await callback(changed=changed, deleted=deleted, task=task)
234
+ except Exception as e:
235
+ task.error = str(e)
236
+ logger.warning(f"Watcher callback error: {e}")
237
+
238
+ file_states = current
239
+
240
+ except asyncio.CancelledError:
241
+ pass
242
+
243
+ async def _run_schedule(self, task: DaemonTask) -> None:
244
+ logger.debug(f"Entered into _run_schedule: id={task.id}")
245
+ interval = task.config.get("interval", 60.0)
246
+ command = task.config.get("command", "")
247
+
248
+ try:
249
+ while self._running:
250
+ await asyncio.sleep(interval)
251
+ task.run_count += 1
252
+ task.last_run = time.time()
253
+
254
+ callback = self._callbacks.get(task.id)
255
+ if callback:
256
+ try:
257
+ await callback(task=task)
258
+ except Exception as e:
259
+ task.error = str(e)
260
+ logger.warning(f"Schedule callback error: {e}")
261
+ elif command:
262
+ try:
263
+ proc = await asyncio.create_subprocess_shell(
264
+ command,
265
+ stdout=asyncio.subprocess.PIPE,
266
+ stderr=asyncio.subprocess.PIPE,
267
+ )
268
+ await asyncio.wait_for(proc.communicate(), timeout=30)
269
+ except Exception as e:
270
+ task.error = str(e)
271
+ logger.warning(f"Schedule command error: {e}")
272
+
273
+ except asyncio.CancelledError:
274
+ pass
275
+
276
+ async def _run_webhook(self, task: DaemonTask) -> None:
277
+ logger.debug(f"Entered into _run_webhook: id={task.id}")
278
+ port = task.config.get("port", 8765)
279
+ expected_path = task.config.get("path", "/webhook")
280
+
281
+ async def handle_request(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
282
+ try:
283
+ request_line = await asyncio.wait_for(reader.readline(), timeout=10)
284
+ line = request_line.decode("utf-8", errors="replace").strip()
285
+ parts = line.split(" ")
286
+ method = parts[0] if parts else "GET"
287
+ path = parts[1] if len(parts) > 1 else "/"
288
+
289
+ headers: dict[str, str] = {}
290
+ content_length = 0
291
+ while True:
292
+ header_line = await asyncio.wait_for(reader.readline(), timeout=5)
293
+ header = header_line.decode("utf-8", errors="replace").strip()
294
+ if not header:
295
+ break
296
+ if ":" in header:
297
+ key, val = header.split(":", 1)
298
+ headers[key.strip().lower()] = val.strip()
299
+ if key.strip().lower() == "content-length":
300
+ content_length = int(val.strip())
301
+
302
+ body = b""
303
+ if content_length > 0:
304
+ body = await asyncio.wait_for(reader.read(min(content_length, 65536)), timeout=10)
305
+
306
+ if path == expected_path and method == "POST":
307
+ task.run_count += 1
308
+ task.last_run = time.time()
309
+
310
+ payload: dict[str, Any] = {}
311
+ if body:
312
+ try:
313
+ payload = json.loads(body)
314
+ except json.JSONDecodeError:
315
+ payload = {"raw": body.decode("utf-8", errors="replace")}
316
+
317
+ callback = self._callbacks.get(task.id)
318
+ if callback:
319
+ try:
320
+ await callback(payload=payload, task=task)
321
+ except Exception as e:
322
+ task.error = str(e)
323
+
324
+ response = 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{"status": "ok"}'
325
+ else:
326
+ response = 'HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\n\r\n{"error": "not found"}'
327
+
328
+ writer.write(response.encode())
329
+ await writer.drain()
330
+ except Exception as e:
331
+ logger.debug(f"Webhook request error: {e}")
332
+ finally:
333
+ writer.close()
334
+ try:
335
+ await writer.wait_closed()
336
+ except Exception:
337
+ pass
338
+
339
+ try:
340
+ self._webhook_server = await asyncio.start_server(handle_request, "127.0.0.1", port)
341
+ logger.info(f"Webhook listener started on 127.0.0.1:{port}{expected_path}")
342
+ async with self._webhook_server:
343
+ await self._webhook_server.serve_forever()
344
+ except asyncio.CancelledError:
345
+ pass
346
+ except Exception as e:
347
+ task.status = "error"
348
+ task.error = str(e)
349
+ logger.warning(f"Webhook server error: {e}")
elidia/db/__init__.py ADDED
File without changes