abhinav-047 commited on
Commit
4499d27
Β·
1 Parent(s): c57ea8c

Add toolsforgitnotionslack folder as a folder for my code which can be run nativelly and as a fastapi endpoint

Browse files
toolsforgitnotionslack/agent/__init__.py ADDED
File without changes
toolsforgitnotionslack/agent/cache.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import Optional
3
+
4
+ class Cache:
5
+ def __init__(self, ttl_seconds: int = 300):
6
+ self._store: dict[str, tuple[str, float]] = {}
7
+ self._ttl = ttl_seconds
8
+
9
+ def get(self, key: str) -> Optional[str]:
10
+ entry = self._store.get(key)
11
+ if entry and (time.monotonic() - entry[1]) < self._ttl:
12
+ return entry[0]
13
+ if entry:
14
+ del self._store[key]
15
+ return None
16
+
17
+ def set(self, key: str, value: str) -> None:
18
+ self._store[key] = (value, time.monotonic())
19
+
20
+ def clear(self) -> None:
21
+ self._store.clear()
toolsforgitnotionslack/agent/planner.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def build_system_prompt(default_repo: str = "") -> str:
2
+ repo_hint = f"\n Default GitHub repo: {default_repo}" if default_repo else ""
3
+ return f"""You are an enterprise knowledge assistant with live access to GitHub, Slack, and Notion.{repo_hint}
4
+
5
+ ## Retrieval discipline
6
+
7
+ ### GitHub
8
+ 1. DISCOVER first: use github_list_repos or github_list_files before reading anything.
9
+ 2. SEARCH before reading: use github_search_code or github_list_markdown_files to locate docs.
10
+ 3. READ only documentation files (.md, README, docs/, wiki/, adr/).
11
+ Never read .py .ts .json .yaml .lock unless user explicitly asks for code.
12
+ 4. Summarise large files β€” do not quote them verbatim.
13
+ 5. Cite every answer as: [owner/repo Β· path/file.md]
14
+
15
+ ### Slack
16
+ 1. Use slack_list_channels to confirm channel IDs before history calls.
17
+ 2. Use slack_search for topic lookups. Use slack_channel_history for recent messages.
18
+ 3. Use slack_get_thread when a message has replies worth reconstructing.
19
+ 4. Cite as: [#channel | YYYY-MM-DD]
20
+
21
+ ### Notion
22
+ 1. Use notion_search first. Only call notion_read_page after you have a page_id.
23
+ 2. Use notion_list_databases β†’ notion_query_database for structured data.
24
+ 3. Cite as: [Notion Β· Page Title]
25
+
26
+ ## Cross-source reasoning
27
+ - Run parallel tool calls when questions span multiple sources.
28
+ - Reuse cached results β€” do not re-call with identical arguments.
29
+ - Summarise before deep-diving.
30
+ - If info is not found after a reasonable search, say so. Never fabricate paths, names, or channels.
31
+
32
+ ## Response format
33
+ - Lead with a direct answer.
34
+ - Cite every factual claim from a tool result.
35
+ - Offer follow-up suggestions when the question is broad.
36
+ """
toolsforgitnotionslack/chatbot.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI endpoint for the Enterprise Knowledge Assistant.
3
+
4
+ pip install fastapi uvicorn
5
+
6
+ Run:
7
+ uvicorn app:app --reload --port 8000
8
+
9
+ Endpoints:
10
+ POST /chat β€” single-turn or multi-turn conversation
11
+ DELETE /chat β€” clear session history and cache
12
+ GET /health β€” service status
13
+ """
14
+
15
+ from dotenv import load_dotenv
16
+ load_dotenv()
17
+
18
+ import asyncio, json, os, time, uuid
19
+ from contextlib import asynccontextmanager
20
+ from typing import Optional
21
+
22
+ from fastapi import FastAPI, HTTPException
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from pydantic import BaseModel
25
+
26
+ from openai import AsyncOpenAI
27
+ from tools.github_tools import GITHUB_TOOLS, GITHUB_TOOL_FNS
28
+ from tools.slack_tools import SLACK_TOOLS, SLACK_TOOL_FNS
29
+ from tools.notion_tools import NOTION_TOOLS, NOTION_TOOL_FNS
30
+ from agent.cache import Cache
31
+ from agent.planner import build_system_prompt
32
+
33
+ # ── Config ─────────────────────────────────────────────────────────────────────
34
+
35
+ MODEL = "gpt-4o-mini"
36
+ GITHUB_REPO = os.environ.get("GITHUB_REPO", "")
37
+ client = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
38
+
39
+ ALL_TOOLS = GITHUB_TOOLS + SLACK_TOOLS + NOTION_TOOLS
40
+ ALL_FNS = {**GITHUB_TOOL_FNS, **SLACK_TOOL_FNS, **NOTION_TOOL_FNS}
41
+
42
+ tool_cache = Cache(ttl_seconds=300)
43
+
44
+ # session_id β†’ list of message dicts
45
+ sessions: dict[str, list[dict]] = {}
46
+
47
+ # ── Lifespan ───────────────────────────────────────────────────────────────────
48
+
49
+ @asynccontextmanager
50
+ async def lifespan(app: FastAPI):
51
+ missing = [v for v in ["OPENAI_API_KEY"] if not os.environ.get(v)]
52
+ if missing:
53
+ print(f"⚠️ Missing env vars: {', '.join(missing)}")
54
+ print(f" GitHub {'βœ“' if os.environ.get('GITHUB_TOKEN') else 'βœ—'} "
55
+ f"Slack {'βœ“' if os.environ.get('SLACK_BOT_TOKEN') else 'βœ—'} "
56
+ f"Notion {'βœ“' if os.environ.get('NOTION_API_TOKEN') else 'βœ—'}")
57
+ yield
58
+
59
+ # ── App ────────────────────────────────────────────────────────────────────────
60
+
61
+ app = FastAPI(
62
+ title="Enterprise Knowledge Assistant",
63
+ version="1.0.0",
64
+ lifespan=lifespan,
65
+ )
66
+
67
+ app.add_middleware(
68
+ CORSMiddleware,
69
+ allow_origins=["*"],
70
+ allow_methods=["*"],
71
+ allow_headers=["*"],
72
+ )
73
+
74
+ # ── Schemas ────────────────────────────────────────────────────────────────────
75
+
76
+ class ChatRequest(BaseModel):
77
+ message: str
78
+ session_id: Optional[str] = None # omit to start a new session
79
+
80
+ class ToolCall(BaseModel):
81
+ name: str
82
+ args: dict
83
+ result: str
84
+ cached: bool
85
+
86
+ class ChatResponse(BaseModel):
87
+ answer: str
88
+ session_id: str
89
+ elapsed_seconds: float
90
+ tool_calls: list[ToolCall]
91
+
92
+ class ClearResponse(BaseModel):
93
+ cleared: bool
94
+ session_id: str
95
+
96
+ class HealthResponse(BaseModel):
97
+ status: str
98
+ model: str
99
+ github: bool
100
+ slack: bool
101
+ notion: bool
102
+ active_sessions: int
103
+
104
+ # ── Agent ──────────────────────────────────────────────────────────────────────
105
+
106
+ async def dispatch(name: str, args: dict) -> tuple[str, bool]:
107
+ """Returns (result, was_cached)."""
108
+ key = f"{name}:{json.dumps(args, sort_keys=True)}"
109
+ cached = tool_cache.get(key)
110
+ if cached:
111
+ return cached, True
112
+ fn = ALL_FNS.get(name)
113
+ result = await fn(**args) if fn else f"Unknown tool: {name}"
114
+ tool_cache.set(key, result)
115
+ return result, False
116
+
117
+
118
+ async def run_agent(history: list[dict]) -> tuple[str, list[ToolCall]]:
119
+ system = build_system_prompt(GITHUB_REPO)
120
+ messages = [{"role": "system", "content": system}] + history
121
+ tool_log: list[ToolCall] = []
122
+
123
+ for _ in range(14):
124
+ response = await client.chat.completions.create(
125
+ model=MODEL,
126
+ messages=messages,
127
+ tools=ALL_TOOLS,
128
+ tool_choice="auto",
129
+ )
130
+ msg = response.choices[0].message
131
+ tool_calls = msg.tool_calls or []
132
+
133
+ if not tool_calls:
134
+ return msg.content or "", tool_log
135
+
136
+ messages.append(msg)
137
+
138
+ names_and_args = [
139
+ (tc.function.name, json.loads(tc.function.arguments))
140
+ for tc in tool_calls
141
+ ]
142
+
143
+ results = await asyncio.gather(*[
144
+ dispatch(name, args) for name, args in names_and_args
145
+ ])
146
+
147
+ for tc, (name, args), (result, was_cached) in zip(tool_calls, names_and_args, results):
148
+ tool_log.append(ToolCall(name=name, args=args,
149
+ result=result[:500], cached=was_cached))
150
+ messages.append({
151
+ "role": "tool",
152
+ "tool_call_id": tc.id,
153
+ "content": result,
154
+ })
155
+
156
+ return "⚠️ Reached reasoning limit. Try a more specific question.", tool_log
157
+
158
+ # ── Routes ─────────────────────────────────────────────────────────────────────
159
+
160
+ @app.post("/chat", response_model=ChatResponse)
161
+ async def chat(req: ChatRequest):
162
+ session_id = req.session_id or str(uuid.uuid4())
163
+ history = sessions.setdefault(session_id, [])
164
+
165
+ history.append({"role": "user", "content": req.message})
166
+
167
+ t0 = time.monotonic()
168
+ try:
169
+ answer, tool_log = await run_agent(history)
170
+ except Exception as e:
171
+ raise HTTPException(status_code=500, detail=str(e))
172
+
173
+ history.append({"role": "assistant", "content": answer})
174
+
175
+ # cap session size
176
+ if len(history) > 60:
177
+ sessions[session_id] = history[-60:]
178
+
179
+ return ChatResponse(
180
+ answer=answer,
181
+ session_id=session_id,
182
+ elapsed_seconds=round(time.monotonic() - t0, 2),
183
+ tool_calls=tool_log,
184
+ )
185
+
186
+
187
+ @app.delete("/chat", response_model=ClearResponse)
188
+ async def clear_session(session_id: str):
189
+ existed = session_id in sessions
190
+ sessions.pop(session_id, None)
191
+ tool_cache.clear()
192
+ return ClearResponse(cleared=existed, session_id=session_id)
193
+
194
+
195
+ @app.get("/health", response_model=HealthResponse)
196
+ async def health():
197
+ return HealthResponse(
198
+ status="ok",
199
+ model=MODEL,
200
+ github=bool(os.environ.get("GITHUB_TOKEN")),
201
+ slack=bool(os.environ.get("SLACK_BOT_TOKEN")),
202
+ notion=bool(os.environ.get("NOTION_API_TOKEN")),
203
+ active_sessions=len(sessions),
204
+ )
toolsforgitnotionslack/main.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Enterprise Knowledge Assistant
3
+ GPT-4o-mini orchestrated, MCP-style tool calling across GitHub, Slack, Notion.
4
+
5
+ pip install openai httpx python-dotenv
6
+
7
+ .env:
8
+ OPENAI_API_KEY=
9
+ GITHUB_TOKEN= # free PAT with repo read scope
10
+ GITHUB_REPO= # default "owner/repo" (optional)
11
+ SLACK_BOT_TOKEN= # free workspace bot token
12
+ NOTION_API_TOKEN= # free integration token
13
+ """
14
+
15
+ from dotenv import load_dotenv
16
+ load_dotenv()
17
+
18
+ import asyncio, json, os, time
19
+ from openai import AsyncOpenAI
20
+ from tools.github_tools import GITHUB_TOOLS, GITHUB_TOOL_FNS
21
+ from tools.slack_tools import SLACK_TOOLS, SLACK_TOOL_FNS
22
+ from tools.notion_tools import NOTION_TOOLS, NOTION_TOOL_FNS
23
+ from agent.cache import Cache
24
+ from agent.planner import build_system_prompt
25
+
26
+ MODEL = "gpt-4o-mini"
27
+ GITHUB_REPO = os.environ.get("GITHUB_REPO", "")
28
+ client = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
29
+ # ALL_TOOLS = GITHUB_TOOLS+ NOTION_TOOLS
30
+ # ALL_FNS = {**GITHUB_TOOL_FNS,**NOTION_TOOL_FNS}
31
+ ALL_TOOLS = GITHUB_TOOLS + SLACK_TOOLS + NOTION_TOOLS
32
+ ALL_FNS = {**GITHUB_TOOL_FNS, **SLACK_TOOL_FNS, **NOTION_TOOL_FNS}
33
+ cache = Cache(ttl_seconds=300)
34
+
35
+
36
+ async def dispatch(name: str, args: dict) -> str:
37
+ key = f"{name}:{json.dumps(args, sort_keys=True)}"
38
+ cached = cache.get(key)
39
+ if cached:
40
+ return f"[cached] {cached}"
41
+ fn = ALL_FNS.get(name)
42
+ result = await fn(**args) if fn else f"Unknown tool: {name}"
43
+ cache.set(key, result)
44
+ return result
45
+
46
+
47
+ async def run_agent(history: list[dict]) -> str:
48
+ system = build_system_prompt(GITHUB_REPO)
49
+ messages = [{"role": "system", "content": system}] + history
50
+
51
+ for _ in range(14):
52
+ response = await client.chat.completions.create(
53
+ model=MODEL,
54
+ messages=messages,
55
+ tools=ALL_TOOLS,
56
+ tool_choice="auto",
57
+ )
58
+ msg = response.choices[0].message
59
+ tool_calls = msg.tool_calls or []
60
+
61
+ if not tool_calls:
62
+ return msg.content or ""
63
+
64
+ messages.append(msg)
65
+
66
+ results = await asyncio.gather(*[
67
+ dispatch(tc.function.name, json.loads(tc.function.arguments))
68
+ for tc in tool_calls
69
+ ])
70
+
71
+ for tc, result in zip(tool_calls, results):
72
+ tag = " [cached]" if result.startswith("[cached]") else ""
73
+ print(f" πŸ”§ {tc.function.name}{tag} β†’ {result[:90].replace(chr(10),' ')}…")
74
+ messages.append({
75
+ "role": "tool",
76
+ "tool_call_id": tc.id,
77
+ "content": result,
78
+ })
79
+
80
+ return "⚠️ Reached reasoning limit. Try a more specific question."
81
+
82
+
83
+ async def chat_loop():
84
+ history: list[dict] = []
85
+ print(f"\n{'═'*60}\n Enterprise Knowledge Assistant | {MODEL}")
86
+ print(f" GitHub {'βœ“' if os.environ.get('GITHUB_TOKEN') else 'βœ—'} "
87
+ f"Slack {'βœ“' if os.environ.get('SLACK_BOT_TOKEN') else 'βœ—'} "
88
+ f"Notion {'βœ“' if os.environ.get('NOTION_API_TOKEN') else 'βœ—'}")
89
+ if GITHUB_REPO:
90
+ print(f" Default repo: {GITHUB_REPO}")
91
+ print(f"{'═'*60}\nCommands: /clear /quit\n")
92
+
93
+ while True:
94
+ try:
95
+ user = input("You: ").strip()
96
+ except (EOFError, KeyboardInterrupt):
97
+ print("\nπŸ‘‹ Bye!"); break
98
+ if not user: continue
99
+ if user.lower() in ("/quit", "/exit"):
100
+ print("πŸ‘‹ Bye!"); break
101
+ if user.lower() == "/clear":
102
+ history = []; cache.clear()
103
+ print("πŸ—‘οΈ Cleared.\n"); continue
104
+
105
+ history.append({"role": "user", "content": user})
106
+ t0 = time.monotonic()
107
+ try:
108
+ answer = await run_agent(history)
109
+ except Exception as e:
110
+ answer = f"❌ {e}"
111
+
112
+ history.append({"role": "assistant", "content": answer})
113
+ print(f"\nAssistant ({time.monotonic()-t0:.1f}s):\n{answer}\n{'─'*60}")
114
+ if len(history) > 60:
115
+ history = history[-60:]
116
+
117
+
118
+ if __name__ == "__main__":
119
+ if not os.environ.get("OPENAI_API_KEY"):
120
+ print("⚠️ Missing OPENAI_API_KEY")
121
+ asyncio.run(chat_loop())
toolsforgitnotionslack/tools/__init__.py ADDED
File without changes
toolsforgitnotionslack/tools/github_tools.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GitHub tools β€” discovery-first, markdown-only reads, paginated, rate-limit aware.
3
+ """
4
+ import os
5
+ import httpx
6
+
7
+ GH = "https://api.github.com"
8
+ GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
9
+ GITHUB_REPO = os.environ.get("GITHUB_REPO", "")
10
+
11
+
12
+ def _headers() -> dict:
13
+ return {
14
+ "Authorization": f"Bearer {GITHUB_TOKEN}",
15
+ "Accept": "application/vnd.github+json",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ }
18
+
19
+
20
+ def _target(repo: str) -> str:
21
+ return repo or GITHUB_REPO
22
+
23
+
24
+ def _rate_warn(resp: httpx.Response) -> str | None:
25
+ if resp.headers.get("x-ratelimit-remaining", "1") == "0":
26
+ reset = resp.headers.get("x-ratelimit-reset", "soon")
27
+ return f"⚠️ GitHub rate limit reached. Resets at epoch {reset}."
28
+ return None
29
+
30
+
31
+ # ── Tools ──────────────────────────────────────────────────────────────────────
32
+
33
+ async def github_list_repos(org_or_user: str = "", repo_type: str = "all",
34
+ page: int = 1) -> str:
35
+ params = {"type": repo_type, "per_page": 50, "page": page}
36
+ async with httpx.AsyncClient(timeout=15) as h:
37
+ if org_or_user:
38
+ r = await h.get(f"{GH}/orgs/{org_or_user}/repos",
39
+ headers=_headers(), params=params)
40
+ if r.status_code == 404:
41
+ r = await h.get(f"{GH}/users/{org_or_user}/repos",
42
+ headers=_headers(), params=params)
43
+ else:
44
+ r = await h.get(f"{GH}/user/repos", headers=_headers(), params=params)
45
+ if w := _rate_warn(r): return w
46
+ if r.status_code != 200: return f"GitHub error {r.status_code}: {r.text[:300]}"
47
+ repos = r.json()
48
+ if not repos: return "No repositories found."
49
+ lines = [
50
+ f"β€’ {repo['full_name']} {'πŸ”’' if repo['private'] else '🌐'} "
51
+ f"⭐{repo['stargazers_count']} {(repo.get('description') or '')[:80]}"
52
+ for repo in repos
53
+ ]
54
+ if len(repos) == 50:
55
+ lines.append(f"[Page {page} β€” call with page={page+1} for more]")
56
+ return "\n".join(lines)
57
+
58
+
59
+ async def github_repo_summary(repo: str = "") -> str:
60
+ t = _target(repo)
61
+ if not t: return "Provide repo as 'owner/repo' or set GITHUB_REPO."
62
+ async with httpx.AsyncClient(timeout=15) as h:
63
+ r = await h.get(f"{GH}/repos/{t}", headers=_headers())
64
+ if w := _rate_warn(r): return w
65
+ if r.status_code == 404: return f"Repo not found: {t}"
66
+ if r.status_code != 200: return f"GitHub error {r.status_code}"
67
+ d = r.json()
68
+ topics = ", ".join(d.get("topics", [])) or "none"
69
+ return (
70
+ f"Repo: {d['full_name']}\n"
71
+ f"Description: {d.get('description') or 'n/a'}\n"
72
+ f"Language: {d.get('language') or 'n/a'}\n"
73
+ f"Topics: {topics}\n"
74
+ f"Default branch: {d.get('default_branch')}\n"
75
+ f"Stars: {d['stargazers_count']} Open issues: {d['open_issues_count']}\n"
76
+ f"URL: {d['html_url']}"
77
+ )
78
+
79
+
80
+ async def github_list_files(repo: str = "", path: str = "", branch: str = "") -> str:
81
+ t = _target(repo)
82
+ if not t: return "Provide repo as 'owner/repo' or set GITHUB_REPO."
83
+ params = {"ref": branch} if branch else {}
84
+ async with httpx.AsyncClient(timeout=15) as h:
85
+ r = await h.get(f"{GH}/repos/{t}/contents/{path}",
86
+ headers=_headers(), params=params)
87
+ if w := _rate_warn(r): return w
88
+ if r.status_code == 404: return f"Path '{path}' not found in {t}."
89
+ if r.status_code != 200: return f"GitHub error {r.status_code}"
90
+ items = r.json()
91
+ if isinstance(items, dict):
92
+ return f"'{path}' is a file. Use github_read_file to read it."
93
+ lines = []
94
+ for item in sorted(items, key=lambda x: (x["type"] != "dir", x["name"])):
95
+ icon = "πŸ“" if item["type"] == "dir" else "πŸ“„"
96
+ size = f" {item.get('size',0):,}b" if item["type"] == "file" else ""
97
+ lines.append(f"{icon} {item['path']}{size}")
98
+ return "\n".join(lines) or "Empty directory."
99
+
100
+
101
+ async def github_read_file(path: str, repo: str = "", branch: str = "") -> str:
102
+ t = _target(repo)
103
+ if not t: return "Provide repo as 'owner/repo' or set GITHUB_REPO."
104
+ lower = path.lower()
105
+ allowed_exts = (".md", ".mdx", ".txt", ".rst", ".adoc")
106
+ allowed_names = ("readme", "changelog", "contributing", "license", "notice")
107
+ allowed_dirs = ("docs/", "doc/", "wiki/", "adr/", "architecture/", "rfcs/")
108
+ is_doc = (
109
+ any(lower.endswith(e) for e in allowed_exts)
110
+ or any(os.path.basename(lower) == n for n in allowed_names)
111
+ or any(lower.startswith(d) for d in allowed_dirs)
112
+ )
113
+ if not is_doc:
114
+ return (
115
+ f"⚠️ '{path}' looks like a source/config file. "
116
+ "This tool is for documentation only (.md, README, docs/ etc). "
117
+ "Re-call with the same args only if the user explicitly asked for this file."
118
+ )
119
+ params = {"ref": branch} if branch else {}
120
+ async with httpx.AsyncClient(timeout=15) as h:
121
+ r = await h.get(
122
+ f"{GH}/repos/{t}/contents/{path}",
123
+ headers={**_headers(), "Accept": "application/vnd.github.raw+json"},
124
+ params=params,
125
+ )
126
+ if w := _rate_warn(r): return w
127
+ if r.status_code == 404: return f"File not found: {path} in {t}"
128
+ if r.status_code != 200: return f"GitHub error {r.status_code}"
129
+ content = r.text
130
+ total = len(content)
131
+ if total > 8000:
132
+ content = content[:8000] + f"\n\n…[truncated β€” {total:,} chars total]"
133
+ return f"[{t} Β· {path}]\n\n{content}"
134
+
135
+
136
+ async def github_search_code(query: str, repo: str = "", language: str = "",
137
+ path_filter: str = "") -> str:
138
+ t = _target(repo)
139
+ q = query
140
+ if t: q += f" repo:{t}"
141
+ if language: q += f" language:{language}"
142
+ if path_filter: q += f" path:{path_filter}"
143
+ async with httpx.AsyncClient(timeout=15) as h:
144
+ r = await h.get(f"{GH}/search/code", headers=_headers(),
145
+ params={"q": q, "per_page": 10})
146
+ if w := _rate_warn(r): return w
147
+ if r.status_code == 422: return "Search query too short or invalid."
148
+ if r.status_code == 403: return "GitHub search rate limit hit. Wait 60s then retry."
149
+ if r.status_code != 200: return f"GitHub error {r.status_code}: {r.text[:200]}"
150
+ items = r.json().get("items", [])
151
+ if not items: return "No matching files found."
152
+ return "\n".join(
153
+ f"β€’ {i['repository']['full_name']}/{i['path']}\n {i['html_url']}"
154
+ for i in items
155
+ )
156
+
157
+
158
+ async def github_list_markdown_files(repo: str = "", folder: str = "") -> str:
159
+ t = _target(repo)
160
+ if not t: return "Provide repo as 'owner/repo' or set GITHUB_REPO."
161
+ q = f"extension:md repo:{t}"
162
+ if folder: q += f" path:{folder}"
163
+ async with httpx.AsyncClient(timeout=15) as h:
164
+ r = await h.get(f"{GH}/search/code", headers=_headers(),
165
+ params={"q": q, "per_page": 30})
166
+ if w := _rate_warn(r): return w
167
+ if r.status_code != 200: return f"GitHub error {r.status_code}"
168
+ items = r.json().get("items", [])
169
+ if not items: return "No markdown files found."
170
+ return f"[{t}] {len(items)} markdown file(s):\n" + "\n".join(f"β€’ {i['path']}" for i in items)
171
+
172
+
173
+ # ── Registry ───────────────────────────────────────────────────────────────────
174
+
175
+ GITHUB_TOOL_FNS = {
176
+ "github_list_repos": github_list_repos,
177
+ "github_repo_summary": github_repo_summary,
178
+ "github_list_files": github_list_files,
179
+ "github_read_file": github_read_file,
180
+ "github_search_code": github_search_code,
181
+ "github_list_markdown_files": github_list_markdown_files,
182
+ }
183
+
184
+ GITHUB_TOOLS = [
185
+ {"type": "function", "function": {
186
+ "name": "github_list_repos",
187
+ "description": (
188
+ "List GitHub repositories for a user or org. "
189
+ "Call this FIRST when you don't know which repos exist. "
190
+ "Supports pagination. Do NOT call if you already have the repo name."
191
+ ),
192
+ "parameters": {"type": "object", "properties": {
193
+ "org_or_user": {"type": "string", "description": "GitHub username or org. Empty = authenticated user."},
194
+ "repo_type": {"type": "string", "enum": ["all","public","private","forks","sources"]},
195
+ "page": {"type": "integer", "description": "Page number, default 1"},
196
+ }},
197
+ }},
198
+ {"type": "function", "function": {
199
+ "name": "github_repo_summary",
200
+ "description": (
201
+ "Get lightweight repo metadata: description, language, topics, stars, open issues. "
202
+ "Call this INSTEAD of reading README when you just need a quick overview. "
203
+ "Much cheaper than github_read_file."
204
+ ),
205
+ "parameters": {"type": "object", "properties": {
206
+ "repo": {"type": "string", "description": "owner/repo"},
207
+ }},
208
+ }},
209
+ {"type": "function", "function": {
210
+ "name": "github_list_files",
211
+ "description": (
212
+ "List files and directories in a repo path. "
213
+ "Use this to explore repo structure BEFORE reading any file. "
214
+ "Do NOT use to read file content β€” use github_read_file for that."
215
+ ),
216
+ "parameters": {"type": "object", "properties": {
217
+ "repo": {"type": "string"}, "path": {"type": "string"},
218
+ "branch": {"type": "string"},
219
+ }},
220
+ }},
221
+ {"type": "function", "function": {
222
+ "name": "github_read_file",
223
+ "description": (
224
+ "Read a file from GitHub. ONLY for documentation files: "
225
+ ".md .mdx .txt .rst, README, CHANGELOG, files in docs/ wiki/ adr/ architecture/. "
226
+ "Do NOT use for .py .ts .json .yaml .lock source files unless user explicitly asks."
227
+ ),
228
+ "parameters": {"type": "object", "required": ["path"], "properties": {
229
+ "path": {"type": "string"}, "repo": {"type": "string"},
230
+ "branch": {"type": "string"},
231
+ }},
232
+ }},
233
+ {"type": "function", "function": {
234
+ "name": "github_search_code",
235
+ "description": (
236
+ "Search for text or code within GitHub repos. "
237
+ "Use to LOCATE relevant files before reading them. "
238
+ "Set language='markdown' and path_filter='docs/' to target documentation."
239
+ ),
240
+ "parameters": {"type": "object", "required": ["query"], "properties": {
241
+ "query": {"type": "string"}, "repo": {"type": "string"},
242
+ "language": {"type": "string"}, "path_filter": {"type": "string"},
243
+ }},
244
+ }},
245
+ {"type": "function", "function": {
246
+ "name": "github_list_markdown_files",
247
+ "description": (
248
+ "List ALL markdown (.md) files in a repo, optionally scoped to a folder. "
249
+ "Use for documentation discovery before github_read_file. "
250
+ "Returns file paths only, not content."
251
+ ),
252
+ "parameters": {"type": "object", "properties": {
253
+ "repo": {"type": "string"}, "folder": {"type": "string"},
254
+ }},
255
+ }},
256
+ ]
toolsforgitnotionslack/tools/notion_tools.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Notion tools β€” search, read pages, list and query databases.
3
+ """
4
+ import os
5
+ import httpx
6
+ from dotenv import load_dotenv
7
+ load_dotenv()
8
+ NOTION_TOKEN = os.environ.get("NOTION_API_TOKEN", "")
9
+
10
+ NOTION_HEADERS = {
11
+ "Authorization": f"Bearer {NOTION_TOKEN}",
12
+ "Notion-Version": "2022-06-28",
13
+ "Content-Type": "application/json",
14
+ }
15
+
16
+
17
+ def _extract_rich_text(rt: list) -> str:
18
+ return "".join(t.get("plain_text", "") for t in rt)
19
+
20
+
21
+ async def notion_search(query: str, filter_type: str = "") -> str:
22
+ body: dict = {"query": query, "page_size": 10}
23
+ if filter_type in ("page", "database"):
24
+ body["filter"] = {"value": filter_type, "property": "object"}
25
+ async with httpx.AsyncClient(timeout=15) as h:
26
+ r = await h.post("https://api.notion.com/v1/search",
27
+ headers=NOTION_HEADERS, json=body)
28
+ if r.status_code != 200: return f"Notion error {r.status_code}: {r.text[:300]}"
29
+ results = r.json().get("results", [])
30
+ if not results: return "No Notion pages or databases found."
31
+ out = []
32
+ for item in results:
33
+ obj = item.get("object", "")
34
+ iid = item.get("id", "")
35
+ url = item.get("url", "")
36
+ title = "Untitled"
37
+ if obj == "page":
38
+ for prop in item.get("properties", {}).values():
39
+ if prop.get("type") == "title":
40
+ title = _extract_rich_text(prop.get("title", [])) or "Untitled"
41
+ break
42
+ elif obj == "database":
43
+ title = _extract_rich_text(item.get("title", [])) or "Untitled DB"
44
+ out.append(f"β€’ [{obj}] {title}\n id: {iid}\n {url}")
45
+ return "\n\n".join(out)
46
+
47
+
48
+ async def notion_read_page(page_id: str, max_blocks: int = 80) -> str:
49
+ async with httpx.AsyncClient(timeout=15) as h:
50
+ r = await h.get(
51
+ f"https://api.notion.com/v1/blocks/{page_id}/children",
52
+ headers=NOTION_HEADERS,
53
+ params={"page_size": max_blocks},
54
+ )
55
+ if r.status_code != 200: return f"Notion error {r.status_code}: {r.text[:300]}"
56
+ blocks = r.json().get("results", [])
57
+ PREFIXES = {
58
+ "heading_1": "# ",
59
+ "heading_2": "## ",
60
+ "heading_3": "### ",
61
+ "bulleted_list_item": "β€’ ",
62
+ "numbered_list_item": "1. ",
63
+ "to_do": "☐ ",
64
+ "toggle": "β–Ά ",
65
+ "quote": "> ",
66
+ "callout": "πŸ’‘ ",
67
+ "code": "```\n",
68
+ }
69
+ lines = []
70
+ for block in blocks:
71
+ btype = block.get("type", "")
72
+ data = block.get(btype, {})
73
+ rt = data.get("rich_text", [])
74
+ text = _extract_rich_text(rt)
75
+ if not text and btype not in ("divider", "image"):
76
+ continue
77
+ if btype == "divider":
78
+ lines.append("---")
79
+ elif btype == "image":
80
+ url = data.get("external", {}).get("url") or data.get("file", {}).get("url", "")
81
+ lines.append(f"[image: {url}]")
82
+ else:
83
+ prefix = PREFIXES.get(btype, "")
84
+ suffix = "\n```" if btype == "code" else ""
85
+ lines.append(f"{prefix}{text}{suffix}")
86
+ return "\n".join(lines) or "Page is empty or has unsupported block types."
87
+
88
+
89
+ async def notion_list_databases(query: str = "") -> str:
90
+ body: dict = {"filter": {"value": "database", "property": "object"}, "page_size": 20}
91
+ if query: body["query"] = query
92
+ async with httpx.AsyncClient(timeout=15) as h:
93
+ r = await h.post("https://api.notion.com/v1/search",
94
+ headers=NOTION_HEADERS, json=body)
95
+ if r.status_code != 200: return f"Notion error {r.status_code}: {r.text[:300]}"
96
+ results = r.json().get("results", [])
97
+ if not results: return "No databases found."
98
+ return "\n".join(
99
+ f"β€’ {_extract_rich_text(db.get('title', [])) or 'Untitled'} id:{db['id']}"
100
+ for db in results
101
+ )
102
+
103
+
104
+ async def notion_query_database(database_id: str, filter_property: str = "",
105
+ filter_value: str = "", page_size: int = 20) -> str:
106
+ body: dict = {"page_size": min(page_size, 50)}
107
+ if filter_property and filter_value:
108
+ body["filter"] = {
109
+ "property": filter_property,
110
+ "rich_text": {"contains": filter_value},
111
+ }
112
+ async with httpx.AsyncClient(timeout=15) as h:
113
+ r = await h.post(
114
+ f"https://api.notion.com/v1/databases/{database_id}/query",
115
+ headers=NOTION_HEADERS, json=body,
116
+ )
117
+ if r.status_code != 200: return f"Notion error {r.status_code}: {r.text[:300]}"
118
+ results = r.json().get("results", [])
119
+ if not results: return "No results found."
120
+ rows = []
121
+ for page in results:
122
+ row = {}
123
+ for name, prop in page.get("properties", {}).items():
124
+ ptype = prop.get("type")
125
+ if ptype == "title":
126
+ row[name] = _extract_rich_text(prop.get("title", []))
127
+ elif ptype == "rich_text":
128
+ row[name] = _extract_rich_text(prop.get("rich_text", []))
129
+ elif ptype == "select":
130
+ row[name] = (prop.get("select") or {}).get("name", "")
131
+ elif ptype == "multi_select":
132
+ row[name] = ", ".join(s["name"] for s in prop.get("multi_select", []))
133
+ elif ptype == "number":
134
+ row[name] = prop.get("number", "")
135
+ elif ptype == "checkbox":
136
+ row[name] = "βœ“" if prop.get("checkbox") else "βœ—"
137
+ elif ptype == "date":
138
+ row[name] = (prop.get("date") or {}).get("start", "")
139
+ elif ptype == "people":
140
+ row[name] = ", ".join(p.get("name", "") for p in prop.get("people", []))
141
+ rows.append(" ".join(f"{k}: {v}" for k, v in row.items() if v))
142
+ return "\n".join(rows)
143
+
144
+
145
+ # ── Registry ───────────────────────────────────────────────────────────────────
146
+
147
+ NOTION_TOOL_FNS = {
148
+ "notion_search": notion_search,
149
+ "notion_read_page": notion_read_page,
150
+ "notion_list_databases": notion_list_databases,
151
+ "notion_query_database": notion_query_database,
152
+ }
153
+
154
+ NOTION_TOOLS = [
155
+ {"type": "function", "function": {
156
+ "name": "notion_search",
157
+ "description": (
158
+ "Search Notion for pages and databases by keyword. "
159
+ "Always call this FIRST to get page IDs before calling notion_read_page. "
160
+ "filter_type: 'page', 'database', or '' (both)."
161
+ ),
162
+ "parameters": {"type": "object", "required": ["query"], "properties": {
163
+ "query": {"type": "string"},
164
+ "filter_type": {"type": "string", "enum": ["page", "database", ""]},
165
+ }},
166
+ }},
167
+ {"type": "function", "function": {
168
+ "name": "notion_read_page",
169
+ "description": (
170
+ "Read the full content of a Notion page by its ID. "
171
+ "Only call this AFTER getting a page_id from notion_search. "
172
+ "Preserves heading hierarchy, bullets, to-dos, callouts, code blocks."
173
+ ),
174
+ "parameters": {"type": "object", "required": ["page_id"], "properties": {
175
+ "page_id": {"type": "string"},
176
+ "max_blocks": {"type": "integer", "description": "Max blocks to read, default 80"},
177
+ }},
178
+ }},
179
+ {"type": "function", "function": {
180
+ "name": "notion_list_databases",
181
+ "description": (
182
+ "List accessible Notion databases, optionally filtered by name. "
183
+ "Use this before notion_query_database to find the correct database_id."
184
+ ),
185
+ "parameters": {"type": "object", "properties": {
186
+ "query": {"type": "string", "description": "Filter by database name"},
187
+ }},
188
+ }},
189
+ {"type": "function", "function": {
190
+ "name": "notion_query_database",
191
+ "description": (
192
+ "Query rows from a Notion database. Supports text property filtering. "
193
+ "Use notion_list_databases first to get the database_id. "
194
+ "Extracts title, text, select, multi_select, number, checkbox, date, people fields."
195
+ ),
196
+ "parameters": {"type": "object", "required": ["database_id"], "properties": {
197
+ "database_id": {"type": "string"},
198
+ "filter_property": {"type": "string", "description": "Property name to filter on e.g. 'Status'"},
199
+ "filter_value": {"type": "string", "description": "Value to match e.g. 'Done'"},
200
+ "page_size": {"type": "integer", "description": "Rows to return, default 20"},
201
+ }},
202
+ }},
203
+ ]
toolsforgitnotionslack/tools/slack_tools.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Slack tools β€” search, history, threads, channel metadata.
3
+ """
4
+ import os
5
+ import httpx
6
+
7
+ SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "")
8
+
9
+
10
+ def _headers() -> dict:
11
+ return {"Authorization": f"Bearer {SLACK_TOKEN}"}
12
+
13
+
14
+ async def slack_list_channels(filter_name: str = "") -> str:
15
+ async with httpx.AsyncClient(timeout=15) as h:
16
+ r = await h.get(
17
+ "https://slack.com/api/conversations.list",
18
+ headers=_headers(),
19
+ params={"types": "public_channel,private_channel",
20
+ "limit": 200, "exclude_archived": True},
21
+ )
22
+ d = r.json()
23
+ if not d.get("ok"): return f"Slack error: {d.get('error')}"
24
+ channels = d.get("channels", [])
25
+ if filter_name:
26
+ channels = [c for c in channels if filter_name.lower() in c["name"].lower()]
27
+ if not channels: return "No channels found."
28
+ return "\n".join(
29
+ f"β€’ #{c['name']} id:{c['id']} members:{c.get('num_members',0)}"
30
+ + (f" [{c['purpose']['value'][:60]}]" if c.get("purpose", {}).get("value") else "")
31
+ for c in channels
32
+ )
33
+
34
+
35
+ async def slack_search(query: str, channel: str = "",
36
+ oldest_date: str = "", latest_date: str = "",
37
+ count: int = 10) -> str:
38
+ q = query
39
+ if channel: q = f"in:#{channel.lstrip('#')} {q}"
40
+ if oldest_date: q += f" after:{oldest_date}"
41
+ if latest_date: q += f" before:{latest_date}"
42
+ async with httpx.AsyncClient(timeout=15) as h:
43
+ r = await h.get(
44
+ "https://slack.com/api/search.messages",
45
+ headers=_headers(),
46
+ params={"query": q, "count": min(count, 20), "highlight": False},
47
+ )
48
+ d = r.json()
49
+ if not d.get("ok"): return f"Slack error: {d.get('error')}"
50
+ matches = d.get("messages", {}).get("matches", [])
51
+ if not matches: return "No messages found."
52
+ parts = []
53
+ for m in matches:
54
+ ch = m.get("channel", {}).get("name", "?")
55
+ user = m.get("username", "?")
56
+ ts = m.get("ts", "")
57
+ text = m.get("text", "")[:400]
58
+ link = m.get("permalink", "")
59
+ parts.append(f"[#{ch} | {user} | {ts}]\n{text}\n{link}")
60
+ return "\n\n---\n\n".join(parts)
61
+
62
+
63
+ async def slack_channel_history(channel_id: str, limit: int = 20,
64
+ oldest: str = "", latest: str = "") -> str:
65
+ params: dict = {"channel": channel_id, "limit": min(limit, 50)}
66
+ if oldest: params["oldest"] = oldest
67
+ if latest: params["latest"] = latest
68
+ async with httpx.AsyncClient(timeout=15) as h:
69
+ r = await h.get("https://slack.com/api/conversations.history",
70
+ headers=_headers(), params=params)
71
+ d = r.json()
72
+ if not d.get("ok"): return f"Slack error: {d.get('error')}"
73
+ messages = d.get("messages", [])
74
+ if not messages: return "No messages in range."
75
+ lines = []
76
+ for m in reversed(messages):
77
+ reply_hint = f" [🧡 {m['reply_count']} replies]" if m.get("reply_count") else ""
78
+ lines.append(f"[{m.get('ts','')}] {m.get('text','')[:300]}{reply_hint}")
79
+ return "\n".join(lines)
80
+
81
+
82
+ async def slack_get_thread(channel_id: str, thread_ts: str, limit: int = 30) -> str:
83
+ async with httpx.AsyncClient(timeout=15) as h:
84
+ r = await h.get(
85
+ "https://slack.com/api/conversations.replies",
86
+ headers=_headers(),
87
+ params={"channel": channel_id, "ts": thread_ts, "limit": min(limit, 50)},
88
+ )
89
+ d = r.json()
90
+ if not d.get("ok"): return f"Slack error: {d.get('error')}"
91
+ messages = d.get("messages", [])
92
+ if not messages: return "Thread not found or empty."
93
+ return "\n".join(
94
+ f"[{m.get('ts','')}] {m.get('username', m.get('user','?'))}: {m.get('text','')[:400]}"
95
+ for m in messages
96
+ )
97
+
98
+
99
+ # ── Registry ───────────────────────────────────────────────────────────────────
100
+
101
+ SLACK_TOOL_FNS = {
102
+ "slack_list_channels": slack_list_channels,
103
+ "slack_search": slack_search,
104
+ "slack_channel_history": slack_channel_history,
105
+ "slack_get_thread": slack_get_thread,
106
+ }
107
+
108
+ SLACK_TOOLS = [
109
+ {"type": "function", "function": {
110
+ "name": "slack_list_channels",
111
+ "description": (
112
+ "List public/private Slack channels with their IDs. "
113
+ "Call this FIRST to discover channel IDs before using slack_channel_history. "
114
+ "Do NOT use to search messages β€” use slack_search for that."
115
+ ),
116
+ "parameters": {"type": "object", "properties": {
117
+ "filter_name": {"type": "string", "description": "Filter channels by name substring"},
118
+ }},
119
+ }},
120
+ {"type": "function", "function": {
121
+ "name": "slack_search",
122
+ "description": (
123
+ "Search Slack messages across channels by topic or keyword. "
124
+ "Supports time filtering with oldest_date/latest_date (YYYY-MM-DD). "
125
+ "Use this for topic lookups. Use slack_channel_history for recent chronological messages."
126
+ ),
127
+ "parameters": {"type": "object", "required": ["query"], "properties": {
128
+ "query": {"type": "string"},
129
+ "channel": {"type": "string", "description": "Limit to #channel-name"},
130
+ "oldest_date": {"type": "string", "description": "YYYY-MM-DD"},
131
+ "latest_date": {"type": "string", "description": "YYYY-MM-DD"},
132
+ "count": {"type": "integer", "description": "Max results, default 10"},
133
+ }},
134
+ }},
135
+ {"type": "function", "function": {
136
+ "name": "slack_channel_history",
137
+ "description": (
138
+ "Fetch recent messages from a Slack channel by channel ID. "
139
+ "Use slack_list_channels first to get the channel ID. "
140
+ "oldest/latest are Unix timestamps for date range filtering."
141
+ ),
142
+ "parameters": {"type": "object", "required": ["channel_id"], "properties": {
143
+ "channel_id": {"type": "string"},
144
+ "limit": {"type": "integer", "description": "Max messages, default 20"},
145
+ "oldest": {"type": "string", "description": "Unix timestamp"},
146
+ "latest": {"type": "string", "description": "Unix timestamp"},
147
+ }},
148
+ }},
149
+ {"type": "function", "function": {
150
+ "name": "slack_get_thread",
151
+ "description": (
152
+ "Reconstruct a full Slack thread given a channel_id and thread_ts (timestamp). "
153
+ "Use this when slack_channel_history shows a message has replies. "
154
+ "Returns all replies in chronological order."
155
+ ),
156
+ "parameters": {"type": "object", "required": ["channel_id", "thread_ts"], "properties": {
157
+ "channel_id": {"type": "string"},
158
+ "thread_ts": {"type": "string", "description": "Timestamp of the parent message"},
159
+ "limit": {"type": "integer", "description": "Max replies, default 30"},
160
+ }},
161
+ }},
162
+ ]