synapse-framework 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. synapse/__init__.py +207 -0
  2. synapse/app.py +942 -0
  3. synapse/cli.py +88 -0
  4. synapse/config.py +103 -0
  5. synapse/connectors/__init__.py +29 -0
  6. synapse/connectors/base.py +222 -0
  7. synapse/connectors/factory.py +202 -0
  8. synapse/connectors/gmail.py +292 -0
  9. synapse/connectors/mcp_wrapper.py +153 -0
  10. synapse/connectors/notion.py +161 -0
  11. synapse/connectors/photos.py +141 -0
  12. synapse/database/__init__.py +19 -0
  13. synapse/database/base.py +63 -0
  14. synapse/database/chroma.py +175 -0
  15. synapse/database/memory.py +132 -0
  16. synapse/database/pgvector.py +99 -0
  17. synapse/database/qdrant.py +138 -0
  18. synapse/embeddings/__init__.py +21 -0
  19. synapse/embeddings/base.py +262 -0
  20. synapse/export/__init__.py +14 -0
  21. synapse/export/mcp_exporter.py +202 -0
  22. synapse/graph/__init__.py +21 -0
  23. synapse/graph/base.py +130 -0
  24. synapse/graph/memory.py +20 -0
  25. synapse/graph/neo4j_store.py +176 -0
  26. synapse/graph/networkx_store.py +221 -0
  27. synapse/inspector/__init__.py +5 -0
  28. synapse/inspector/template.py +530 -0
  29. synapse/models/__init__.py +73 -0
  30. synapse/models/base.py +95 -0
  31. synapse/models/gemini.py +328 -0
  32. synapse/models/ollama.py +322 -0
  33. synapse/models/openai.py +289 -0
  34. synapse/oauth/__init__.py +7 -0
  35. synapse/oauth/google.py +194 -0
  36. synapse/oauth/notion.py +35 -0
  37. synapse/rag/__init__.py +8 -0
  38. synapse/rag/chunker.py +219 -0
  39. synapse/rag/hybrid_rag.py +122 -0
  40. synapse/resource/__init__.py +5 -0
  41. synapse/resource/resource.py +304 -0
  42. synapse/resources/__init__.py +5 -0
  43. synapse/skills/__init__.py +5 -0
  44. synapse/skills/skill.py +321 -0
  45. synapse/storage/__init__.py +24 -0
  46. synapse/storage/graph_exporter.py +624 -0
  47. synapse/storage/graph_store.py +162 -0
  48. synapse/storage/vector_store.py +103 -0
  49. synapse_framework-0.2.4.dist-info/METADATA +415 -0
  50. synapse_framework-0.2.4.dist-info/RECORD +54 -0
  51. synapse_framework-0.2.4.dist-info/WHEEL +5 -0
  52. synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
  53. synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
  54. synapse_framework-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,292 @@
