renzoide commited on
Commit
3f9a531
·
1 Parent(s): 137328e

Implement task notification system with cron job and ntfy integration

Browse files
Files changed (8) hide show
  1. .env.example +24 -0
  2. .gitignore +4 -1
  3. README.md +111 -7
  4. app.py +10 -0
  5. cron.py +264 -0
  6. pyproject.toml +1 -0
  7. requirements.txt +4 -0
  8. uv.lock +26 -0
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Rowmind Environment Variables
2
+ # Copy this file to .env and fill in your values
3
+
4
+ # ===================
5
+ # ntfy Configuration
6
+ # ===================
7
+ # Server URL (default: https://ntfy.sh, or use your self-hosted instance)
8
+ NTFY_SERVER=https://ntfy.sh
9
+
10
+ # Topic name - USE A RANDOM/UNGUESSABLE NAME for security!
11
+ # Example: rowmind-a3f8b2c1-notifications
12
+ # Generate one with: python -c "import uuid; print(f'rowmind-{uuid.uuid4()}')"
13
+ NTFY_TOPIC=rowmind-your-random-topic-here
14
+
15
+ # Optional: Access token for authentication (recommended for public repos)
16
+ # Create one at https://ntfy.sh/account or your self-hosted instance
17
+ # This prevents others from sending notifications to your topic
18
+ NTFY_TOKEN=
19
+
20
+ # ===================
21
+ # Cron Configuration
22
+ # ===================
23
+ # How often to check for due tasks (in seconds)
24
+ CRON_INTERVAL_SECONDS=60
.gitignore CHANGED
@@ -1,4 +1,7 @@
1
  data/*
2
  plan.md
3
  __pycache__/
4
- *.log
 
 
 
 
1
  data/*
2
  plan.md
3
  __pycache__/
4
+ *.log
5
+
6
+ # Environment variables (contains secrets)
7
+ .env
README.md CHANGED
@@ -20,6 +20,43 @@ tags:
20
 
21
  ## Rowmind MCP Server
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  ### Idea
24
 
25
  Rowmind is a small local “memory service” that you plug into any MCP‑compatible chat client (ChatGPT, Claude, Cursor, etc.).
@@ -80,15 +117,82 @@ Rowmind exposes a small set of tools and one resource over MCP:
80
 
81
  The same pattern works for expenses, mood logs, trips, or any other table you define.
82
 
83
- ### Reminders
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- A simple worker script can turn tasks into notifications:
86
 
87
- - Read `tasks.csv`.
88
- - Compute `now_epoch`.
89
- - Find tasks with `status="pending"` and `scheduled_at_epoch <= now_epoch`.
90
- - Send a notification (for example via ntfy).
91
- - If `schedule_type="one_time"` or `interval_seconds=0`, mark the task as `done`; otherwise add `interval_seconds` to `scheduled_at_epoch` and keep it `pending`.
92
 
93
  Because everything is stored as epoch timestamps, the worker only does integer comparisons and additions.
94
 
 
20
 
21
  ## Rowmind MCP Server
22
 
23
+ ### 🧠 How it Works
24
+
25
+ ```mermaid
26
+ graph LR
27
+ User[User / Chat Client] <-->|MCP Protocol| Server[Rowmind Server]
28
+ Server <-->|Reads/Writes| CSVs[(Local CSV Files)]
29
+
30
+ subgraph "Your Machine / Private Space"
31
+ Server
32
+ CSVs
33
+ end
34
+
35
+ style User fill:#f9f,stroke:#333,stroke-width:2px
36
+ style Server fill:#bbf,stroke:#333,stroke-width:2px
37
+ style CSVs fill:#bfb,stroke:#333,stroke-width:2px
38
+ ```
39
+
40
+ ### 🚀 Why Rowmind?
41
+
42
+ ```mermaid
43
+ mindmap
44
+ root((Rowmind))
45
+ Privacy
46
+ Data lives locally
47
+ You own your memory
48
+ No external DB
49
+ Simplicity
50
+ Plain text CSVs
51
+ Easy to backup
52
+ Easy to edit
53
+ Control
54
+ Custom Tables
55
+ Your own Schema
56
+ Hackable
57
+ ```
58
+
59
+
60
  ### Idea
61
 
62
  Rowmind is a small local “memory service” that you plug into any MCP‑compatible chat client (ChatGPT, Claude, Cursor, etc.).
 
117
 
118
  The same pattern works for expenses, mood logs, trips, or any other table you define.
119
 
120
+ ### Reminders & Notifications 🔔
121
+
122
+ > **🧪 Hackathon Experiment**: This feature explores how an MCP server can go beyond just storing data—it can actively reach out to you when something needs attention. Instead of you asking "do I have any pending tasks?", Rowmind tells you.
123
+
124
+ #### Why ntfy?
125
+
126
+ We wanted the simplest possible way to send push notifications without building a mobile app or dealing with Firebase/APNs complexity. [ntfy](https://ntfy.sh) is perfect for this:
127
+
128
+ - **No account required** — just pick a topic name and subscribe
129
+ - **Works everywhere** — Android, iOS, web, CLI
130
+ - **Self-hostable** — if you need full control
131
+ - **HTTP-based** — a simple POST request sends a notification
132
+
133
+ This makes ntfy ideal for hackathon projects where you want real push notifications without the usual infrastructure overhead.
134
+
135
+ #### How it works
136
+
137
+ Rowmind runs a **background worker** (using APScheduler) that:
138
+
139
+ 1. Checks every 60 seconds for tasks where `scheduled_at_epoch <= now`
140
+ 2. Sends a push notification via ntfy with the task description
141
+ 3. Marks one-time tasks as `done`, or reschedules recurring tasks
142
+
143
+ ```text
144
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
145
+ │ tasks.csv │ --> │ Cron Worker │ --> │ ntfy │ --> 📱 Your phone
146
+ │ (pending) │ │ (every 60s) │ │ (HTTP) │
147
+ └─────────────┘ └─────────────┘ └─────────────┘
148
+ ```
149
+
150
+ #### Quick setup
151
+
152
+ 1. **Copy the environment template:**
153
+
154
+ ```bash
155
+ cp .env.example .env
156
+ ```
157
+
158
+ 2. **Generate a random topic name** (important for security):
159
+
160
+ ```bash
161
+ python -c "import uuid; print(f'rowmind-{uuid.uuid4()}')"
162
+ ```
163
+
164
+ 3. **Configure your `.env` file:**
165
+
166
+ ```env
167
+ # Required: Use a random topic name for security
168
+ NTFY_TOPIC=rowmind-your-random-topic-here
169
+
170
+ # Optional: ntfy server (defaults to https://ntfy.sh)
171
+ NTFY_SERVER=https://ntfy.sh
172
+
173
+ # Optional but recommended: Access token for authentication
174
+ NTFY_TOKEN=tk_your_access_token
175
+
176
+ # Optional: Check interval in seconds (default: 60)
177
+ CRON_INTERVAL_SECONDS=60
178
+ ```
179
+
180
+ 4. **Subscribe to your topic** in the ntfy app ([Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy), [iOS](https://apps.apple.com/app/ntfy/id1625396347), or [web](https://ntfy.sh))
181
+
182
+ 5. **Run the app** — the scheduler starts automatically:
183
+
184
+ ```bash
185
+ python app.py
186
+ ```
187
+
188
+ #### Security considerations
189
 
190
+ Since this is a public repository:
191
 
192
+ - **Never commit your `.env` file** (it's in `.gitignore`)
193
+ - Use a **random/unguessable topic name** — anyone who knows your topic can send you notifications
194
+ - Consider using **ntfy access tokens** for authentication
195
+ - For production use, run your own [self-hosted ntfy server](https://docs.ntfy.sh/install/)
 
196
 
197
  Because everything is stored as epoch timestamps, the worker only does integer comparisons and additions.
198
 
app.py CHANGED
@@ -2,6 +2,9 @@ import os
2
  from typing import List, Dict
3
 
4
  import gradio as gr
 
 
 
5
  from helper import (
6
  get_csv_path,
7
  get_current_time_epoch,
@@ -411,5 +414,12 @@ with gr.Blocks(title="Rowmind MCP Server") as demo:
411
  api_name="update_row",
412
  )
413
 
 
 
 
414
  if __name__ == "__main__":
 
 
 
 
415
  demo.launch(mcp_server=True)
 
2
  from typing import List, Dict
3
 
4
  import gradio as gr
5
+ from dotenv import load_dotenv
6
+
7
+ from cron import start_scheduler, is_scheduler_running
8
  from helper import (
9
  get_csv_path,
10
  get_current_time_epoch,
 
414
  api_name="update_row",
415
  )
416
 
417
+ # Load environment variables
418
+ load_dotenv()
419
+
420
  if __name__ == "__main__":
421
+ # Start the background cron scheduler for task notifications
422
+ if not is_scheduler_running():
423
+ start_scheduler()
424
+
425
  demo.launch(mcp_server=True)
cron.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cron worker for Rowmind task notifications.
3
+
4
+ This module provides a background scheduler that:
5
+ 1. Checks for tasks with scheduled_at_epoch <= current time
6
+ 2. Sends notifications via ntfy
7
+ 3. Updates task status (done for one-time, recalculates next run for recurring)
8
+
9
+ Uses APScheduler as recommended by Gradio for background tasks.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ import time
15
+ from typing import Optional
16
+
17
+ import httpx
18
+ from dotenv import load_dotenv
19
+ from apscheduler.schedulers.background import BackgroundScheduler
20
+
21
+ from helper import (
22
+ execute_sql_query,
23
+ get_csv_path,
24
+ update_row_in_csv,
25
+ )
26
+
27
+ # Load environment variables
28
+ load_dotenv()
29
+
30
+ # Configure logging
31
+ logging.basicConfig(level=logging.INFO)
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # ntfy Configuration
35
+ NTFY_SERVER = os.getenv("NTFY_SERVER", "https://ntfy.sh")
36
+ NTFY_TOPIC = os.getenv("NTFY_TOPIC")
37
+ NTFY_TOKEN = os.getenv("NTFY_TOKEN") # Optional, for authentication
38
+
39
+ # Cron Configuration
40
+ CRON_INTERVAL_SECONDS = int(os.getenv("CRON_INTERVAL_SECONDS", "60"))
41
+
42
+ # Global scheduler instance
43
+ _scheduler: Optional[BackgroundScheduler] = None
44
+
45
+
46
+ def send_ntfy_notification(
47
+ message: str, priority: str = "default", tags: str = "bell"
48
+ ) -> bool:
49
+ """
50
+ Send a notification via ntfy.
51
+
52
+ Args:
53
+ message: The notification message body
54
+ priority: Priority level (min, low, default, high, urgent)
55
+ tags: Comma-separated emoji tags (e.g., "bell,warning")
56
+
57
+ Returns:
58
+ True if notification was sent successfully, False otherwise
59
+ """
60
+ if not NTFY_TOPIC:
61
+ logger.warning("NTFY_TOPIC not configured. Skipping notification.")
62
+ return False
63
+
64
+ url = f"{NTFY_SERVER.rstrip('/')}/{NTFY_TOPIC}"
65
+
66
+ headers = {
67
+ "Title": "Rowmind Task",
68
+ "Priority": priority,
69
+ "Tags": tags,
70
+ }
71
+
72
+ # Add authentication if token is configured
73
+ if NTFY_TOKEN:
74
+ headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
75
+
76
+ try:
77
+ response = httpx.post(
78
+ url,
79
+ content=message.encode("utf-8"),
80
+ headers=headers,
81
+ timeout=10.0,
82
+ )
83
+ response.raise_for_status()
84
+ logger.info("Notification sent successfully")
85
+ return True
86
+ except httpx.HTTPStatusError as e:
87
+ logger.error(
88
+ f"Failed to send notification (HTTP {e.response.status_code}): {e}"
89
+ )
90
+ return False
91
+ except httpx.RequestError as e:
92
+ logger.error(f"Failed to send notification (network error): {e}")
93
+ return False
94
+
95
+
96
+ def get_due_tasks() -> list[dict]:
97
+ """
98
+ Get all pending tasks that are due for notification.
99
+
100
+ Returns:
101
+ List of task dictionaries with rowid included
102
+ """
103
+ now = int(time.time())
104
+
105
+ query = f"""
106
+ SELECT *, rowid
107
+ FROM tasks
108
+ WHERE status = 'pending'
109
+ AND scheduled_at_epoch > 0
110
+ AND scheduled_at_epoch <= {now}
111
+ ORDER BY scheduled_at_epoch ASC
112
+ """
113
+
114
+ result = execute_sql_query(query)
115
+
116
+ # Filter out error responses
117
+ if result and "error" in result[0]:
118
+ logger.error(f"SQL error getting due tasks: {result[0]['error']}")
119
+ return []
120
+
121
+ return result
122
+
123
+
124
+ def process_task(task: dict) -> None:
125
+ """
126
+ Process a due task: send notification and update status.
127
+
128
+ Args:
129
+ task: Task dictionary from the database
130
+ """
131
+ description = task.get("description", "Task reminder")
132
+ task_id = task.get("task_id", "unknown")
133
+ schedule_type = task.get("schedule_type", "one_time")
134
+ interval_seconds = int(task.get("interval_seconds", 0) or 0)
135
+ rowid = task.get("rowid")
136
+
137
+ logger.info(f"Processing task {task_id}: {description}")
138
+
139
+ # Send notification
140
+ if not send_ntfy_notification(message=description, tags="memo,bell"):
141
+ logger.warning(f"Failed to notify for task {task_id}, will retry next cycle")
142
+ return
143
+
144
+ # Update task status
145
+ tasks_csv = get_csv_path("tasks")
146
+
147
+ if schedule_type == "recurring" and interval_seconds > 0:
148
+ # Recurring task: update next scheduled time
149
+ current_scheduled = int(task.get("scheduled_at_epoch", 0) or 0)
150
+ next_scheduled = current_scheduled + interval_seconds
151
+
152
+ update_row_in_csv(
153
+ tasks_csv,
154
+ key_field="rowid",
155
+ key_value=rowid,
156
+ update_data={"scheduled_at_epoch": str(next_scheduled)},
157
+ )
158
+ logger.info(f"Task {task_id} rescheduled for epoch {next_scheduled}")
159
+ else:
160
+ # One-time task: mark as done
161
+ update_row_in_csv(
162
+ tasks_csv,
163
+ key_field="rowid",
164
+ key_value=rowid,
165
+ update_data={"status": "done"},
166
+ )
167
+ logger.info(f"Task {task_id} marked as done")
168
+
169
+
170
+ def run_cron_job() -> None:
171
+ """
172
+ Main cron job function. Checks for due tasks and processes them.
173
+ """
174
+ logger.debug("Running cron job...")
175
+
176
+ due_tasks = get_due_tasks()
177
+
178
+ if not due_tasks:
179
+ logger.debug("No due tasks found")
180
+ return
181
+
182
+ logger.info(f"Found {len(due_tasks)} due task(s)")
183
+
184
+ for task in due_tasks:
185
+ try:
186
+ process_task(task)
187
+ except Exception as e:
188
+ logger.error(f"Error processing task {task.get('task_id', 'unknown')}: {e}")
189
+
190
+
191
+ def start_scheduler() -> BackgroundScheduler:
192
+ """
193
+ Start the background scheduler for the cron job.
194
+
195
+ Returns:
196
+ The started BackgroundScheduler instance
197
+ """
198
+ global _scheduler
199
+
200
+ if _scheduler is not None and _scheduler.running:
201
+ logger.warning("Scheduler already running")
202
+ return _scheduler
203
+
204
+ if not NTFY_TOPIC:
205
+ logger.warning(
206
+ "NTFY_TOPIC not configured. Set it in .env to enable notifications. "
207
+ "Scheduler will still run but notifications won't be sent."
208
+ )
209
+
210
+ _scheduler = BackgroundScheduler()
211
+ _scheduler.add_job(
212
+ func=run_cron_job,
213
+ trigger="interval",
214
+ seconds=CRON_INTERVAL_SECONDS,
215
+ id="rowmind_task_notifier",
216
+ replace_existing=True,
217
+ )
218
+ _scheduler.start()
219
+
220
+ logger.info(
221
+ f"Cron scheduler started. Checking for due tasks every {CRON_INTERVAL_SECONDS} seconds."
222
+ )
223
+
224
+ return _scheduler
225
+
226
+
227
+ def stop_scheduler() -> None:
228
+ """Stop the background scheduler if running."""
229
+ global _scheduler
230
+
231
+ if _scheduler is not None and _scheduler.running:
232
+ _scheduler.shutdown(wait=False)
233
+ logger.info("Cron scheduler stopped")
234
+
235
+ _scheduler = None
236
+
237
+
238
+ def is_scheduler_running() -> bool:
239
+ """Check if the scheduler is currently running."""
240
+ return _scheduler is not None and _scheduler.running
241
+
242
+
243
+ # For standalone testing
244
+ if __name__ == "__main__":
245
+ import time
246
+
247
+ print("Starting cron worker in standalone mode...")
248
+ print(f"NTFY_SERVER: {NTFY_SERVER}")
249
+ print(f"NTFY_TOPIC: {NTFY_TOPIC or '(not configured)'}")
250
+ print(f"CRON_INTERVAL_SECONDS: {CRON_INTERVAL_SECONDS}")
251
+
252
+ scheduler = start_scheduler()
253
+
254
+ try:
255
+ # Run immediately once for testing
256
+ run_cron_job()
257
+
258
+ # Keep the main thread alive
259
+ while True:
260
+ time.sleep(1)
261
+ except KeyboardInterrupt:
262
+ print("\nStopping scheduler...")
263
+ stop_scheduler()
264
+ print("Done.")
pyproject.toml CHANGED
@@ -5,6 +5,7 @@ description = "MCP server"
5
  readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
 
8
  "duckdb>=1.4.2",
9
  "gradio[mcp,oauth]>=5.49.1",
10
  "huggingface-hub>=1.1.4",
 
5
  readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
+ "apscheduler>=3.11.0",
9
  "duckdb>=1.4.2",
10
  "gradio[mcp,oauth]>=5.49.1",
11
  "huggingface-hub>=1.1.4",
requirements.txt CHANGED
@@ -14,6 +14,8 @@ anyio==4.11.0
14
  # openai
15
  # sse-starlette
16
  # starlette
 
 
17
  attrs==25.4.0
18
  # via
19
  # jsonschema
@@ -260,6 +262,8 @@ typing-inspection==0.4.2
260
  # pydantic-settings
261
  tzdata==2025.2
262
  # via pandas
 
 
263
  urllib3==2.5.0
264
  # via requests
265
  uvicorn==0.38.0
 
14
  # openai
15
  # sse-starlette
16
  # starlette
17
+ apscheduler==3.11.1
18
+ # via rowmind (pyproject.toml)
19
  attrs==25.4.0
20
  # via
21
  # jsonschema
 
262
  # pydantic-settings
263
  tzdata==2025.2
264
  # via pandas
265
+ tzlocal==5.3.1
266
+ # via apscheduler
267
  urllib3==2.5.0
268
  # via requests
269
  uvicorn==0.38.0
uv.lock CHANGED
@@ -48,6 +48,18 @@ wheels = [
48
  { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
49
  ]
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  [[package]]
52
  name = "attrs"
53
  version = "25.4.0"
@@ -1727,6 +1739,7 @@ name = "rowmind"
1727
  version = "0.1.0"
1728
  source = { virtual = "." }
1729
  dependencies = [
 
1730
  { name = "duckdb" },
1731
  { name = "gradio", extra = ["mcp", "oauth"] },
1732
  { name = "huggingface-hub" },
@@ -1736,6 +1749,7 @@ dependencies = [
1736
 
1737
  [package.metadata]
1738
  requires-dist = [
 
1739
  { name = "duckdb", specifier = ">=1.4.2" },
1740
  { name = "gradio", extras = ["mcp", "oauth"], specifier = ">=5.49.1" },
1741
  { name = "huggingface-hub", specifier = ">=1.1.4" },
@@ -2070,6 +2084,18 @@ wheels = [
2070
  { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
2071
  ]
2072
 
 
 
 
 
 
 
 
 
 
 
 
 
2073
  [[package]]
2074
  name = "urllib3"
2075
  version = "2.5.0"
 
48
  { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
49
  ]
50
 
51
+ [[package]]
52
+ name = "apscheduler"
53
+ version = "3.11.1"
54
+ source = { registry = "https://pypi.org/simple" }
55
+ dependencies = [
56
+ { name = "tzlocal" },
57
+ ]
58
+ sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" }
59
+ wheels = [
60
+ { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" },
61
+ ]
62
+
63
  [[package]]
64
  name = "attrs"
65
  version = "25.4.0"
 
1739
  version = "0.1.0"
1740
  source = { virtual = "." }
1741
  dependencies = [
1742
+ { name = "apscheduler" },
1743
  { name = "duckdb" },
1744
  { name = "gradio", extra = ["mcp", "oauth"] },
1745
  { name = "huggingface-hub" },
 
1749
 
1750
  [package.metadata]
1751
  requires-dist = [
1752
+ { name = "apscheduler", specifier = ">=3.11.0" },
1753
  { name = "duckdb", specifier = ">=1.4.2" },
1754
  { name = "gradio", extras = ["mcp", "oauth"], specifier = ">=5.49.1" },
1755
  { name = "huggingface-hub", specifier = ">=1.1.4" },
 
2084
  { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
2085
  ]
2086
 
2087
+ [[package]]
2088
+ name = "tzlocal"
2089
+ version = "5.3.1"
2090
+ source = { registry = "https://pypi.org/simple" }
2091
+ dependencies = [
2092
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
2093
+ ]
2094
+ sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
2095
+ wheels = [
2096
+ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
2097
+ ]
2098
+
2099
  [[package]]
2100
  name = "urllib3"
2101
  version = "2.5.0"