sicily 0.2.3__tar.gz

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.
@@ -0,0 +1,27 @@
1
+ .DS_Store
2
+
3
+ # Python-generated files
4
+ __pycache__/
5
+ *.py[oc]
6
+ build/
7
+ dist/
8
+ wheels/
9
+ *.egg-info
10
+
11
+ # Virtual environments
12
+ .venv
13
+ .env
14
+
15
+ # Credentials & Tokens
16
+ google_credentials.json
17
+ gmail_token.json
18
+ gmail_token_*.json
19
+
20
+ # User specific data
21
+ .chat_id
22
+ Data/
23
+ settings.json
24
+
25
+ # Dev folders
26
+ Notes/
27
+ Helper Scripts And Files/
@@ -0,0 +1,71 @@
1
+ import os
2
+ import socket
3
+ from google.oauth2.credentials import Credentials
4
+ from google_auth_oauthlib.flow import InstalledAppFlow
5
+ from google.auth.transport.requests import Request
6
+
7
+ SCOPES = [
8
+ "https://www.googleapis.com/auth/gmail.modify",
9
+ "https://www.googleapis.com/auth/gmail.send",
10
+ "https://www.googleapis.com/auth/gmail.readonly",
11
+ ]
12
+
13
+ CREDENTIALS_FILE = "google_credentials.json"
14
+ PORT = 8080
15
+
16
+
17
+ async def get_gmail_token() -> str:
18
+ """Get Gmail token, handling stale port from cancelled auth flows."""
19
+
20
+ token_file = "gmail_token.json"
21
+ creds = None
22
+
23
+ if os.path.exists(token_file):
24
+ creds = Credentials.from_authorized_user_file(token_file, SCOPES)
25
+
26
+ if creds and creds.expired and creds.refresh_token:
27
+ creds.refresh(Request())
28
+
29
+ elif not creds or not creds.valid:
30
+ if not os.path.exists(CREDENTIALS_FILE):
31
+ raise FileNotFoundError(f"❌ {CREDENTIALS_FILE} not found!")
32
+
33
+ # --- Free the port if it's stuck from a previous cancelled flow ---
34
+ if _is_port_in_use(PORT):
35
+ print(f"⚠️ Port {PORT} already in use — freeing it...")
36
+ _free_port(PORT)
37
+
38
+ flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES)
39
+ creds = flow.run_local_server(
40
+ port=PORT,
41
+ prompt='consent'
42
+ )
43
+
44
+ with open(token_file, "w") as f:
45
+ f.write(creds.to_json())
46
+
47
+ return creds.token
48
+
49
+
50
+ def _is_port_in_use(port: int) -> bool:
51
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
52
+ return s.connect_ex(("localhost", port)) == 0
53
+
54
+
55
+ def _free_port(port: int):
56
+ """Kill whatever process is holding the port."""
57
+ import subprocess, sys
58
+ if sys.platform == "win32":
59
+ result = subprocess.run(
60
+ f"for /f \"tokens=5\" %a in ('netstat -aon ^| find \":{port}\"') do taskkill /F /PID %a",
61
+ shell=True, capture_output=True
62
+ )
63
+ else:
64
+ # Linux / macOS
65
+ result = subprocess.run(
66
+ f"fuser -k {port}/tcp",
67
+ shell=True, capture_output=True
68
+ )
69
+ # Give the OS a moment to release the port
70
+ import time
71
+ time.sleep(0.5)
@@ -0,0 +1,54 @@
1
+ import asyncio
2
+ import hashlib, secrets, base64, time, webbrowser
3
+ import httpx
4
+ from urllib.parse import urlencode
5
+ import os
6
+
7
+ CLIENT_ID = os.getenv("SWIGGY_CLIENT_ID")
8
+ REDIRECT_URI = "http://localhost:8000/callback"
9
+ _oauth_code_future = None
10
+
11
+ _cache = {"token": None, "expires_at": 0}
12
+
13
+ async def get_valid_token() -> str:
14
+ """Returns a cached token, or re-runs OAuth if expired."""
15
+ if _cache["token"] and time.time() < _cache["expires_at"] - 60:
16
+ return _cache["token"]
17
+
18
+ # PKCE
19
+ verifier = secrets.token_urlsafe(32)
20
+ digest = hashlib.sha256(verifier.encode()).digest()
21
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
22
+
23
+ # Open browser for phone + OTP
24
+ params = urlencode({
25
+ "response_type": "code",
26
+ "client_id": CLIENT_ID,
27
+ "redirect_uri": REDIRECT_URI,
28
+ "code_challenge": challenge,
29
+ "code_challenge_method": "S256",
30
+ "state": secrets.token_urlsafe(16),
31
+ "scope": "mcp:tools",
32
+ })
33
+ webbrowser.open(f"https://mcp.swiggy.com/auth/authorize?{params}")
34
+
35
+ global _oauth_code_future
36
+
37
+ loop = asyncio.get_running_loop()
38
+ _oauth_code_future = loop.create_future()
39
+ print("⏳ Waiting for OAuth redirect on http://localhost:8000/callback ...", flush=True)
40
+ code = await _oauth_code_future
41
+
42
+ async with httpx.AsyncClient() as client:
43
+ resp = await client.post("https://mcp.swiggy.com/auth/token", json={
44
+ "grant_type": "authorization_code",
45
+ "code": code,
46
+ "code_verifier": verifier,
47
+ "client_id": CLIENT_ID,
48
+ "redirect_uri": REDIRECT_URI,
49
+ })
50
+
51
+ token = resp.json()["access_token"]
52
+ _cache["token"] = token
53
+ _cache["expires_at"] = time.time() + 432000 # 5 days
54
+ return token
@@ -0,0 +1,10 @@
1
+ import os
2
+
3
+ async def get_tavily_config() -> dict:
4
+ env = os.environ.copy()
5
+
6
+ env.update({
7
+ "TAVILY_API_KEY": os.environ["TAVILY_API_KEY"],
8
+ })
9
+
10
+ return env
@@ -0,0 +1,12 @@
1
+ import os
2
+
3
+ async def get_telegram_config() -> dict:
4
+ env = os.environ.copy()
5
+
6
+ env.update({
7
+ "TELEGRAM_API_ID": os.environ["TELEGRAM_API_ID"],
8
+ "TELEGRAM_API_HASH": os.environ["TELEGRAM_API_HASH"],
9
+ "TELEGRAM_SESSION_STRING": os.environ["TELEGRAM_SESSION_STRING"],
10
+ })
11
+
12
+ return env
File without changes
sicily-0.2.3/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: sicily
3
+ Version: 0.2.3
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: aiosqlite>=0.22.1
7
+ Requires-Dist: cryptography>=48.0.1
8
+ Requires-Dist: fastapi>=0.136.1
9
+ Requires-Dist: google-auth-oauthlib>=1.4.0
10
+ Requires-Dist: google-auth>=2.53.0
11
+ Requires-Dist: google-generativeai>=0.8.6
12
+ Requires-Dist: langchain-anthropic>=1.4.3
13
+ Requires-Dist: langchain-core>=1.4.0
14
+ Requires-Dist: langchain-google-genai>=4.2.2
15
+ Requires-Dist: langchain-mcp-adapters>=0.2.2
16
+ Requires-Dist: langchain-openai>=1.2.1
17
+ Requires-Dist: langchain>=1.3.0
18
+ Requires-Dist: langgraph-checkpoint-sqlite>=3.1.0
19
+ Requires-Dist: langgraph>=1.2.0
20
+ Requires-Dist: langsmith>=0.8.3
21
+ Requires-Dist: numpy>=2.4.6
22
+ Requires-Dist: openai>=2.36.0
23
+ Requires-Dist: pyjwt>=2.13.0
24
+ Requires-Dist: pytest>=9.0.3
25
+ Requires-Dist: python-dotenv>=1.2.2
26
+ Requires-Dist: python-multipart>=0.0.30
27
+ Requires-Dist: python-telegram-bot>=22.7
28
+ Requires-Dist: pyyaml>=6.0.3
29
+ Requires-Dist: requests>=2.34.0
30
+ Requires-Dist: starlette>=1.1.0
31
+ Requires-Dist: structlog>=26.1.0
32
+ Requires-Dist: tiktoken>=0.13.0
33
+ Requires-Dist: uvicorn>=0.46.0
34
+ Description-Content-Type: text/markdown
35
+
36
+ # Sicily — State-Locked Autonomous Agent Framework & Resilient Tool Orchestrator
37
+
38
+ A production-grade, cost-efficient, and crash-resilient AI runtime powered by **LangGraph** and **Multi-Transport MCP**. Operating through an asynchronous **Telegram** interface, it can coordinate external tools, execute recurring tasks, maintain long-term memory, and persist workflow state across restarts.
39
+
40
+ Designed as a persistent personal agent, Sicily emphasizes efficient reasoning, safe automation, and operational reliability.
41
+
42
+ The system is built around five core principles:
43
+
44
+ - **Cost-efficient reasoning** — use specialized models, selective context retrieval, and multi-stage tool filtering to maximize accuracy while minimizing token consumption.
45
+ - **Scalable tool orchestration** — dynamically retrieve only the tools relevant to the current request, keeping reasoning focused even as the available toolset grows.
46
+ - **Persistent memory** — learn user preferences over time and inject only contextually relevant information into conversations.
47
+ - **Safe automation** — enforce human approval before executing potentially destructive actions.
48
+ - **Operational resilience** — preserve sessions, checkpoints, and scheduled workflows across downtime and infrastructure failures.
49
+
50
+ ## Architecture Overview
51
+
52
+ <img src="Images/main_system_workflow.svg" alt="Main System Workflow" width="75%">
53
+
54
+ A message arrives via Telegram, passes through the FastAPI backend and session manager, then enters the LangGraph state graph. Inside the graph, the system prepares context (injecting relevant user preferences), fetches only the tools needed for this specific request, reasons with the main LLM, optionally pauses for human approval on destructive actions, executes tools, and sends back a response.
55
+
56
+ ---
57
+
58
+ ## Core Capabilities
59
+
60
+ ### Handles Unlimited Tools Without Losing Focus
61
+
62
+ Most agents fall apart as you add more tools — the context window fills up, costs spike, and the model starts hallucinating wrong tool calls. This system solves that with a **two-stage retrieval pipeline** that filters tools *before* they ever reach the main LLM.
63
+
64
+ <img src="Images/two_stage_tool_retrieval.svg" alt="Two-Stage Tool Retrieval" width="75%">
65
+
66
+ **Stage 1 — Intent routing:** A cheap nano model reads the last few messages and decides which *services* are relevant right now (e.g. only Swiggy, not Gmail). Everything else is ignored entirely.
67
+
68
+ **Stage 2 — Semantic filtering:** Within each selected service, tool descriptions are compared to the query using cosine similarity. Only the most relevant tools per service are passed forward.
69
+
70
+ The result: the main LLM always sees a short, focused list of tools regardless of how many are registered. You can add so many more tools tomorrow from multiple servers and the model won't know or care about the ones that aren't relevant.
71
+
72
+ ---
73
+
74
+ ### Remembers You — And Gets Better Over Time
75
+
76
+ The agent builds a personal profile of you automatically. Every session, after you've been idle for some time, an evaluator LLM analyses the conversation and extracts stable behavioural patterns — things like ordering preferences, communication style, time habits, or how you like information presented.
77
+
78
+ <img src="Images/memory_and_personalization.svg" alt="Memory and Personalization" width="75%">
79
+
80
+ These are stored as a clean flat list in `preferences.md`. When preferences contradict each other (you said you prefer concise responses last month but now you clearly want detail), the merge step resolves the conflict and keeps only the newer version.
81
+
82
+ On every new session, only the preferences *relevant to your current request* are retrieved via semantic search and woven into the system prompt. You're not stuffing the context with everything — just what matters right now.
83
+
84
+ The agent's core personality and tone live separately in `Souls/{name}.md`, which can be swapped out entirely without touching any application logic.
85
+
86
+ ---
87
+
88
+ ### Never Does Anything Destructive Without Asking
89
+
90
+ Every tool call goes through a three-gate safety pipeline before execution.
91
+
92
+ <img src="Images/hitl_safety_pipeline.svg" alt="HITL Safety Pipeline" width="75%">
93
+
94
+ **Gate 1 — Prefix fast-path:** Tools starting with `get_`, `search_`, `read_` are immediately marked safe. No LLM call needed.
95
+
96
+ **Gate 2 — Heuristic detection:** Tools starting with `update_`, `delete_`, `send_` are flagged as unsafe automatically.
97
+
98
+ **Gate 3 — LLM safety net:** Anything ambiguous gets evaluated by a dedicated safety LLM that reads the tool description and the arguments being passed.
99
+
100
+ If a tool is flagged unsafe, the LangGraph graph *pauses* and asks you: approve, abort, or edit the arguments. Nothing happens until you decide. If a tool hallucinated by the model doesn't exist, the executor catches it cleanly and returns a `ToolMessage` saying "Tool not found" — no graph crashes, no cascading errors.
101
+
102
+ ---
103
+
104
+ ### Always On — Scheduled Tasks Run 24/7
105
+
106
+ The agent doesn't just respond to messages. It proactively executes tasks on a schedule, dispatching them into the main agent the same way a user message would be handled.
107
+
108
+ <img src="Images/recurring_task_engine.svg" alt="Recurring Task Engine" width="75%">
109
+
110
+ Tasks are defined in plain YAML — no code changes needed to add, modify, or disable them:
111
+
112
+ ```yaml
113
+ - id: morning_news
114
+ enabled: true
115
+ task: "Summarize the top AI news headlines"
116
+ schedule:
117
+ mode: daily
118
+ at: "08:00"
119
+ days: [mon, tue, wed, thu, fri]
120
+
121
+ - id: email_check
122
+ enabled: true
123
+ task: "Check unread emails and flag anything urgent"
124
+ schedule:
125
+ mode: interval
126
+ every: 30m
127
+ days: [mon, tue, wed]
128
+ ```
129
+
130
+ Each enabled task gets its own independent async loop. Schedules support daily execution at a specific time, fixed intervals (minutes or hours), and optional weekday filters.
131
+
132
+ ---
133
+
134
+ ### Sessions Survive Server Downtime
135
+
136
+ Sessions are persisted to SQLite and survive crashes, restarts, and planned downtime. On every boot, the system scans all stored sessions and reconciles them: sessions that expired while the server was down are cleaned up, and valid sessions have their idle timers reconstructed from where they left off.
137
+
138
+ The LangGraph checkpoint store lives in the same database, so the full conversation context is restored exactly where it was — no lost history, no cold-start on reconnect.
139
+
140
+ On unexpected shutdown, a 10-second graceful drain gives in-flight tasks time to settle their database commits before the process is force-killed.
141
+
142
+ ---
143
+
144
+ ### Keeps Context Sharp as Conversations Grow Long
145
+
146
+ Long conversations accumulate fast, especially with tool calls. Once the message history exceeds tokens threshold, the context trimmer kicks in.
147
+
148
+ <img src="Images/context_trimming.svg" alt="Context Trimming" width="75%">
149
+
150
+ It walks backward through the message history to find a safe cut point — specifically, it never splits an `AIMessage` (tool call) from its corresponding `ToolMessage` (result), because the OpenAI API rejects sequences where a tool call has no matching result. Everything before the cut is compressed into a single summary message. The active portion of the conversation stays intact.
sicily-0.2.3/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Sicily — State-Locked Autonomous Agent Framework & Resilient Tool Orchestrator
2
+
3
+ A production-grade, cost-efficient, and crash-resilient AI runtime powered by **LangGraph** and **Multi-Transport MCP**. Operating through an asynchronous **Telegram** interface, it can coordinate external tools, execute recurring tasks, maintain long-term memory, and persist workflow state across restarts.
4
+
5
+ Designed as a persistent personal agent, Sicily emphasizes efficient reasoning, safe automation, and operational reliability.
6
+
7
+ The system is built around five core principles:
8
+
9
+ - **Cost-efficient reasoning** — use specialized models, selective context retrieval, and multi-stage tool filtering to maximize accuracy while minimizing token consumption.
10
+ - **Scalable tool orchestration** — dynamically retrieve only the tools relevant to the current request, keeping reasoning focused even as the available toolset grows.
11
+ - **Persistent memory** — learn user preferences over time and inject only contextually relevant information into conversations.
12
+ - **Safe automation** — enforce human approval before executing potentially destructive actions.
13
+ - **Operational resilience** — preserve sessions, checkpoints, and scheduled workflows across downtime and infrastructure failures.
14
+
15
+ ## Architecture Overview
16
+
17
+ <img src="Images/main_system_workflow.svg" alt="Main System Workflow" width="75%">
18
+
19
+ A message arrives via Telegram, passes through the FastAPI backend and session manager, then enters the LangGraph state graph. Inside the graph, the system prepares context (injecting relevant user preferences), fetches only the tools needed for this specific request, reasons with the main LLM, optionally pauses for human approval on destructive actions, executes tools, and sends back a response.
20
+
21
+ ---
22
+
23
+ ## Core Capabilities
24
+
25
+ ### Handles Unlimited Tools Without Losing Focus
26
+
27
+ Most agents fall apart as you add more tools — the context window fills up, costs spike, and the model starts hallucinating wrong tool calls. This system solves that with a **two-stage retrieval pipeline** that filters tools *before* they ever reach the main LLM.
28
+
29
+ <img src="Images/two_stage_tool_retrieval.svg" alt="Two-Stage Tool Retrieval" width="75%">
30
+
31
+ **Stage 1 — Intent routing:** A cheap nano model reads the last few messages and decides which *services* are relevant right now (e.g. only Swiggy, not Gmail). Everything else is ignored entirely.
32
+
33
+ **Stage 2 — Semantic filtering:** Within each selected service, tool descriptions are compared to the query using cosine similarity. Only the most relevant tools per service are passed forward.
34
+
35
+ The result: the main LLM always sees a short, focused list of tools regardless of how many are registered. You can add so many more tools tomorrow from multiple servers and the model won't know or care about the ones that aren't relevant.
36
+
37
+ ---
38
+
39
+ ### Remembers You — And Gets Better Over Time
40
+
41
+ The agent builds a personal profile of you automatically. Every session, after you've been idle for some time, an evaluator LLM analyses the conversation and extracts stable behavioural patterns — things like ordering preferences, communication style, time habits, or how you like information presented.
42
+
43
+ <img src="Images/memory_and_personalization.svg" alt="Memory and Personalization" width="75%">
44
+
45
+ These are stored as a clean flat list in `preferences.md`. When preferences contradict each other (you said you prefer concise responses last month but now you clearly want detail), the merge step resolves the conflict and keeps only the newer version.
46
+
47
+ On every new session, only the preferences *relevant to your current request* are retrieved via semantic search and woven into the system prompt. You're not stuffing the context with everything — just what matters right now.
48
+
49
+ The agent's core personality and tone live separately in `Souls/{name}.md`, which can be swapped out entirely without touching any application logic.
50
+
51
+ ---
52
+
53
+ ### Never Does Anything Destructive Without Asking
54
+
55
+ Every tool call goes through a three-gate safety pipeline before execution.
56
+
57
+ <img src="Images/hitl_safety_pipeline.svg" alt="HITL Safety Pipeline" width="75%">
58
+
59
+ **Gate 1 — Prefix fast-path:** Tools starting with `get_`, `search_`, `read_` are immediately marked safe. No LLM call needed.
60
+
61
+ **Gate 2 — Heuristic detection:** Tools starting with `update_`, `delete_`, `send_` are flagged as unsafe automatically.
62
+
63
+ **Gate 3 — LLM safety net:** Anything ambiguous gets evaluated by a dedicated safety LLM that reads the tool description and the arguments being passed.
64
+
65
+ If a tool is flagged unsafe, the LangGraph graph *pauses* and asks you: approve, abort, or edit the arguments. Nothing happens until you decide. If a tool hallucinated by the model doesn't exist, the executor catches it cleanly and returns a `ToolMessage` saying "Tool not found" — no graph crashes, no cascading errors.
66
+
67
+ ---
68
+
69
+ ### Always On — Scheduled Tasks Run 24/7
70
+
71
+ The agent doesn't just respond to messages. It proactively executes tasks on a schedule, dispatching them into the main agent the same way a user message would be handled.
72
+
73
+ <img src="Images/recurring_task_engine.svg" alt="Recurring Task Engine" width="75%">
74
+
75
+ Tasks are defined in plain YAML — no code changes needed to add, modify, or disable them:
76
+
77
+ ```yaml
78
+ - id: morning_news
79
+ enabled: true
80
+ task: "Summarize the top AI news headlines"
81
+ schedule:
82
+ mode: daily
83
+ at: "08:00"
84
+ days: [mon, tue, wed, thu, fri]
85
+
86
+ - id: email_check
87
+ enabled: true
88
+ task: "Check unread emails and flag anything urgent"
89
+ schedule:
90
+ mode: interval
91
+ every: 30m
92
+ days: [mon, tue, wed]
93
+ ```
94
+
95
+ Each enabled task gets its own independent async loop. Schedules support daily execution at a specific time, fixed intervals (minutes or hours), and optional weekday filters.
96
+
97
+ ---
98
+
99
+ ### Sessions Survive Server Downtime
100
+
101
+ Sessions are persisted to SQLite and survive crashes, restarts, and planned downtime. On every boot, the system scans all stored sessions and reconciles them: sessions that expired while the server was down are cleaned up, and valid sessions have their idle timers reconstructed from where they left off.
102
+
103
+ The LangGraph checkpoint store lives in the same database, so the full conversation context is restored exactly where it was — no lost history, no cold-start on reconnect.
104
+
105
+ On unexpected shutdown, a 10-second graceful drain gives in-flight tasks time to settle their database commits before the process is force-killed.
106
+
107
+ ---
108
+
109
+ ### Keeps Context Sharp as Conversations Grow Long
110
+
111
+ Long conversations accumulate fast, especially with tool calls. Once the message history exceeds tokens threshold, the context trimmer kicks in.
112
+
113
+ <img src="Images/context_trimming.svg" alt="Context Trimming" width="75%">
114
+
115
+ It walks backward through the message history to find a safe cut point — specifically, it never splits an `AIMessage` (tool call) from its corresponding `ToolMessage` (result), because the OpenAI API rejects sequences where a tool call has no matching result. Everything before the cut is compressed into a single summary message. The active portion of the conversation stays intact.
@@ -0,0 +1,239 @@
1
+ import asyncio
2
+ import re
3
+ import yaml
4
+ from pathlib import Path
5
+
6
+ from datetime import datetime, timedelta
7
+
8
+ YAML_FILE = Path.cwd() / "Recurring_Tasks" / "recurring_tasks.yaml"
9
+
10
+ _dispatch = None
11
+
12
+ def set_dispatch(fn):
13
+ global _dispatch
14
+ _dispatch = fn
15
+
16
+ VALID_DAYS = {
17
+ "mon", "tue", "wed", "thu", "fri", "sat", "sun"
18
+ }
19
+
20
+ WEEKDAY_MAP = {
21
+ 0: "mon",
22
+ 1: "tue",
23
+ 2: "wed",
24
+ 3: "thu",
25
+ 4: "fri",
26
+ 5: "sat",
27
+ 6: "sun",
28
+ }
29
+
30
+
31
+ # PUBLIC ENTRYPOINT
32
+ async def start_recurring_tasks():
33
+
34
+ tasks = load_and_validate_yaml()
35
+
36
+ enabled_tasks = [t for t in tasks if t["enabled"]]
37
+
38
+ print(f"🔁 Starting {len(enabled_tasks)} recurring task(s)...")
39
+
40
+ for task_config in enabled_tasks:
41
+ asyncio.create_task(task_runner(task_config))
42
+
43
+
44
+ # YAML LOADING + VALIDATION
45
+ def load_and_validate_yaml():
46
+
47
+ with open(YAML_FILE, "r") as f:
48
+ data = yaml.safe_load(f)
49
+
50
+ if not data or "tasks" not in data:
51
+ raise ValueError("Missing 'tasks' in YAML.")
52
+
53
+ tasks = data["tasks"]
54
+
55
+ if not isinstance(tasks, list):
56
+ raise ValueError("'tasks' must be a list.")
57
+
58
+ seen_ids = set()
59
+
60
+ for task in tasks:
61
+
62
+ required_fields = ["id", "enabled", "task", "schedule"]
63
+
64
+ for field in required_fields:
65
+ if field not in task:
66
+ raise ValueError(f"Task missing required field: {field}")
67
+
68
+ task_id = task["id"]
69
+
70
+ if task_id in seen_ids:
71
+ raise ValueError(f"Duplicate task id: {task_id}")
72
+
73
+ seen_ids.add(task_id)
74
+
75
+ validate_schedule(task_id, task["schedule"])
76
+
77
+ return tasks
78
+
79
+
80
+ def validate_schedule(task_id, schedule):
81
+
82
+ if "mode" not in schedule:
83
+ raise ValueError(f"[{task_id}] Missing schedule.mode")
84
+
85
+ mode = schedule["mode"]
86
+
87
+ if mode not in ["daily", "interval"]:
88
+ raise ValueError(f"[{task_id}] Invalid mode: {mode}")
89
+
90
+ # ── DAILY ────────────────────────────────────────────────
91
+ if mode == "daily":
92
+
93
+ if "at" not in schedule:
94
+ raise ValueError(f"[{task_id}] daily mode requires 'at'")
95
+
96
+ validate_time(schedule["at"], task_id)
97
+
98
+ # ── INTERVAL ─────────────────────────────────────────────
99
+ elif mode == "interval":
100
+
101
+ if "every" not in schedule:
102
+ raise ValueError(f"[{task_id}] interval mode requires 'every'")
103
+
104
+ validate_interval(schedule["every"], task_id)
105
+
106
+ if "start" in schedule:
107
+ validate_time(schedule["start"], task_id)
108
+
109
+ # ── DAYS ────────────────────────────────────────────────
110
+ if "days" in schedule:
111
+
112
+ if not isinstance(schedule["days"], list):
113
+ raise ValueError(f"[{task_id}] days must be a list")
114
+
115
+ for d in schedule["days"]:
116
+ if d not in VALID_DAYS:
117
+ raise ValueError(f"[{task_id}] Invalid day: {d}")
118
+
119
+
120
+ def validate_time(value, task_id):
121
+
122
+ if not re.fullmatch(r"\d{2}:\d{2}", value):
123
+ raise ValueError(f"[{task_id}] Invalid time format: {value}")
124
+
125
+ hour, minute = map(int, value.split(":"))
126
+
127
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
128
+ raise ValueError(f"[{task_id}] Invalid time: {value}")
129
+
130
+
131
+ def validate_interval(value, task_id):
132
+
133
+ if not re.fullmatch(r"\d+[mh]", value):
134
+ raise ValueError(f"[{task_id}] Invalid interval: {value}")
135
+
136
+
137
+ # TASK RUNNER
138
+ async def task_runner(task_config):
139
+
140
+ task_id = task_config["id"]
141
+ task_text = task_config["task"]
142
+ schedule = task_config["schedule"]
143
+
144
+ mode = schedule["mode"]
145
+
146
+ print(f"✅ Task loaded: {task_id}")
147
+
148
+ while True:
149
+
150
+ now = datetime.now()
151
+
152
+ # DAILY MODE
153
+ if mode == "daily":
154
+
155
+ run_time = build_today_datetime(schedule["at"])
156
+
157
+ if run_time <= now:
158
+ run_time += timedelta(days=1)
159
+
160
+ wait_seconds = (run_time - now).total_seconds()
161
+
162
+ await asyncio.sleep(wait_seconds)
163
+
164
+ if should_run_today(schedule):
165
+ await execute_task(task_id, task_text)
166
+
167
+ # INTERVAL MODE
168
+ elif mode == "interval":
169
+
170
+ if "start" in schedule:
171
+
172
+ first_run = build_today_datetime(schedule["start"])
173
+
174
+ if first_run > now:
175
+ wait_seconds = (first_run - now).total_seconds()
176
+ await asyncio.sleep(wait_seconds)
177
+
178
+ if should_run_today(schedule):
179
+ await execute_task(task_id, task_text)
180
+
181
+ interval_seconds = parse_interval(schedule["every"])
182
+
183
+ await asyncio.sleep(interval_seconds)
184
+
185
+
186
+ # HELPERS
187
+ async def execute_task(task_id, task_text):
188
+
189
+ print(
190
+ f"\n🔁 TASK EXECUTED"
191
+ f"\n🆔 ID : {task_id}"
192
+ f"\n📝 Task : {task_text}"
193
+ f"\n🕒 Time : {datetime.now()}\n",
194
+ flush=True
195
+ )
196
+
197
+ if _dispatch is None:
198
+ print(f"⚠️ No dispatch set — skipping task {task_id}")
199
+ return
200
+
201
+ await _dispatch(task_id, task_text)
202
+
203
+
204
+ def build_today_datetime(time_str):
205
+
206
+ hour, minute = map(int, time_str.split(":"))
207
+
208
+ now = datetime.now()
209
+
210
+ return now.replace(
211
+ hour=hour,
212
+ minute=minute,
213
+ second=0,
214
+ microsecond=0
215
+ )
216
+
217
+
218
+ def parse_interval(value):
219
+
220
+ amount = int(value[:-1])
221
+ unit = value[-1]
222
+
223
+ if unit == "m":
224
+ return amount * 60
225
+
226
+ if unit == "h":
227
+ return amount * 60 * 60
228
+
229
+ raise ValueError(f"Invalid interval unit: {value}")
230
+
231
+
232
+ def should_run_today(schedule):
233
+
234
+ if "days" not in schedule:
235
+ return True
236
+
237
+ today = WEEKDAY_MAP[datetime.now().weekday()]
238
+
239
+ return today in schedule["days"]