gcharanteja commited on
Commit
54f0a75
Β·
1 Parent(s): a608a37

Refactor project structure and enhance functionality

Browse files

- Update .gitignore to exclude model files and sensitive data.
- Modify Dockerfile to remove .env from the image for security.
- Revise README.md to clarify project purpose and deployment instructions.
- Implement main.py as the orchestrator for service management with detailed status endpoints.
- Create shared_state.py for managing shared variables between components.
- Refactor telegram_agent.py to improve message handling and initialization of components.

Files changed (6) hide show
  1. .gitignore +2 -0
  2. Dockerfile +1 -0
  3. README.md +65 -4
  4. main.py +287 -22
  5. shared_state.py +8 -0
  6. telegram_agent.py +198 -336
.gitignore CHANGED
@@ -3,5 +3,7 @@
3
  .venv/
4
  __pycache__/
5
  *.pyc
 
 
6
  .qwen
7
 
 
3
  .venv/
4
  __pycache__/
5
  *.pyc
6
+ models/
7
+ *.gguf
8
  .qwen
9
 
Dockerfile CHANGED
@@ -20,6 +20,7 @@ RUN uv sync --frozen --no-dev
20
 
21
  # Copy application files
22
  COPY --chown=user . .
 
23
 
24
  # Run main.py
25
  CMD ["uv", "run", "main.py"]
 
20
 
21
  # Copy application files
22
  COPY --chown=user . .
23
+ RUN rm -f .env # never include .env in Docker image (use HF Space secrets)
24
 
25
  # Run main.py
26
  CMD ["uv", "run", "main.py"]
README.md CHANGED
@@ -7,10 +7,71 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Mann - Terminal Loading Animation
11
 
12
- Infinite loading animation using tqdm, visible in Hugging Face Spaces logs.
13
 
14
- ## Deployment
15
 
16
- This space uses Docker. The loading animation runs continuously and can be viewed in the Space logs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # Mann β€” Jira Sprint Manager
11
 
12
+ AI-powered Jira sprint management via Telegram. Uses a local LLM (Phi-4-mini) with MCP tools to interact with Jira.
13
 
14
+ ## Architecture
15
 
16
+ ```
17
+ main.py (FastAPI orchestrator, port 7860)
18
+ β”œβ”€β”€ server.py β†’ Jira REST API (subprocess, port 8001)
19
+ β”œβ”€β”€ jira_mcp_server.py β†’ MCP tool server (stdio)
20
+ └── telegram_agent.py β†’ Telegram bot + LLM (background thread)
21
+ β”œβ”€β”€ Phi-4-mini (llama-cpp) β€” loaded from HF cache
22
+ β”œβ”€β”€ sentence-transformers β€” local embeddings
23
+ └── Pinecone β€” conversation memory
24
+ ```
25
+
26
+ ## Endpoints
27
+
28
+ | Method | Path | Description |
29
+ |--------|------|-------------|
30
+ | `POST` | `/start` | Trigger service startup |
31
+ | `GET` | `/status` | Overall health of all services |
32
+ | `GET` | `/status/server` | Jira REST API status |
33
+ | `GET` | `/status/mcp` | MCP server + tool list |
34
+ | `GET` | `/status/telegram` | Telegram bot + LLM status |
35
+ | `*` | `/jira/*` | Proxy to Jira REST API |
36
+
37
+ ## Environment Variables
38
+
39
+ Set these in HF Spaces β†’ Settings β†’ Repository secrets:
40
+
41
+ | Variable | Description |
42
+ |----------|-------------|
43
+ | `JIRA_BASE_URL` | Your Jira instance URL (e.g. `https://your-domain.atlassian.net`) |
44
+ | `JIRA_EMAIL` | Jira account email |
45
+ | `JIRA_API_TOKEN` | Jira API token |
46
+ | `JIRA_PROJECT_KEY` | Project key (default: `SCRUM`) |
47
+ | `JIRA_BOARD_ID` | Board ID (default: `1`) |
48
+ | `TELEGRAM_TOKEN` | Telegram bot token from @BotFather |
49
+ | `PINECONE_API_KEY` | Pinecone API key for conversation memory |
50
+ | `PINECONE_INDEX` | Pinecone index name (default: `jira-agent-memory`) |
51
+
52
+ ## Local Development
53
+
54
+ ```bash
55
+ # Install dependencies
56
+ uv sync
57
+
58
+ # Set environment variables
59
+ cp .env.example .env # edit with your credentials
60
+
61
+ # Run the unified orchestrator
62
+ uv run main.py
63
+
64
+ # Or run Telegram agent standalone (requires server.py already running)
65
+ uv run telegram_agent.py
66
+ ```
67
+
68
+ ## HF Spaces Deployment
69
+
70
+ 1. Push code to this repo
71
+ 2. Set all environment variables in Space settings
72
+ 3. Space auto-starts via `Dockerfile` β†’ `main.py`
73
+ 4. Check `/status` endpoint to verify all services are live
74
+
75
+ ## Model
76
+
77
+ Phi-4-mini (Q4_K_M, ~2.2GB) is downloaded from HuggingFace to `~/.cache/huggingface` β€” this does **not** count against the 1GB app storage limit on HF Spaces.
main.py CHANGED
@@ -1,40 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import time
 
 
2
  import threading
3
- import os
4
- from tqdm import tqdm
5
- from fastapi import FastAPI
 
 
 
6
  import uvicorn
7
 
8
- app = FastAPI()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- def loading_animation():
11
- """Infinite loading animation running in background."""
 
 
 
 
 
 
 
 
12
  try:
13
- for i in tqdm(range(1000000), desc="Loading NINININININININ", unit="%", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}"):
14
- time.sleep(0.01)
15
- except KeyboardInterrupt:
16
- print("\nLoading interrupted!")
 
 
 
 
17
 
18
- @app.get("/health")
19
- def health():
20
- return {"status": "ok", "message": "Server is running"}
21
 
 
 
 
 
 
 
22
 