1
+ """Gmail connector for the Synapse framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from langchain_core.documents import Document
10
+
11
+ from synapse.connectors.base import BaseConnector
12
+ from synapse.oauth.google import GoogleOAuthService
13
+
14
+ GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
15
+
16
+
17
+ class GmailConnector(BaseConnector):
18
+ """Google Gmail connector for fetching, searching, and indexing emails.
19
+
20
+ Attributes:
21
+ oauth: Reusable GoogleOAuthService managing OAuth tokens and Discovery client.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ credentials_path: str | Path = "credentials.json",
27
+ token_path: str | Path = "token.json", # nosec B107
28
+ oauth_service: GoogleOAuthService | None = None,
29
+ scopes: list[str] | None = None,
30
+ ) -> None:
31
+ """Initializes the GmailConnector.
32
+
33
+ Args:
34
+ credentials_path: Path to OAuth client secrets JSON.
35
+ token_path: Path to cached token JSON.
36
+ oauth_service: Optional pre-configured GoogleOAuthService instance.
37
+ scopes: Optional list of OAuth scopes.
38
+ """
39
+ super().__init__(
40
+ name="gmail",
41
+ description="Gmail inbox, unread/read emails, senders, and message threads",
42
+ intent_regex=(
43
+ r"\b(email|emails|e-mail|inbox|unread|mail|mails|message|messages|"
44
+ r"gmail|sender|sent\s+me|from\s+[a-z0-9_.-]+|check\s+my\s+mail|"
45
+ r"check\s+my\s+inbox|recent\s+threads)\b"
46
+ ),
47
+ default_tool="search_emails",
48
+ )
49
+
50
+ active_scopes = list(scopes or [GMAIL_READONLY_SCOPE])
51
+ if oauth_service:
52
+ self.oauth = oauth_service
53
+ self.oauth.add_scopes(active_scopes)
54
+ else:
55
+ self.oauth = GoogleOAuthService(
56
+ credentials_path=credentials_path,
57
+ token_path=token_path,
58
+ scopes=active_scopes,
59
+ )
60
+
61
+ def is_authenticated(self) -> bool:
62
+ """Returns True if a valid Gmail OAuth token exists."""
63
+ return self.oauth.is_authenticated()
64
+
65
+ async def list_tools(self) -> list[dict[str, Any]]:
66
+ """Returns the Gmail tool schemas."""
67
+ return [
68
+ {
69
+ "server": self.name,
70
+ "name": "search_emails",
71
+ "description": (
72
+ "Search Gmail messages using Gmail query syntax "
73
+ "(e.g. 'label:INBOX', 'label:INBOX is:unread', 'from:Sender')."
74
+ ),
75
+ "inputSchema": {
76
+ "type": "object",
77
+ "properties": {
78
+ "query": {
79
+ "type": "string",
80
+ "default": "label:INBOX",
81
+ "description": "Gmail search query string.",
82
+ },
83
+ "max_results": {
84
+ "type": "integer",
85
+ "default": 5,
86
+ "description": "Maximum number of messages to return.",
87
+ },
88
+ },
89
+ },
90
+ }
91
+ ]
92
+
93
+ def default_args_builder(self, user_message: str) -> dict[str, Any]:
94
+ """Constructs smart Gmail query arguments from user natural language prompt."""
95
+ text = (user_message or "").strip()
96
+ q_lower = text.lower()
97
+ wants_unread = bool(
98
+ re.search(
99
+ r"\b(unread|unseen|not\s+read|new\s+email|new\s+emails|new\s+message|new\s+messages)\b",
100
+ q_lower,
101
+ )
102
+ )
103
+ query = "label:INBOX is:unread" if wants_unread else "label:INBOX"
104
+
105
+ from_match = re.search(
106
+ r"\bfrom\s+([a-zA-Z0-9_.@-]+)", text, re.IGNORECASE
107
+ )
108
+ if from_match:
109
+ sender_term = from_match.group(1).strip("?.!,")
110
+ if sender_term.lower() not in {
111
+ "my",
112
+ "the",
113
+ "recent",
114
+ "inbox",
115
+ "gmail",
116
+ "email",
117
+ "emails",
118
+ }:
119
+ query = (
120
+ f"from:{sender_term} is:unread"
121
+ if wants_unread
122
+ else f"from:{sender_term}"
123
+ )
124
+
125
+ return {"query": query, "max_results": 5}
126
+
127
+ async def call_tool(
128
+ self,
129
+ tool_name: str,
130
+ arguments: dict[str, Any] | None = None,
131
+ ) -> Any:
132
+ """Executes a Gmail tool via Google API."""
133
+ args = arguments or {}
134
+ if tool_name == "search_emails":
135
+ query = str(args.get("query", "label:INBOX"))
136
+ max_results = int(args.get("max_results", 5))
137
+ return self.search_emails(query=query, max_results=max_results)
138
+ raise ValueError(
139
+ f"Unknown tool '{tool_name}' for connector '{self.name}'"
140
+ )
141
+
142
+ def search_emails(
143
+ self,
144
+ query: str = "label:INBOX",
145
+ max_results: int = 5,
146
+ ) -> list[dict[str, Any]]:
147
+ """Searches Gmail using Google API Discovery client."""
148
+ if not self.is_authenticated():
149
+ return []
150
+
151
+ service = self.oauth.get_service(
152
+ api_name="gmail", version="v1", scopes=[GMAIL_READONLY_SCOPE]
153
+ )
154
+ response = (
155
+ service.users()
156
+ .messages()
157
+ .list(userId="me", q=query, maxResults=max_results)
158
+ .execute()
159
+ )
160
+
161
+ messages_meta = response.get("messages", [])
162
+ if not messages_meta:
163
+ return []
164
+
165
+ results: list[dict[str, Any]] = []
166
+ for meta in messages_meta:
167
+ msg = (
168
+ service.users()
169
+ .messages()
170
+ .get(userId="me", id=meta["id"], format="full")
171
+ .execute()
172
+ )
173
+
174
+ payload = msg.get("payload", {})
175
+ headers = payload.get("headers", [])
176
+ label_ids = msg.get("labelIds", []) or []
177
+ is_unread = "UNREAD" in label_ids
178
+
179
+ subject = next(
180
+ (h["value"] for h in headers if h["name"].lower() == "subject"),
181
+ "No Subject",
182
+ )
183
+ sender = next(
184
+ (h["value"] for h in headers if h["name"].lower() == "from"),
185
+ "Unknown Sender",
186
+ )
187
+ recipient = next(
188
+ (h["value"] for h in headers if h["name"].lower() == "to"), "Me"
189
+ )
190
+ date_str = next(
191
+ (h["value"] for h in headers if h["name"].lower() == "date"), ""
192
+ )
193
+
194
+ body_text = GoogleOAuthService.decode_mime_body(payload)
195
+ snippet = msg.get("snippet", "")
196
+
197
+ results.append({
198
+ "id": msg["id"],
199
+ "subject": subject,
200
+ "sender": sender,
201
+ "to": recipient,
202
+ "date": date_str,
203
+ "is_unread": is_unread,
204
+ "labels": label_ids,
205
+ "snippet": snippet,
206
+ "body": (body_text.strip() or snippet.strip()),
207
+ })
208
+
209
+ return results
210
+
211
+ def chunk_record(
212
+ self,
213
+ tool_name: str,
214
+ record: dict[str, Any],
215
+ ) -> list[Document]:
216
+ """Chunks an email record with rich unread metadata."""
217
+ email_id = str(record.get("id", ""))
218
+ subject = str(record.get("subject", "No Subject")).strip()
219
+ sender = str(
220
+ record.get("from") or record.get("sender") or "Unknown Sender"
221
+ ).strip()
222
+ recipients = str(record.get("to", "Me")).strip()
223
+ date_str = str(record.get("date", "")).strip()
224
+ body = str(record.get("body", "")).strip()
225
+ is_unread = bool(record.get("is_unread", False))
226
+ read_tag = "UNREAD" if is_unread else "READ"
227
+
228
+ if not body and not subject:
229
+ return []
230
+
231
+ header = f"[EMAIL | {read_tag}] Subject: {subject} | From: {sender} | To: {recipients}"
232
+ full_text = f"{header}\nDate: {date_str}\n\n{body}"
233
+
234
+ base_meta = {
235
+ "source": "gmail",
236
+ "email_id": email_id,
237
+ "subject": subject,
238
+ "sender": sender,
239
+ "recipients": recipients,
240
+ "date": date_str,
241
+ "is_unread": is_unread,
242
+ "type": "Email",
243
+ "chunk_id": f"gmail:{email_id}:0",
244
+ }
245
+
246
+ return [Document(page_content=full_text, metadata=base_meta)]
247
+
248
+ def format_record(
249
+ self,
250
+ tool_name: str,
251
+ record: dict[str, Any],
252
+ index: int = 1,
253
+ ) -> tuple[str, dict[str, Any], str]:
254
+ """Formats email into context string, UI chunk, and knowledge graph triple."""
255
+ is_unread = bool(record.get("is_unread", False))
256
+ unread_badge = "UNREAD" if is_unread else "READ"
257
+ subject = record.get("subject", "No Subject")
258
+ sender = record.get("sender", "Unknown Sender")
259
+ date_str = record.get("date", "")
260
+ body_excerpt = (
261
+ record.get("body") or record.get("snippet") or ""
262
+ )[:600].strip()
263
+
264
+ summary_line = (
265
+ f"{index}. [{unread_badge}] Subject: {subject}\n"
266
+ f" From: {sender} | Date: {date_str}\n"
267
+ f" Content: {body_excerpt}"
268
+ )
269
+
270
+ live_chunk = {
271
+ "page_content": (
272
+ f"[EMAIL | {unread_badge}] Subject: {subject} | From: {sender}\n"
273
+ f"Date: {date_str}\n\n{body_excerpt}"
274
+ ),
275
+ "metadata": {
276
+ "source": "gmail_live",
277
+ "subject": f"[{unread_badge}] {subject}",
278
+ "sender": sender,
279
+ "date": date_str,
280
+ "is_unread": is_unread,
281
+ },
282
+ "source": "gmail_live",
283
+ "subject": f"[{unread_badge}] {subject}",
284
+ "chunk_id": f"live_gmail_{record.get('id', index)}",
285
+ }
286
+
287
+ triple = f"{sender} -> SENT -> {subject}"
288
+ return summary_line, live_chunk, triple
289
+
290
+
291
+ # Developer-friendly alias matching user request
292
+ GmailConnectors = GmailConnector
@@ -0,0 +1,153 @@
1
+ """Generic FastMCP / stdio MCP connector wrapper for the Synapse framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from mcp import ClientSession, StdioServerParameters
13
+ from mcp.client.stdio import stdio_client
14
+
15
+ from synapse.connectors.base import BaseConnector
16
+
17
+
18
+ class MCPConnector(BaseConnector):
19
+ """Wraps an external FastMCP or stdio MCP server into a Synapse BaseConnector.
20
+
21
+ Enables adding any MCP server (e.g. GitHub, Slack, Google Calendar, Postgres)
22
+ with a single class instantiation.
23
+
24
+ Attributes:
25
+ server_params: StdioServerParameters used to run the MCP subprocess.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ name: str,
31
+ script_path: str | Path | None = None,
32
+ command: str | None = None,
33
+ args: list[str] | None = None,
34
+ env: dict[str, str] | None = None,
35
+ description: str = "",
36
+ intent_regex: str | None = None,
37
+ default_tool: str = "",
38
+ is_ready_fn: Callable[[], bool] | None = None,
39
+ static_tools: list[dict[str, Any]] | None = None,
40
+ ) -> None:
41
+ """Initializes the generic MCP connector.
42
+
43
+ Args:
44
+ name: Connector identifier.
45
+ script_path: Path to a Python MCP server script.
46
+ command: Executable command override (e.g. 'uvx', 'npx').
47
+ args: CLI arguments when using an external command.
48
+ env: Extra environment variables.
49
+ description: Domain description.
50
+ intent_regex: Regex for matching relevant user prompts.
51
+ default_tool: Default tool name.
52
+ is_ready_fn: Callable returning True when authentication is ready.
53
+ static_tools: Pre-declared tool schemas.
54
+ """
55
+ super().__init__(
56
+ name=name,
57
+ description=description or f"{name} MCP connector",
58
+ intent_regex=intent_regex,
59
+ default_tool=default_tool,
60
+ )
61
+ self.is_ready_fn = is_ready_fn or (lambda: True)
62
+ self.static_tools = static_tools or []
63
+ self._tools_cache: list[dict[str, Any]] | None = (
64
+ static_tools if static_tools else None
65
+ )
66
+
67
+ merged_env = os.environ.copy()
68
+ if script_path:
69
+ script_abs = Path(script_path).resolve()
70
+ python_path = f"{script_abs.parent}:{merged_env.get('PYTHONPATH', '')}"
71
+ merged_env["PYTHONPATH"] = python_path
72
+ exec_cmd = command or sys.executable
73
+ exec_args = args if args is not None else [str(script_abs)]
74
+ else:
75
+ exec_cmd = command or sys.executable
76
+ exec_args = args or []
77
+
78
+ if env:
79
+ merged_env.update(env)
80
+
81
+ self.server_params = StdioServerParameters(
82
+ command=exec_cmd,
83
+ args=exec_args,
84
+ env=merged_env,
85
+ )
86
+
87
+ def is_authenticated(self) -> bool:
88
+ """Checks if the wrapped MCP server is configured and ready."""
89
+ try:
90
+ return bool(self.is_ready_fn())
91
+ except Exception:
92
+ return False
93
+
94
+ async def list_tools(self) -> list[dict[str, Any]]:
95
+ """Discovers tools exposed by the MCP server."""
96
+ if self._tools_cache:
97
+ return self._tools_cache
98
+
99
+ try:
100
+ async with stdio_client(self.server_params) as (
101
+ read_stream,
102
+ write_stream,
103
+ ), ClientSession(read_stream, write_stream) as session:
104
+ await session.initialize()
105
+ tools_result = await session.list_tools()
106
+ discovered = [
107
+ {
108
+ "server": self.name,
109
+ "name": tool.name,
110
+ "description": tool.description,
111
+ "inputSchema": getattr(
112
+ tool,
113
+ "inputSchema",
114
+ getattr(tool, "input_schema", {}),
115
+ ),
116
+ }
117
+ for tool in tools_result.tools
118
+ ]
119
+ self._tools_cache = discovered
120
+ return discovered
121
+ except Exception:
122
+ return self.static_tools
123
+
124
+ async def call_tool(
125
+ self,
126
+ tool_name: str,
127
+ arguments: dict[str, Any] | None = None,
128
+ ) -> Any:
129
+ """Invokes a tool on the MCP server over stdio."""
130
+ async with stdio_client(self.server_params) as (
131
+ read_stream,
132
+ write_stream,
133
+ ), ClientSession(read_stream, write_stream) as session:
134
+ await session.initialize()
135
+ result = await session.call_tool(
136
+ name=tool_name,
137
+ arguments=arguments or {},
138
+ )
139
+
140
+ extracted_data: list[Any] = []
141
+ for content in result.content:
142
+ if hasattr(content, "text"):
143
+ try:
144
+ parsed = json.loads(content.text)
145
+ extracted_data.append(parsed)
146
+ except (json.JSONDecodeError, TypeError):
147
+ extracted_data.append(content.text)
148
+ else:
149
+ extracted_data.append(content)
150
+
151
+ if len(extracted_data) == 1:
152
+ return extracted_data[0]
153
+ return extracted_data
@@ -0,0 +1,161 @@
1
+ """Notion connector for searching and indexing workspace documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from synapse.connectors.base import BaseConnector
11
+ from synapse.oauth.notion import NotionAuthService
12
+
13
+
14
+ class NotionConnector(BaseConnector):
15
+ """Notion workspace connector for querying pages, databases, and notes.
16
+
17
+ Attributes:
18
+ auth: NotionAuthService managing API authentication headers.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ api_key: str | None = None,
24
+ auth_service: NotionAuthService | None = None,
25
+ ) -> None:
26
+ """Initializes the NotionConnector.
27
+
28
+ Args:
29
+ api_key: Optional Notion API integration token.
30
+ auth_service: Optional pre-configured NotionAuthService instance.
31
+ """
32
+ super().__init__(
33
+ name="notion",
34
+ description="Notion workspace pages, notes, wiki docs, and databases",
35
+ intent_regex=(
36
+ r"\b(notion|wiki|workspace\s+page|workspace\s+pages|notion\s+page|"
37
+ r"notion\s+pages|notion\s+note|notion\s+notes|notion\s+database)\b"
38
+ ),
39
+ default_tool="search_notion_pages",
40
+ )
41
+ self.auth = auth_service or NotionAuthService(api_key=api_key)
42
+
43
+ def is_authenticated(self) -> bool:
44
+ """Checks if a Notion API token is available."""
45
+ return self.auth.is_authenticated()
46
+
47
+ async def list_tools(self) -> list[dict[str, Any]]:
48
+ """Returns the Notion tool schemas."""
49
+ return [
50
+ {
51
+ "server": self.name,
52
+ "name": "search_notion_pages",
53
+ "description": "Search Notion workspace pages and databases matching a query.",
54
+ "inputSchema": {
55
+ "type": "object",
56
+ "properties": {
57
+ "query": {
58
+ "type": "string",
59
+ "default": "",
60
+ "description": "Title or keyword substring to search.",
61
+ },
62
+ "max_results": {
63
+ "type": "integer",
64
+ "default": 5,
65
+ "description": "Maximum number of Notion items to return.",
66
+ },
67
+ },
68
+ },
69
+ }
70
+ ]
71
+
72
+ def default_args_builder(self, user_message: str) -> dict[str, Any]:
73
+ """Strips conversational noise from user prompt for Notion search."""
74
+ text = (user_message or "").strip()
75
+ cleaned = re.sub(
76
+ r"\b(check|find|search|show|my|in|on|from|notion|pages|page|notes|note|database|workspace|docs)\b",
77
+ " ",
78
+ text,
79
+ flags=re.IGNORECASE,
80
+ )
81
+ cleaned = " ".join(cleaned.split()).strip("?.!,")
82
+ return {"query": cleaned[:80], "max_results": 5}
83
+
84
+ async def call_tool(
85
+ self,
86
+ tool_name: str,
87
+ arguments: dict[str, Any] | None = None,
88
+ ) -> Any:
89
+ """Executes a Notion tool."""
90
+ args = arguments or {}
91
+ if tool_name == "search_notion_pages":
92
+ query = str(args.get("query", ""))
93
+ max_results = int(args.get("max_results", 5))
94
+ return await self.search_notion_pages(
95
+ query=query, max_results=max_results
96
+ )
97
+ raise ValueError(
98
+ f"Unknown tool '{tool_name}' for connector '{self.name}'"
99
+ )
100
+
101
+ async def search_notion_pages(
102
+ self,
103
+ query: str = "",
104
+ max_results: int = 5,
105
+ ) -> list[dict[str, Any]]:
106
+ """Executes a live search against the Notion v1 REST API."""
107
+ if not self.is_authenticated():
108
+ return []
109
+
110
+ payload: dict[str, Any] = {"page_size": max(1, min(max_results, 20))}
111
+ if query.strip():
112
+ payload["query"] = query.strip()
113
+
114
+ async with httpx.AsyncClient(timeout=20.0) as client:
115
+ res = await client.post(
116
+ f"{self.auth.base_url}/search",
117
+ headers=self.auth.get_headers(),
118
+ json=payload,
119
+ )
120
+ res.raise_for_status()
121
+ data = res.json()
122
+
123
+ results: list[dict[str, Any]] = []
124
+ for item in data.get("results", []):
125
+ item_id = item.get("id", "")
126
+ obj_type = item.get("object", "page")
127
+ url = item.get("url", "")
128
+ last_edited = item.get("last_edited_time", "")
129
+
130
+ title = "Untitled Notion Page"
131
+ props = item.get("properties", {})
132
+ for prop_val in props.values():
133
+ if (
134
+ isinstance(prop_val, dict)
135
+ and prop_val.get("type") == "title"
136
+ ):
137
+ title_arr = prop_val.get("title", [])
138
+ if title_arr:
139
+ title = (
140
+ "".join(
141
+ t.get("plain_text", "") for t in title_arr
142
+ ).strip()
143
+ or title
144
+ )
145
+ break
146
+
147
+ results.append({
148
+ "id": item_id,
149
+ "title": title,
150
+ "type": obj_type,
151
+ "url": url,
152
+ "date": last_edited,
153
+ "content": f"Notion {obj_type}: {title} (URL: {url})",
154
+ })
155
+
156
+ return results
157
+
158
+
159
+ # Developer-friendly aliases matching different casing
160
+ NotionConnectors = NotionConnector
161
+ NotionsConnector = NotionConnector