23
- @app.get("/healthz")
24
- def healthz():
25
- return {"status": "ok"}
26
 
 
 
 
 
 
 
 
27
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- def main():
30
- port = int(os.environ.get("PORT", "7860"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- # Start loading animation in background thread
33
- loading_thread = threading.Thread(target=loading_animation, daemon=True)
34
- loading_thread.start()
35
 
36
- # Start FastAPI server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  uvicorn.run(app, host="0.0.0.0", port=port)
38
 
 
39
  if __name__ == "__main__":
40
  main()
 
1
+ """
2
+ Unified Jira Sprint Manager β€” Orchestrator
3
+ ============================================
4
+ Single entry point for HF Spaces. Starts all services and exposes status endpoints.
5
+
6
+ Endpoints:
7
+ POST /start β€” Start all services (server, MCP, Telegram bot)
8
+ GET /status β€” Overall health of all services
9
+ GET /status/server β€” Check if server.py (Jira REST API) is live
10
+ GET /status/mcp β€” Check if MCP server is live
11
+ GET /status/telegram β€” Check if Telegram bot + LLM are live
12
+
13
+ Plus all server.py routes mounted under /jira/*
14
+ """
15
+
16
+ import os
17
+ import sys
18
  import time
19
+ import json
20
+ import asyncio
21
  import threading
22
+ import subprocess
23
+ import requests
24
+ from contextlib import asynccontextmanager
25
+
26
+ from fastapi import FastAPI, HTTPException
27
+ from pydantic import BaseModel
28
  import uvicorn
29
 
30
+ # ── Service state ─────────────────────────────────────────────────────────────
31
+ class ServiceState:
32
+ def __init__(self):
33
+ self.server_started = False
34
+ self.server_process = None
35
+ self.telegram_started = False
36
+ self.all_started = False
37
+ self.error = None
38
+
39
+ state = ServiceState()
40
+
41
+ # ── Refs for status checking (set by telegram_agent) ─────────────────────────
42
+ mcp_session_ref = None
43
+ openai_tools_ref = None
44
+ telegram_bot_ref = None
45
+ llm_ref = None
46
+
47
+ # ── Config ────────────────────────────────────────────────────────────────────
48
+ SERVER_PORT = int(os.environ.get("SERVER_PORT", "8001"))
49
+ MAIN_PORT = int(os.environ.get("PORT", "7860"))
50
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
51
+
52
+
53
+ # ── Start server.py as subprocess ─────────────────────────────────────────────
54
+ def start_jira_server():
55
+ """Launch server.py (FastAPI Jira REST API) as a background subprocess."""
56
+ server_script = os.path.join(BASE_DIR, "server.py")
57
+ if not os.path.exists(server_script):
58
+ state.error = f"server.py not found at {server_script}"
59
+ return False
60
+
61
+ print(f"\n[orchestrator] Starting Jira REST API server on port {SERVER_PORT}...")
62
+ env = {**os.environ, "PORT": str(SERVER_PORT)}
63
+ proc = subprocess.Popen(
64
+ [sys.executable, server_script],
65
+ env=env,
66
+ stdout=subprocess.PIPE,
67
+ stderr=subprocess.STDOUT,
68
+ text=True,
69
+ )
70
+ state.server_process = proc
71
+
72
+ # Wait for server to be ready
73
+ for attempt in range(30):
74
+ time.sleep(1)
75
+ poll = proc.poll()
76
+ if poll is not None:
77
+ state.error = f"server.py exited early (code={poll})"
78
+ if proc.stdout:
79
+ print(proc.stdout.read())
80
+ return False
81
+ try:
82
+ r = requests.get(f"http://localhost:{SERVER_PORT}/", timeout=2)
83
+ if r.status_code == 200:
84
+ state.server_started = True
85
+ print(f"[orchestrator] Jira REST API server is live on port {SERVER_PORT}")
86
+ return True
87
+ except requests.RequestException:
88
+ continue
89
+
90
+ state.error = "server.py failed to start within 30 seconds"
91
+ return False
92
+
93
 
94
+ # ── Start Telegram + MCP in background thread ─────────────────────────────────
95
+ def start_telegram_and_mcp():
96
+ """Run the Telegram agent + MCP server in a dedicated event loop."""
97
+ import shared_state
98
+ from telegram_agent import (
99
+ start_telegram_with_mcp, _init_telegram_bot, _init_llm,
100
+ _init_embedder, _init_pinecone, bot, llm,
101
+ )
102
+
103
+ # Initialize components to detect LLM
104
  try:
105
+ _init_embedder()
106
+ _init_llm()
107
+ _init_pinecone()
108
+ _init_telegram_bot()
109
+ except Exception as e:
110
+ state.error = f"Failed to init components: {str(e)}"
111
+ print(f"[orchestrator] ERROR: {state.error}")
112
+ return
113
 
114
+ # Set refs
115
+ shared_state.llm_instance = llm
116
+ shared_state.telegram_bot = bot
117
 
118
+ # Run the MCP + Telegram loop
119
+ try:
120
+ asyncio.run(start_telegram_with_mcp())
121
+ except Exception as e:
122
+ state.error = f"Telegram/MCP failed: {str(e)}"
123
+ print(f"[orchestrator] ERROR: {state.error}")
124
 
 
 
 
125
 
126
+ # ── Lifespan for async startup ───────────────────────────────────────────────
127
+ @asynccontextmanager
128
+ async def lifespan(app: FastAPI):
129
+ """Start all services on app startup."""
130
+ print("\n" + "=" * 60)
131
+ print(" Jira Sprint Manager β€” Unified Orchestrator")
132
+ print("=" * 60)
133
 
134
+ # 1. Start server.py
135
+ if not start_jira_server():
136
+ print(f"[orchestrator] ERROR: Failed to start server.py: {state.error}")
137
+ else:
138
+ # 2. Start Telegram + MCP in background thread
139
+ print("[orchestrator] Starting Telegram bot + MCP server...")
140
+ tg_thread = threading.Thread(target=start_telegram_and_mcp, daemon=True)
141
+ tg_thread.start()
142
+ state.telegram_started = True
143
+ state.all_started = True
144
 
145
+ yield
146
+
147
+ # Shutdown
148
+ print("\n[orchestrator] Shutting down...")
149
+ if state.server_process:
150
+ state.server_process.terminate()
151
+ state.server_process.wait(timeout=5)
152
+
153
+
154
+ # ── FastAPI App ───────────────────────────────────────────────────────────────
155
+ app = FastAPI(
156
+ title="Jira Sprint Manager",
157
+ description="Unified orchestrator for Jira Sprint Management (REST API + MCP + Telegram Bot)",
158
+ version="2.0.0",
159
+ lifespan=lifespan,
160
+ )
161
+
162
+
163
+ # ── Status Endpoints ─────────────────────────────────────────────────────────
164
+
165
+ @app.get("/status/server")
166
+ def status_server():
167
+ """Check if the Jira REST API server (server.py) is live."""
168
+ if not state.server_started:
169
+ return {"service": "jira_rest_api", "status": "down", "port": SERVER_PORT,
170
+ "error": state.error or "Not started yet"}
171
+ try:
172
+ r = requests.get(f"http://localhost:{SERVER_PORT}/", timeout=3)
173
+ return {"service": "jira_rest_api", "status": "live" if r.status_code == 200 else "degraded",
174
+ "port": SERVER_PORT, "response": r.json() if r.status_code == 200 else None}
175
+ except requests.RequestException as e:
176
+ return {"service": "jira_rest_api", "status": "unreachable", "port": SERVER_PORT, "error": str(e)}
177
+
178
+
179
+ @app.get("/status/mcp")
180
+ def status_mcp():
181
+ """Check if the MCP server is live and how many tools are available."""
182
+ import shared_state
183
+ tools = shared_state.openai_tools
184
+ session = shared_state.mcp_session
185
+
186
+ if session is None or tools is None:
187
+ return {"service": "mcp_server", "status": "down", "tools_count": 0,
188
+ "tools": [], "error": "MCP server not initialized"}
189
+ return {"service": "mcp_server", "status": "live", "tools_count": len(tools),
190
+ "tools": [t["function"]["name"] for t in tools]}
191
 
 
 
 
192
 
193
+ @app.get("/status/telegram")
194
+ def status_telegram():
195
+ """Check if the Telegram bot is running and LLM is loaded."""
196
+ import shared_state
197
+ b = shared_state.telegram_bot
198
+ l = shared_state.llm_instance
199
+
200
+ bot_status = "live" if b is not None else "down"
201
+ llm_status = "loaded" if l is not None else "not_loaded"
202
+
203
+ model_path = os.environ.get("MODEL_PATH", os.path.join(BASE_DIR, "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf"))
204
+ model_exists = os.path.exists(model_path)
205
+
206
+ return {"service": "telegram_bot", "bot_status": bot_status, "llm_status": llm_status,
207
+ "model_path": model_path, "model_downloaded": model_exists}
208
+
209
+
210
+ @app.get("/status")
211
+ def status_all():
212
+ """Overall health check of all services."""
213
+ server = status_server()
214
+ mcp = status_mcp()
215
+ telegram = status_telegram()
216
+
217
+ all_up = (server.get("status") == "live" and mcp.get("status") == "live" and
218
+ telegram.get("bot_status") == "live" and telegram.get("llm_status") == "loaded")
219
+
220
+ return {
221
+ "overall_status": "healthy" if all_up else "partial" if state.all_started else "starting",
222
+ "services": {"jira_rest_api": server, "mcp_server": mcp, "telegram_bot": telegram},
223
+ }
224
+
225
+
226
+ class StartRequest(BaseModel):
227
+ download_model: bool = True
228
+
229
+
230
+ @app.post("/start")
231
+ def start_services(req: StartRequest = None):
232
+ """
233
+ Manually trigger service startup (useful if lifespan didn't auto-start).
234
+ Returns immediately β€” services run in background.
235
+ """
236
+ if state.all_started:
237
+ return {"status": "already_started", "message": "All services are already running"}
238
+
239
+ # Start server.py
240
+ if not state.server_started:
241
+ if not start_jira_server():
242
+ raise HTTPException(status_code=500, detail=f"Failed to start server.py: {state.error}")
243
+
244
+ # Start Telegram + MCP
245
+ if not state.telegram_started:
246
+ tg_thread = threading.Thread(target=start_telegram_and_mcp, daemon=True)
247
+ tg_thread.start()
248
+ state.telegram_started = True
249
+ state.all_started = True
250
+
251
+ return {"status": "starting", "message": "Services starting. Poll /status to check progress."}
252
+
253
+
254
+ # ── Proxy: Mount server.py routes under /jira/* ──────────────────────────────
255
+ from fastapi import Request as FastAPIRequest
256
+ from fastapi.responses import JSONResponse
257
+
258
+
259
+ @app.api_route("/jira/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
260
+ async def proxy_jira(path: str, request: FastAPIRequest):
261
+ """Proxy requests to the Jira REST API server."""
262
+ if not state.server_started:
263
+ raise HTTPException(status_code=503, detail="Jira REST API server not started")
264
+
265
+ body = await request.body() if request.method in ("POST", "PUT", "PATCH") else None
266
+
267
+ try:
268
+ r = requests.request(
269
+ method=request.method, url=f"http://localhost:{SERVER_PORT}/{path}",
270
+ headers={k: v for k, v in request.headers.items() if k.lower() != "host"},
271
+ params=dict(request.query_params), data=body, timeout=30,
272
+ )
273
+ return JSONResponse(content=r.json(), status_code=r.status_code)
274
+ except requests.RequestException as e:
275
+ raise HTTPException(status_code=502, detail=f"Failed to reach Jira server: {str(e)}")
276
+
277
+
278
+ # ── Root ──────────────────────────────────────────────────────────────────────
279
+ @app.get("/")
280
+ def root():
281
+ return {
282
+ "service": "Jira Sprint Manager",
283
+ "version": "2.0.0",
284
+ "status": "healthy" if state.all_started else "starting",
285
+ "endpoints": {
286
+ "POST /start": "Start all services",
287
+ "GET /status": "Overall health check",
288
+ "GET /status/server": "Jira REST API status",
289
+ "GET /status/mcp": "MCP server status",
290
+ "GET /status/telegram": "Telegram bot + LLM status",
291
+ "GET/POST /jira/*": "Proxy to Jira REST API",
292
+ },
293
+ }
294
+
295
+
296
+ # ── Main ──────────────────────────────────────────────────────────────────────
297
+ def main():
298
+ port = int(os.environ.get("PORT", "7860"))
299
+ print(f"\n[orchestrator] Starting unified FastAPI server on port {port}")
300
+ print(f"[orchestrator] Jira REST API will run on port {SERVER_PORT}\n")
301
  uvicorn.run(app, host="0.0.0.0", port=port)
302
 
303
+
304
  if __name__ == "__main__":
305
  main()
shared_state.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared state between main.py and telegram_agent.py
3
+ """
4
+
5
+ mcp_session = None
6
+ openai_tools = None
7
+ telegram_bot = None
8
+ llm_instance = None
telegram_agent.py CHANGED
@@ -8,19 +8,22 @@ Stack:
8
  Pinecone β†’ stores every turn + tool call + tool result
9
  Telegram (pyTelegramBotAPI) β†’ chat interface
10
 
11
- Run order:
12
- Terminal 1: python jira_mcp_server.py (keep running)
13
- Terminal 2: python telegram_agent.py (Telegram bot)
 
 
 
14
  """
15
 
16
  import os
17
  import sys
18
  import json
19
  import uuid
20
- import time
21
  import asyncio
22
  import datetime
23
  import threading
 
24
  from llama_cpp import Llama
25
  from sentence_transformers import SentenceTransformer
26
  from pinecone import Pinecone, ServerlessSpec
@@ -35,106 +38,96 @@ load_dotenv()
35
  TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN")
36
  PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY")
37
  PINECONE_INDEX = os.environ.get("PINECONE_INDEX", "jira-agent-memory")
38
-
39
  MCP_SERVER_SCRIPT = os.path.join(os.path.dirname(__file__), "jira_mcp_server.py")
40
-
41
  EMBED_DIM = 768
42
-
43
  MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf")
44
 
45
- if not TELEGRAM_TOKEN:
46
- raise ValueError("Missing required environment variable: TELEGRAM_TOKEN")
47
- if not PINECONE_API_KEY:
48
- raise ValueError("Missing required environment variable: PINECONE_API_KEY")
 
 
 
 
 
 
49
 
50
- # ── Telegram bot ─────────────────────────────────────────────────────────────
51
- bot = telebot.TeleBot(TELEGRAM_TOKEN, parse_mode=None)
 
 
 
 
 
 
 
52
 
53
- # Per-user conversation state: {chat_id: {"session": ClientSession, "openai_tools": [...], "messages": [...]}}
54
- user_sessions = {}
55
- loop_ref = None # will hold the asyncio event loop
56
 
57
- # ── local embedder ────────────────────────────────────────────────────────────
58
- print(" Loading embedding model...")
59
- _embedder = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cpu")
60
- print(" Embedding model ready.\n")
 
 
 
 
 
61
 
62
  def embed(text: str) -> list[float]:
63
  vec = _embedder.encode(text[:8000], normalize_embeddings=True)
64
  return vec.tolist()
65
 
66
- # ── local model loader ───────────────────────────────────────────────────────
67
- if not os.path.exists(MODEL_PATH):
68
- print(f" Model not found at {MODEL_PATH}")
69
- print(" Downloading from HuggingFace (bartowski/Phi-4-mini-instruct-GGUF)...\n")
70
- print(" This is ~2.2 GB β€” may take a few minutes depending on your connection.\n")
71
- models_dir = os.path.dirname(MODEL_PATH)
72
- os.makedirs(models_dir, exist_ok=True)
 
73
  from huggingface_hub import hf_hub_download
74
- downloaded_path = hf_hub_download(
 
75
  repo_id="bartowski/Phi-4-mini-instruct-GGUF",
76
  filename="Phi-4-mini-instruct-Q4_K_M.gguf",
77
- local_dir=models_dir,
78
- local_dir_use_symlinks=False,
79
- )
80
- # Move to expected path if needed
81
- if downloaded_path != MODEL_PATH and not os.path.exists(MODEL_PATH):
82
- import shutil
83
- shutil.move(downloaded_path, MODEL_PATH)
84
- print("\n Download complete.\n")
85
-
86
- print(" Loading local model...")
87
- print(f" Model: {MODEL_PATH}")
88
- print(" This may take 10-30 seconds on first run...\n")
89
-
90
- llm = Llama(
91
- model_path=MODEL_PATH,
92
- n_ctx=8192,
93
- n_threads=4,
94
- n_batch=512,
95
- n_gpu_layers=0,
96
- verbose=False,
97
- temperature=0.2,
98
- )
99
- print(" Model loaded successfully.\n")
100
-
101
- # ── pinecone ──────────────────────────────────────────────────────────────────
102
- pc = Pinecone(api_key=PINECONE_API_KEY)
103
- existing = [i.name for i in pc.list_indexes()]
104
- if PINECONE_INDEX not in existing:
105
- print(f" Creating Pinecone index '{PINECONE_INDEX}' (dim={EMBED_DIM})...")
106
- pc.create_index(
107
- name=PINECONE_INDEX,
108
- dimension=EMBED_DIM,
109
- metric="cosine",
110
- spec=ServerlessSpec(cloud="aws", region="us-east-1"),
111
  )
112
- print(" Index created.\n")
113
-
114
- pine_index = pc.Index(PINECONE_INDEX)
115
 
116
-
117
- def store(user_input: str, agent_reply: str, tool_name: str,
118
- tool_args: dict, tool_result: dict):
119
- doc = (
120
- f"User: {user_input}\n"
121
- f"Agent: {agent_reply}\n"
122
- f"Tool called: {tool_name}\n"
123
- f"Tool args: {json.dumps(tool_args)}\n"
124
- f"Tool result: {json.dumps(tool_result)}"
125
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  pine_index.upsert(vectors=[{
127
- "id": str(uuid.uuid4()),
128
- "values": embed(doc),
129
- "metadata": {
130
- "doc": doc,
131
- "user_input": user_input,
132
- "agent_reply": agent_reply,
133
- "tool_name": tool_name,
134
- "tool_args": json.dumps(tool_args),
135
- "tool_result": json.dumps(tool_result),
136
- "timestamp": datetime.datetime.utcnow().isoformat(),
137
- },
138
  }])
139
 
140
 
@@ -143,213 +136,116 @@ def recall(user_input: str, top_k: int = 2) -> str:
143
  total = stats.get("total_vector_count", 0)
144
  if total == 0:
145
  return ""
146
- results = pine_index.query(
147
- vector=embed(user_input),
148
- top_k=min(top_k, total),
149
- include_metadata=True,
150
- )
151
- matches = results.get("matches", [])
152
- if not matches:
153
- return ""
154
- chunks = [
155
- f"[past interaction]\n{m['metadata'].get('user_input', '')} β†’ {m['metadata'].get('tool_name', 'no tool')}"
156
- for m in matches if m.get('score', 0) > 0.5
157
- ]
158
- if not chunks:
159
- return ""
160
- return "\nRECENT SIMILAR REQUESTS (for context only, do NOT reuse old data):\n" + "\n---\n".join(chunks) + "\n"
161
 
162
 
163
- def mcp_tools_to_openai_schema(mcp_tools) -> list[dict]:
164
- tools = []
165
- for t in mcp_tools:
166
- tools.append({
167
- "type": "function",
168
- "function": {
169
- "name": t.name,
170
- "description": t.description or "",
171
- "parameters": t.inputSchema or {"type": "object", "properties": {}},
172
- },
173
- })
174
- return tools
175
 
176
 
177
- # ── agent turn (returns final reply string) ───────────────────────────────────
178
  async def agent_turn_async(session, openai_tools, user_input: str, chat_id: int) -> str:
179
- """Run one full agent turn. Returns the final reply for the user."""
180
-
181
  memory_block = recall(user_input)
182
-
183
  tool_descriptions = ""
184
  for t in openai_tools:
185
- params = t["function"].get("parameters", {})
186
- req = params.get("required", [])
187
- props = params.get("properties", {})
188
- param_str = ", ".join([f"{k}" + (" (required)" if k in req else "") for k in props.keys()]) if props else "none"
189
- tool_descriptions += f"- {t['function']['name']}: {t['function']['description']} | params: {param_str}\n"
190
-
191
- tool_descriptions += """
192
- IMPORTANT: For add_issues_to_sprint, sprint_id must be a NUMBER (integer), NOT "current".
193
- If you don't know the sprint_id, first call get_active_sprint to find the sprint ID, then use that number.
194
- """
195
-
196
- system_prompt = f"""You are an intelligent Jira Sprint Management AI Agent.
197
-
198
- You have these tools available to you. To call a tool, respond with ONLY a JSON object in this exact format:
199
- {{"tool": "<tool_name>", "arguments": {{"<param>": "<value>"}}}}
200
-
201
- Available tools:
202
- {tool_descriptions}
203
-
204
- RULES:
205
- 1. For ANY query about Jira data, you MUST call one of the tools above by responding with JSON.
206
- 2. Do NOT make up Jira data. Only report what the tool returns.
207
- 3. If the user asks for something that doesn't need a tool (e.g. greeting), just respond normally.
208
- 4. After the tool result comes back, summarize it concisely for the user.
209
- 5. Be concise.
210
 
211
- {memory_block}"""
 
 
 
212
 
213
  final_reply = ""
214
- tool_calls_parsed = []
215
  all_results = []
216
-
217
- # Build messages for this turn (do NOT pollute with old internal tool messages)
218
- messages = [
219
- {"role": "system", "content": system_prompt},
220
- {"role": "user", "content": user_input},
221
- ]
222
- # Add clean conversation history (only user/assistant exchanges, max 6 turns)
223
- user_sessions.setdefault(chat_id, {"messages": []})
224
- clean_history = []
225
- for m in user_sessions[chat_id]["messages"]:
226
- if m["role"] in ("user", "assistant"):
227
- clean_history.append(m)
228
- messages.extend(clean_history[-6:])
229
-
230
- tool_name = "none"
231
- tool_args = {}
232
- tool_result = {}
233
- final_reply = ""
234
  tool_calls_parsed = []
235
- all_results = []
236
 
237
- max_tool_rounds = 5
238
- for _ in range(max_tool_rounds):
239
  try:
240
- response = llm.create_chat_completion(
241
- messages=messages,
242
- max_tokens=1024,
243
- temperature=0.2,
244
- top_p=0.9,
245
- )
246
-
247
- choice = response['choices'][0]
248
- message = choice['message']
249
- response_text = message.get("content", "")
250
-
251
- # Strip <think> blocks
252
  if "<think>" in response_text and "</think>" in response_text:
253
- response_text = response_text[response_text.rfind("</think>") + len("</think>"):].strip()
254
 
255
- # Parse ONE or MORE JSON tool calls
256
  tool_calls_parsed = []
257
  remaining = response_text
258
  while True:
259
- json_start = remaining.find("{")
260
- if json_start < 0:
261
  break
262
- remaining = remaining[json_start:]
263
- depth = 0
264
- json_end = -1
265
  for i, ch in enumerate(remaining):
266
- if ch == "{":
267
- depth += 1
268
  elif ch == "}":
269
  depth -= 1
270
  if depth == 0:
271
- json_end = i + 1
272
  break
273
- if json_end < 0:
274
  break
275
  try:
276
- parsed = json.loads(remaining[:json_end])
277
  if "tool" in parsed:
278
  tool_calls_parsed.append(parsed)
279
  except json.JSONDecodeError:
280
  pass
281
- remaining = remaining[json_end:]
282
 
283
- # Model wants to call tools
284
  if tool_calls_parsed:
285
  all_results = []
286
  for tc in tool_calls_parsed:
287
- tool_name = tc.get("tool", "unknown")
288
- tool_args = tc.get("arguments", {})
289
-
290
- print(f"\n β†’ MCP tool: {tool_name}({json.dumps(tool_args)})")
291
-
292
- result = await session.call_tool(tool_name, arguments=tool_args)
293
-
294
- if result.content and len(result.content) > 0:
295
- raw_result = result.content[0].text if hasattr(result.content[0], "text") else str(result.content[0])
296
- try:
297
- tool_result = json.loads(raw_result)
298
- except json.JSONDecodeError:
299
- tool_result = {"raw": raw_result}
300
- else:
301
- tool_result = {}
302
-
303
- print(f" result: {json.dumps(tool_result, indent=2)}\n")
304
- all_results.append((tool_name, tool_result))
305
-
306
- messages.append({
307
- "role": "assistant",
308
- "content": response_text,
309
- })
310
-
311
- results_text_parts = []
312
- for tn, tr in all_results:
313
- results_text_parts.append(f"Tool {tn} returned: {json.dumps(tr)}")
314
-
315
- has_sprint_id_error = any(
316
- "sprint_id" in json.dumps(tr).lower() and ("integer" in json.dumps(tr).lower() or "int_parsing" in json.dumps(tr).lower())
317
- for _, tr in all_results
318
- )
319
- if has_sprint_id_error:
320
- messages.append({
321
- "role": "user",
322
- "content": f"{' | '.join(results_text_parts)}\n\nThe sprint_id must be a number. First call get_active_sprint to find the current sprint ID, then use that number.",
323
- })
324
  else:
325
- messages.append({
326
- "role": "user",
327
- "content": f"{' | '.join(results_text_parts)}\n\nNow summarize the results for the user concisely.",
328
- })
329
-
330
- # Model gave final text answer
331
  else:
332
  final_reply = response_text
333
  print(f"\n Agent: {final_reply}\n")
334
  break
335
-
336
  except Exception as e:
337
- err = str(e).lower()
338
- if "out of memory" in err:
339
- print(" Memory error...\n")
340
- return "Sorry, I ran into a memory error. Please try again."
341
- print(f" Model Error: {str(e)[:300]}\n")
342
- return f"Sorry, I ran into an error: {str(e)[:200]}"
343
-
344
- # Store in Pinecone
345
  if all_results:
346
- last_tool_name, last_tool_res = all_results[-1]
347
- last_tool_args_dict = tool_calls_parsed[-1].get("arguments", {}) if tool_calls_parsed else {}
348
- store(user_input, final_reply, last_tool_name, last_tool_args_dict, last_tool_res)
349
  else:
350
  store(user_input, final_reply, "none", {}, {})
351
 
352
- # Save conversation history (keep last 10 turns)
353
  user_sessions[chat_id]["messages"].append({"role": "user", "content": user_input})
354
  user_sessions[chat_id]["messages"].append({"role": "assistant", "content": final_reply})
355
  if len(user_sessions[chat_id]["messages"]) > 20:
@@ -359,121 +255,87 @@ RULES:
359
 
360
 
361
  # ── Telegram handlers ─────────────────────────────────────────────────────────
362
- @bot.message_handler(commands=['start'])
363
- def cmd_start(message):
364
- name = message.from_user.first_name or "there"
365
- bot.reply_to(message,
366
- f"Hey {name}! πŸ‘‹\n\n"
367
- f"I'm your Jira Sprint Manager. I can help you:\n"
368
- f"β€’ View the backlog\n"
369
- f"β€’ Check active sprint progress\n"
370
- f"β€’ Create stories/tasks\n"
371
- f"β€’ Move issues between sprints\n"
372
- f"β€’ Transition issue status (To Do β†’ In Progress β†’ Testing β†’ Done)\n"
373
- f"β€’ Close & rollover sprints\n\n"
374
- f"Just ask me anything!"
375
- )
376
-
377
-
378
- @bot.message_handler(commands=['memory'])
379
- def cmd_memory(message):
380
- """Search Pinecone memory."""
381
- parts = message.text.split(None, 1)
382
- if len(parts) < 2:
383
- bot.reply_to(message, "Usage: /memory <search query>")
384
- return
385
- query = parts[1]
386
- result = recall(query, top_k=5)
387
- if result:
388
- # Truncate if too long for Telegram
389
- reply = result[:4000]
390
- bot.reply_to(message, reply)
391
- else:
392
- bot.reply_to(message, "Nothing found in memory.")
393
-
394
-
395
- @bot.message_handler(func=lambda m: True)
396
- def handle_message(message):
397
- """Handle all other messages β€” run the agent."""
398
- user_input = message.text.strip()
399
- chat_id = message.chat.id
400
- username = message.from_user.first_name or message.from_user.username or "User"
401
-
402
- if not user_input:
403
- return
404
-
405
- # Log user message to terminal for debugging
406
- print(f"\n{'='*50}")
407
- print(f" Telegram [{username}]: {user_input}")
408
- print(f"{'='*50}\n")
409
-
410
- # Show "typing" indicator
411
- bot.send_chat_action(chat_id, "typing")
412
-
413
- # Schedule the async agent turn on the event loop
414
- future = asyncio.run_coroutine_threadsafe(
415
- agent_turn_async(mcp_session_ref, openai_tools_ref, user_input, chat_id),
416
- loop_ref,
417
- )
418
- reply = future.result(timeout=120) # wait up to 2 minutes
419
-
420
- # Send reply (split if too long for Telegram's 4096 char limit)
421
- chunks = [reply[i:i+4000] for i in range(0, len(reply), 4000)] if reply else ["..."]
422
- for chunk in chunks:
423
- bot.send_message(chat_id, chunk)
424
-
425
-
426
- # ── main entry: start MCP + Telegram ─────────────────────────────────────────
427
- mcp_session_ref = None
428
- openai_tools_ref = None
429
-
430
- async def main_async():
431
  global mcp_session_ref, openai_tools_ref, loop_ref
432
  loop_ref = asyncio.get_event_loop()
433
 
434
- server_params = StdioServerParameters(
435
- command=sys.executable,
436
- args=[MCP_SERVER_SCRIPT],
437
- env={**os.environ},
438
- )
 
 
 
439
 
440
  async with stdio_client(server_params) as (read, write):
441
  async with ClientSession(read, write) as session:
442
  await session.initialize()
443
-
444
- tools_response = await session.list_tools()
445
- mcp_tools = tools_response.tools
446
  openai_tools = mcp_tools_to_openai_schema(mcp_tools)
447
 
448
- # Store refs for the Telegram thread
 
 
 
 
 
449
  mcp_session_ref = session
450
  openai_tools_ref = openai_tools
451
 
452
  stats = pine_index.describe_index_stats()
453
- total = stats.get("total_vector_count", 0)
454
-
455
  print("=" * 60)
456
- print(" Jira Agent (Telegram + MCP) | Phi-4-Mini (local)")
457
- print(f" MCP tools | {[t.name for t in mcp_tools]}")
458
- print(f" Memory | {total} interactions in Pinecone")
459
  print("=" * 60 + "\n")
460
 
461
- # Start Telegram bot in a background thread
462
  def run_telegram():
463
- print(" Telegram bot started.\n")
464
  bot.infinity_polling()
465
 
466
- telegram_thread = threading.Thread(target=run_telegram, daemon=True)
467
- telegram_thread.start()
468
 
469
- # Keep the asyncio loop alive
470
  try:
471
  while True:
472
  await asyncio.sleep(1)
473
  except (KeyboardInterrupt, SystemExit):
474
- print("\nShutting down...")
475
  bot.stop_polling()
476
 
477
 
 
478
  if __name__ == "__main__":
479
- asyncio.run(main_async())
 
8
  Pinecone β†’ stores every turn + tool call + tool result
9
  Telegram (pyTelegramBotAPI) β†’ chat interface
10
 
11
+ Can be run standalone:
12
+ python telegram_agent.py
13
+
14
+ Or imported by main.py orchestrator:
15
+ from telegram_agent import start_telegram_with_mcp
16
+ asyncio.run(start_telegram_with_mcp())
17
  """
18
 
19
  import os
20
  import sys
21
  import json
22
  import uuid
 
23
  import asyncio
24
  import datetime
25
  import threading
26
+ from typing import Optional
27
  from llama_cpp import Llama
28
  from sentence_transformers import SentenceTransformer
29
  from pinecone import Pinecone, ServerlessSpec
 
38
  TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN")
39
  PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY")
40
  PINECONE_INDEX = os.environ.get("PINECONE_INDEX", "jira-agent-memory")
 
41
  MCP_SERVER_SCRIPT = os.path.join(os.path.dirname(__file__), "jira_mcp_server.py")
 
42
  EMBED_DIM = 768
 
43
  MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf")
44
 
45
+ # ── Globals (lazy-initialized) ────────────────────────────────────────────────
46
+ bot: Optional[telebot.TeleBot] = None
47
+ llm: Optional[Llama] = None
48
+ pine_index = None
49
+ _embedder = None
50
+ user_sessions = {}
51
+ loop_ref = None
52
+ mcp_session_ref = None
53
+ openai_tools_ref = None
54
+
55
 
56
+ # ── Lazy initializers ────────────────────────────────────────────────────────
57
+ def _init_telegram_bot():
58
+ global bot
59
+ if bot is not None:
60
+ return bot
61
+ if not TELEGRAM_TOKEN:
62
+ raise ValueError("Missing TELEGRAM_TOKEN")
63
+ bot = telebot.TeleBot(TELEGRAM_TOKEN, parse_mode=None)
64
+ return bot
65
 
 
 
 
66
 
67
+ def _init_embedder():
68
+ global _embedder
69
+ if _embedder is not None:
70
+ return _embedder
71
+ print(" Loading embedding model...")
72
+ _embedder = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cpu")
73
+ print(" Embedding model ready.\n")
74
+ return _embedder
75
+
76
 
77
  def embed(text: str) -> list[float]:
78
  vec = _embedder.encode(text[:8000], normalize_embeddings=True)
79
  return vec.tolist()
80
 
81
+
82
+ def _init_llm() -> Llama:
83
+ global llm
84
+ if llm is not None:
85
+ return llm
86
+
87
+ # Download to HF cache (does NOT count against HF Spaces 1GB app storage)
88
+ # The cache lives at ~/.cache/huggingface which is on a separate volume
89
  from huggingface_hub import hf_hub_download
90
+ print(" Resolving model from HuggingFace cache...\n")
91
+ cached_path = hf_hub_download(
92
  repo_id="bartowski/Phi-4-mini-instruct-GGUF",
93
  filename="Phi-4-mini-instruct-Q4_K_M.gguf",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  )
95
+ print(f" Model cached at: {cached_path}")
96
+ print(f" Loading model...\n")
 
97
 
98
+ llm = Llama(
99
+ model_path=cached_path, n_ctx=8192, n_threads=4, n_batch=512,
100
+ n_gpu_layers=0, verbose=False, temperature=0.2,
 
 
 
 
 
 
101
  )
102
+ print(" Model loaded.\n")
103
+ return llm
104
+
105
+
106
+ def _init_pinecone():
107
+ global pine_index
108
+ if pine_index is not None:
109
+ return pine_index
110
+ if not PINECONE_API_KEY:
111
+ raise ValueError("Missing PINECONE_API_KEY")
112
+ pc = Pinecone(api_key=PINECONE_API_KEY)
113
+ existing = [i.name for i in pc.list_indexes()]
114
+ if PINECONE_INDEX not in existing:
115
+ print(f" Creating Pinecone index '{PINECONE_INDEX}'...")
116
+ pc.create_index(name=PINECONE_INDEX, dimension=EMBED_DIM, metric="cosine",
117
+ spec=ServerlessSpec(cloud="aws", region="us-east-1"))
118
+ print(" Index created.\n")
119
+ pine_index = pc.Index(PINECONE_INDEX)
120
+ return pine_index
121
+
122
+
123
+ def store(user_input, agent_reply, tool_name, tool_args, tool_result):
124
+ doc = f"User: {user_input}\nAgent: {agent_reply}\nTool: {tool_name}\nArgs: {json.dumps(tool_args)}\nResult: {json.dumps(tool_result)}"
125
  pine_index.upsert(vectors=[{
126
+ "id": str(uuid.uuid4()), "values": embed(doc),
127
+ "metadata": {"doc": doc, "user_input": user_input, "agent_reply": agent_reply,
128
+ "tool_name": tool_name, "tool_args": json.dumps(tool_args),
129
+ "tool_result": json.dumps(tool_result),
130
+ "timestamp": datetime.datetime.utcnow().isoformat()},
 
 
 
 
 
 
131
  }])
132
 
133
 
 
136
  total = stats.get("total_vector_count", 0)
137
  if total == 0:
138
  return ""
139
+ results = pine_index.query(vector=embed(user_input), top_k=min(top_k, total), include_metadata=True)
140
+ chunks = [f"[past]\n{m['metadata'].get('user_input','')} β†’ {m['metadata'].get('tool_name','')}"
141
+ for m in results.get("matches", []) if m.get('score', 0) > 0.5]
142
+ return "\nRECENT SIMILAR (context only):\n" + "\n---\n".join(chunks) + "\n" if chunks else ""
 
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
+ def mcp_tools_to_openai_schema(mcp_tools):
146
+ return [{"type": "function", "function": {"name": t.name, "description": t.description or "",
147
+ "parameters": t.inputSchema or {"type": "object", "properties": {}}}} for t in mcp_tools]
 
 
 
 
 
 
 
 
 
148
 
149
 
150
+ # ── Agent turn ────────────────────────────────────��───────────────────────────
151
  async def agent_turn_async(session, openai_tools, user_input: str, chat_id: int) -> str:
 
 
152
  memory_block = recall(user_input)
 
153
  tool_descriptions = ""
154
  for t in openai_tools:
155
+ p = t["function"].get("parameters", {})
156
+ req = p.get("required", [])
157
+ props = p.get("properties", {})
158
+ ps = ", ".join(f"{k}" + (" (required)" if k in req else "") for k in props) if props else "none"
159
+ tool_descriptions += f"- {t['function']['name']}: {t['function']['description']} | {ps}\n"
160
+ tool_descriptions += "\nIMPORTANT: For add_issues_to_sprint, sprint_id must be a NUMBER, NOT 'current'.\n"
161
+
162
+ system_prompt = (
163
+ f"You are an intelligent Jira Sprint Management AI Agent.\n\n"
164
+ f"Available tools:\n{tool_descriptions}\n"
165
+ f"To call a tool, respond with ONLY JSON: {{\"tool\": \"<name>\", \"arguments\": {{...}}}}\n\n"
166
+ f"RULES:\n1. Call tools for Jira data queries.\n2. Don't make up data.\n"
167
+ f"3. Normal replies for greetings.\n4. Summarize tool results concisely.\n\n{memory_block}"
168
+ )
 
 
 
 
 
 
 
 
 
 
 
169
 
170
+ messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}]
171
+ user_sessions.setdefault(chat_id, {"messages": []})
172
+ clean = [m for m in user_sessions[chat_id]["messages"] if m["role"] in ("user", "assistant")]
173
+ messages.extend(clean[-6:])
174
 
175
  final_reply = ""
 
176
  all_results = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  tool_calls_parsed = []
 
178
 
179
+ for _ in range(5):
 
180
  try:
181
+ response = llm.create_chat_completion(messages=messages, max_tokens=1024, temperature=0.2, top_p=0.9)
182
+ response_text = response['choices'][0]['message'].get("content", "")
 
 
 
 
 
 
 
 
 
 
183
  if "<think>" in response_text and "</think>" in response_text:
184
+ response_text = response_text[response_text.rfind("</think>") + 8:].strip()
185
 
186
+ # Parse JSON tool calls
187
  tool_calls_parsed = []
188
  remaining = response_text
189
  while True:
190
+ j = remaining.find("{")
191
+ if j < 0:
192
  break
193
+ remaining = remaining[j:]
194
+ depth, end = 0, -1
 
195
  for i, ch in enumerate(remaining):
196
+ if ch == "{": depth += 1
 
197
  elif ch == "}":
198
  depth -= 1
199
  if depth == 0:
200
+ end = i + 1
201
  break
202
+ if end < 0:
203
  break
204
  try:
205
+ parsed = json.loads(remaining[:end])
206
  if "tool" in parsed:
207
  tool_calls_parsed.append(parsed)
208
  except json.JSONDecodeError:
209
  pass
210
+ remaining = remaining[end:]
211
 
 
212
  if tool_calls_parsed:
213
  all_results = []
214
  for tc in tool_calls_parsed:
215
+ tname, targs = tc.get("tool", "unknown"), tc.get("arguments", {})
216
+ print(f"\n β†’ MCP: {tname}({json.dumps(targs)})")
217
+ result = await session.call_tool(tname, arguments=targs)
218
+ raw = result.content[0].text if result.content and hasattr(result.content[0], "text") else str(result.content[0]) if result.content else "{}"
219
+ try:
220
+ tresult = json.loads(raw)
221
+ except json.JSONDecodeError:
222
+ tresult = {"raw": raw}
223
+ print(f" ← {json.dumps(tresult, indent=2)}\n")
224
+ all_results.append((tname, tresult))
225
+
226
+ messages.append({"role": "assistant", "content": response_text})
227
+ parts = [f"Tool {tn} returned: {json.dumps(tr)}" for tn, tr in all_results]
228
+ sprint_err = any("sprint_id" in json.dumps(tr).lower() and ("integer" in json.dumps(tr).lower() or "int_parsing" in json.dumps(tr).lower()) for _, tr in all_results)
229
+ if sprint_err:
230
+ messages.append({"role": "user", "content": f"{' | '.join(parts)}\n\nsprint_id must be a number. Call get_active_sprint first."})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  else:
232
+ messages.append({"role": "user", "content": f"{' | '.join(parts)}\n\nSummarize concisely."})
 
 
 
 
 
233
  else:
234
  final_reply = response_text
235
  print(f"\n Agent: {final_reply}\n")
236
  break
 
237
  except Exception as e:
238
+ if "out of memory" in str(e).lower():
239
+ return "Sorry, memory error. Try again."
240
+ return f"Error: {str(e)[:200]}"
241
+
242
+ # Pinecone store
 
 
 
243
  if all_results:
244
+ store(user_input, final_reply, all_results[-1][0], tool_calls_parsed[-1].get("arguments", {}), all_results[-1][1])
 
 
245
  else:
246
  store(user_input, final_reply, "none", {}, {})
247
 
248
+ # Save history
249
  user_sessions[chat_id]["messages"].append({"role": "user", "content": user_input})
250
  user_sessions[chat_id]["messages"].append({"role": "assistant", "content": final_reply})
251
  if len(user_sessions[chat_id]["messages"]) > 20:
 
255
 
256
 
257
  # ── Telegram handlers ─────────────────────────────────────────────────────────
258
+ def _register_handlers():
259
+ @bot.message_handler(commands=['start'])
260
+ def cmd_start(message):
261
+ name = message.from_user.first_name or "there"
262
+ bot.reply_to(message, f"Hey {name}! πŸ‘‹\n\nI'm your Jira Sprint Manager. Ask me anything about sprints, backlog, or issues!")
263
+
264
+ @bot.message_handler(commands=['memory'])
265
+ def cmd_memory(message):
266
+ parts = message.text.split(None, 1)
267
+ if len(parts) < 2:
268
+ bot.reply_to(message, "Usage: /memory <query>")
269
+ return
270
+ result = recall(parts[1], top_k=5)
271
+ bot.reply_to(message, result[:4000] if result else "Nothing found.")
272
+
273
+ @bot.message_handler(func=lambda m: True)
274
+ def handle_message(message):
275
+ user_input = message.text.strip()
276
+ if not user_input:
277
+ return
278
+ print(f"\n{'='*50}\n Telegram [{message.from_user.first_name}]: {user_input}\n{'='*50}\n")
279
+ bot.send_chat_action(message.chat.id, "typing")
280
+ future = asyncio.run_coroutine_threadsafe(
281
+ agent_turn_async(mcp_session_ref, openai_tools_ref, user_input, message.chat.id), loop_ref)
282
+ reply = future.result(timeout=120)
283
+ for chunk in [reply[i:i+4000] for i in range(0, len(reply), 4000)] if reply else ["..."]:
284
+ bot.send_message(message.chat.id, chunk)
285
+
286
+
287
+ # ── Main entry: start MCP + Telegram (callable by orchestrator) ───────────────
288
+ async def start_telegram_with_mcp():
289
+ """Initialize all components and start the Telegram bot with MCP."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  global mcp_session_ref, openai_tools_ref, loop_ref
291
  loop_ref = asyncio.get_event_loop()
292
 
293
+ # Lazy init all components
294
+ _init_telegram_bot()
295
+ _init_embedder()
296
+ _init_llm()
297
+ _init_pinecone()
298
+ _register_handlers()
299
+
300
+ server_params = StdioServerParameters(command=sys.executable, args=[MCP_SERVER_SCRIPT], env={**os.environ})
301
 
302
  async with stdio_client(server_params) as (read, write):
303
  async with ClientSession(read, write) as session:
304
  await session.initialize()
305
+ tools_resp = await session.list_tools()
306
+ mcp_tools = tools_resp.tools
 
307
  openai_tools = mcp_tools_to_openai_schema(mcp_tools)
308
 
309
+ # Update refs for status checking
310
+ import shared_state
311
+ shared_state.mcp_session = session
312
+ shared_state.openai_tools = openai_tools
313
+ shared_state.telegram_bot = bot
314
+ shared_state.llm_instance = llm
315
  mcp_session_ref = session
316
  openai_tools_ref = openai_tools
317
 
318
  stats = pine_index.describe_index_stats()
 
 
319
  print("=" * 60)
320
+ print(f" Telegram + MCP | Tools: {[t.name for t in mcp_tools]}")
321
+ print(f" Memory: {stats.get('total_vector_count', 0)} vectors")
 
322
  print("=" * 60 + "\n")
323
 
 
324
  def run_telegram():
325
+ print(" Telegram bot running.\n")
326
  bot.infinity_polling()
327
 
328
+ t = threading.Thread(target=run_telegram, daemon=True)
329
+ t.start()
330
 
 
331
  try:
332
  while True:
333
  await asyncio.sleep(1)
334
  except (KeyboardInterrupt, SystemExit):
335
+ print("\nShutting down Telegram...")
336
  bot.stop_polling()
337
 
338
 
339
+ # ── Standalone run ────────────────────────────────────────────────────────────
340
  if __name__ == "__main__":
341
+ asyncio.run(start_telegram_with_mcp())