memoreei 0.2.0__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.
memoreei/__init__.py ADDED
File without changes
memoreei/cli.py ADDED
@@ -0,0 +1,490 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ app = typer.Typer(help="Memoreei — personal memory MCP server CLI")
10
+ import_app = typer.Typer(help="Import data from various sources")
11
+ app.add_typer(import_app, name="import")
12
+
13
+
14
+ @app.command()
15
+ def serve(
16
+ sse: bool = typer.Option(False, "--sse", help="Use SSE transport instead of stdio"),
17
+ port: int = typer.Option(8080, "--port", help="Port for SSE transport"),
18
+ ) -> None:
19
+ """Start the MCP server."""
20
+ from memoreei.server import mcp
21
+
22
+ if sse:
23
+ mcp.run(transport="sse", port=port)
24
+ else:
25
+ mcp.run(transport="stdio")
26
+
27
+
28
+ @app.command()
29
+ def status() -> None:
30
+ """Show DB stats: message counts, sources, last sync times."""
31
+
32
+ async def _run() -> None:
33
+ from memoreei.config import get_config
34
+ from memoreei.search.embeddings import get_provider
35
+ from memoreei.storage.database import Database
36
+
37
+ cfg = get_config()
38
+ db = Database(db_path=cfg.db_path)
39
+ await db.connect()
40
+
41
+ sources = await db.list_sources()
42
+ total = sum(s.get("count", 0) for s in sources)
43
+
44
+ typer.echo(f"DB: {cfg.db_path}")
45
+ typer.echo(f"Total messages: {total}")
46
+ typer.echo(f"Embedding provider: {cfg.embedding_provider}")
47
+ typer.echo("")
48
+ typer.echo("Sources:")
49
+ if sources:
50
+ for s in sources:
51
+ typer.echo(f" {s['source']:40s} {s.get('count', 0):>6} messages")
52
+ else:
53
+ typer.echo(" (none)")
54
+
55
+ typer.echo("")
56
+ typer.echo(f"Configured connectors: {cfg.configured_connectors() or ['(none)']}")
57
+ await db.close()
58
+
59
+ asyncio.run(_run())
60
+
61
+
62
+ @app.command()
63
+ def sync(
64
+ source: Optional[str] = typer.Argument(None, help="Source to sync (discord, telegram, matrix, slack, email, mastodon). Omit for all."),
65
+ ) -> None:
66
+ """Sync a specific source or all configured sources."""
67
+
68
+ async def _run() -> None:
69
+ from memoreei.config import get_config
70
+ from memoreei.search.embeddings import get_provider
71
+ from memoreei.storage.database import Database
72
+ from memoreei.sync_manager import SyncManager
73
+ from memoreei.tools.memory_tools import MemoryTools
74
+
75
+ cfg = get_config()
76
+ db = Database(db_path=cfg.db_path)
77
+ await db.connect()
78
+ embedder = get_provider()
79
+ tools = MemoryTools(db=db, embedder=embedder)
80
+ manager = SyncManager()
81
+
82
+ if source:
83
+ count = await manager.sync_source(source, tools)
84
+ typer.echo(f"Synced {count} new messages from {source}")
85
+ else:
86
+ configured = cfg.configured_connectors()
87
+ if not configured:
88
+ typer.echo("No connectors configured.")
89
+ return
90
+ total = 0
91
+ for src in configured:
92
+ count = await manager.sync_source(src, tools)
93
+ typer.echo(f" {src}: {count} new messages")
94
+ total += count
95
+ typer.echo(f"Total: {total} new messages")
96
+
97
+ await db.close()
98
+
99
+ asyncio.run(_run())
100
+
101
+
102
+ @app.command()
103
+ def search(
104
+ query: str = typer.Argument(..., help="Search query"),
105
+ limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
106
+ source: Optional[str] = typer.Option(None, "--source", "-s", help="Filter by source"),
107
+ ) -> None:
108
+ """Search memories from the CLI."""
109
+
110
+ async def _run() -> None:
111
+ from memoreei.config import get_config
112
+ from memoreei.search.embeddings import get_provider
113
+ from memoreei.storage.database import Database
114
+ from memoreei.tools.memory_tools import MemoryTools
115
+
116
+ cfg = get_config()
117
+ db = Database(db_path=cfg.db_path)
118
+ await db.connect()
119
+ embedder = get_provider()
120
+ tools = MemoryTools(db=db, embedder=embedder)
121
+
122
+ results = await tools.search_memory(query=query, limit=limit, source=source)
123
+ if not results:
124
+ typer.echo("No results found.")
125
+ return
126
+
127
+ for i, r in enumerate(results, 1):
128
+ typer.echo(f"\n[{i}] {r.get('source', '?')} {r.get('timestamp', '')}")
129
+ typer.echo(f" {r.get('content', '')[:200]}")
130
+ if r.get("participant"):
131
+ typer.echo(f" — {r['participant']}")
132
+
133
+ await db.close()
134
+
135
+ asyncio.run(_run())
136
+
137
+
138
+ @import_app.command(name="whatsapp")
139
+ def import_whatsapp(
140
+ file: str = typer.Argument(..., help="Path to WhatsApp .txt export file"),
141
+ ) -> None:
142
+ """Import a WhatsApp chat export .txt file."""
143
+
144
+ async def _run() -> None:
145
+ from memoreei.config import get_config
146
+ from memoreei.search.embeddings import get_provider
147
+ from memoreei.storage.database import Database
148
+ from memoreei.tools.memory_tools import MemoryTools
149
+
150
+ cfg = get_config()
151
+ db = Database(db_path=cfg.db_path)
152
+ await db.connect()
153
+ embedder = get_provider()
154
+ tools = MemoryTools(db=db, embedder=embedder)
155
+
156
+ result = await tools.ingest_whatsapp(file_path=file)
157
+ typer.echo(json.dumps(result, indent=2))
158
+ await db.close()
159
+
160
+ asyncio.run(_run())
161
+
162
+
163
+ @import_app.command(name="sms")
164
+ def import_sms(
165
+ file: str = typer.Argument(..., help="Path to SMS Backup & Restore .xml file"),
166
+ ) -> None:
167
+ """Import SMS/MMS messages from an Android SMS Backup & Restore XML file."""
168
+
169
+ async def _run() -> None:
170
+ from memoreei.config import get_config
171
+ from memoreei.search.embeddings import get_provider
172
+ from memoreei.storage.database import Database
173
+ from memoreei.tools.memory_tools import MemoryTools
174
+
175
+ cfg = get_config()
176
+ db = Database(db_path=cfg.db_path)
177
+ await db.connect()
178
+ embedder = get_provider()
179
+ tools = MemoryTools(db=db, embedder=embedder)
180
+
181
+ result = await tools.import_sms_backup(file_path=file)
182
+ typer.echo(json.dumps(result, indent=2))
183
+ await db.close()
184
+
185
+ asyncio.run(_run())
186
+
187
+
188
+ @import_app.command(name="discord-package")
189
+ def import_discord_package(
190
+ path: str = typer.Argument(..., help="Path to extracted Discord data package folder or ZIP file"),
191
+ ) -> None:
192
+ """Import a Discord Data Package (GDPR export) — all channels, DMs, servers.
193
+
194
+ Request your data at Discord Settings > Privacy & Safety > Request All of My Data.
195
+ """
196
+
197
+ async def _run() -> None:
198
+ from memoreei.config import get_config
199
+ from memoreei.search.embeddings import get_provider
200
+ from memoreei.storage.database import Database
201
+ from memoreei.tools.memory_tools import MemoryTools
202
+
203
+ cfg = get_config()
204
+ db = Database(db_path=cfg.db_path)
205
+ await db.connect()
206
+ embedder = get_provider()
207
+ tools = MemoryTools(db=db, embedder=embedder)
208
+
209
+ result = await tools.import_discord_package_tool(package_path=path)
210
+ typer.echo(json.dumps(result, indent=2))
211
+ await db.close()
212
+
213
+ asyncio.run(_run())
214
+
215
+
216
+ _CONNECTORS = {
217
+ "gmail": {
218
+ "name": "Gmail (IMAP)",
219
+ "icon": "📧",
220
+ "vars": [
221
+ ("GMAIL_EMAIL", "Gmail address", False, "e.g. you@gmail.com"),
222
+ ("GMAIL_APP_PASSWORD", "App Password", True,
223
+ "Generate at https://myaccount.google.com/apppasswords (requires 2FA)"),
224
+ ],
225
+ "sync_name": "email",
226
+ },
227
+ "discord": {
228
+ "name": "Discord (Bot API)",
229
+ "icon": "🎮",
230
+ "vars": [
231
+ ("DISCORD_BOT_TOKEN", "Bot token", True, "From https://discord.com/developers/applications"),
232
+ ("DISCORD_CHANNEL_ID", "Channel ID", False, "Right-click channel → Copy ID (enable Developer Mode)"),
233
+ ],
234
+ },
235
+ "telegram": {
236
+ "name": "Telegram",
237
+ "icon": "✈️",
238
+ "vars": [
239
+ ("TELEGRAM_BOT_TOKEN", "Bot token", True, "From @BotFather on Telegram"),
240
+ ("TELEGRAM_CHAT_ID", "Chat ID", False, "Use @userinfobot or check API updates"),
241
+ ],
242
+ },
243
+ "slack": {
244
+ "name": "Slack",
245
+ "icon": "💬",
246
+ "vars": [
247
+ ("SLACK_BOT_TOKEN", "Bot token", True, "From https://api.slack.com/apps → OAuth & Permissions"),
248
+ ("SLACK_CHANNEL_ID", "Channel ID", False, "Right-click channel → View channel details → copy ID"),
249
+ ],
250
+ },
251
+ "matrix": {
252
+ "name": "Matrix",
253
+ "icon": "🟩",
254
+ "vars": [
255
+ ("MATRIX_HOMESERVER", "Homeserver URL", False, "e.g. https://matrix.org"),
256
+ ("MATRIX_ACCESS_TOKEN", "Access token", True, "Settings → Help & About → Access Token in Element"),
257
+ ("MATRIX_ROOM_ID", "Room ID", False, "e.g. !abc123:matrix.org"),
258
+ ],
259
+ },
260
+ "mastodon": {
261
+ "name": "Mastodon",
262
+ "icon": "🐘",
263
+ "vars": [
264
+ ("MASTODON_INSTANCE", "Instance URL", False, "e.g. https://mastodon.social"),
265
+ ("MASTODON_HASHTAG", "Hashtag to track (optional)", False, "Without the # sign"),
266
+ ("MASTODON_ACCESS_TOKEN", "Access token", True,
267
+ "Preferences → Development → New Application → copy token"),
268
+ ],
269
+ },
270
+ "signal": {
271
+ "name": "Signal Desktop",
272
+ "icon": "🔒",
273
+ "vars": [
274
+ ("SIGNAL_DB_PATH", "Signal DB path (optional)", False,
275
+ "Leave blank for auto-detect (~/.config/Signal/sql/db.sqlite)"),
276
+ ("SIGNAL_CONFIG_PATH", "Signal config path (optional)", False,
277
+ "Leave blank for auto-detect (~/.config/Signal/config.json)"),
278
+ ],
279
+ },
280
+ "imessage": {
281
+ "name": "iMessage (macOS only)",
282
+ "icon": "🍎",
283
+ "vars": [
284
+ ("IMESSAGE_DB_PATH", "Messages DB path", False,
285
+ "Default: ~/Library/Messages/chat.db"),
286
+ ],
287
+ },
288
+ }
289
+
290
+
291
+ def _find_env_path() -> "Path":
292
+ from pathlib import Path
293
+
294
+ env_path = Path(".env")
295
+ if not env_path.exists():
296
+ candidate = Path(__file__).parent.parent.parent.parent / ".env"
297
+ if candidate.exists():
298
+ env_path = candidate
299
+ return env_path
300
+
301
+
302
+ def _read_env_lines(env_path: "Path") -> list[str]:
303
+ if env_path.exists():
304
+ return env_path.read_text().splitlines()
305
+ return []
306
+
307
+
308
+ def _write_env_updates(
309
+ env_path: "Path", env_lines: list[str], updates: list[tuple[str, str]]
310
+ ) -> None:
311
+ for var_name, value in updates:
312
+ found = False
313
+ for i, line in enumerate(env_lines):
314
+ stripped = line.lstrip("# ").strip()
315
+ if stripped.startswith(f"{var_name}=") or stripped.startswith(f"{var_name} ="):
316
+ env_lines[i] = f"{var_name}={value}"
317
+ found = True
318
+ break
319
+ if not found:
320
+ env_lines.append(f"{var_name}={value}")
321
+ env_path.write_text("\n".join(env_lines) + "\n")
322
+
323
+
324
+ def _prompt_connector_vars(key: str) -> list[tuple[str, str]]:
325
+ """Prompt the user for a single connector's variables. Returns list of (var, value)."""
326
+ import questionary
327
+
328
+ info = _CONNECTORS[key]
329
+ typer.echo(f"\n {info['icon']} {info['name']}\n")
330
+ updates: list[tuple[str, str]] = []
331
+
332
+ for var_name, label, is_secret, hint in info["vars"]:
333
+ typer.echo(f" {typer.style(hint, dim=True)}")
334
+ if is_secret:
335
+ value = questionary.password(f" {label}:").ask()
336
+ else:
337
+ value = questionary.text(f" {label}:").ask()
338
+ if value is None:
339
+ # User pressed Ctrl-C
340
+ raise typer.Exit(1)
341
+ if value.strip():
342
+ updates.append((var_name, value.strip()))
343
+ typer.echo("")
344
+
345
+ return updates
346
+
347
+
348
+ @app.command()
349
+ def setup(
350
+ connector: Optional[str] = typer.Argument(
351
+ None,
352
+ help="Connector to configure (gmail, discord, telegram, slack, matrix, mastodon, signal, imessage). Omit to choose interactively.",
353
+ ),
354
+ ) -> None:
355
+ """Interactive setup — configure connectors and write credentials to .env."""
356
+ import questionary
357
+ from pathlib import Path
358
+
359
+ env_path = _find_env_path()
360
+ env_lines = _read_env_lines(env_path)
361
+
362
+ # Check if this is first-time setup (no .env or no DB path configured)
363
+ existing_db_path = None
364
+ for line in env_lines:
365
+ stripped = line.strip()
366
+ if stripped.startswith("MEMOREEI_DB_PATH=") and not stripped.startswith("#"):
367
+ existing_db_path = stripped.split("=", 1)[1].strip()
368
+ break
369
+
370
+ if not existing_db_path:
371
+ typer.echo("\n 🗄️ Database setup\n")
372
+ default_path = str(Path.home() / ".memoreei" / "memoreei.db")
373
+ db_path = questionary.text(
374
+ " Where should Memoreei store its database?",
375
+ default=default_path,
376
+ ).ask()
377
+ if db_path is None:
378
+ raise typer.Exit(1)
379
+ db_path = db_path.strip() or default_path
380
+ # Ensure parent directory exists
381
+ db_dir = Path(db_path).expanduser().parent
382
+ db_dir.mkdir(parents=True, exist_ok=True)
383
+ # Prepend to env updates
384
+ _write_env_updates(env_path, env_lines, [("MEMOREEI_DB_PATH", db_path)])
385
+ env_lines = _read_env_lines(env_path) # reload after write
386
+ typer.echo(f" ✓ Database: {db_path}\n")
387
+
388
+ if connector:
389
+ # Single connector mode
390
+ key = connector.lower().replace("-", "").replace("_", "")
391
+ if key not in _CONNECTORS:
392
+ typer.echo(f"Unknown connector: {connector}")
393
+ typer.echo(f"Available: {', '.join(_CONNECTORS.keys())}")
394
+ raise typer.Exit(1)
395
+ selected_keys = [key]
396
+ else:
397
+ # Interactive multi-select with spacebar
398
+ choices = [
399
+ questionary.Choice(
400
+ title=f"{info['icon']} {info['name']}",
401
+ value=key,
402
+ )
403
+ for key, info in _CONNECTORS.items()
404
+ ]
405
+ selected_keys = questionary.checkbox(
406
+ "Select connectors to configure (space to toggle, enter to confirm):",
407
+ choices=choices,
408
+ instruction="",
409
+ ).ask()
410
+ if not selected_keys:
411
+ typer.echo("\nNothing selected.")
412
+ raise typer.Exit(0)
413
+
414
+ # Prompt for each selected connector
415
+ all_updates: list[tuple[str, str]] = []
416
+ configured: list[str] = []
417
+
418
+ for key in selected_keys:
419
+ updates = _prompt_connector_vars(key)
420
+ if updates:
421
+ all_updates.extend(updates)
422
+ configured.append(key)
423
+
424
+ if not all_updates:
425
+ typer.echo("\n Nothing to save.")
426
+ raise typer.Exit(0)
427
+
428
+ _write_env_updates(env_path, env_lines, all_updates)
429
+
430
+ typer.echo(f"\n ✓ Saved to {env_path.resolve()}\n")
431
+ for key in configured:
432
+ sync_name = _CONNECTORS[key].get("sync_name", key)
433
+ icon = _CONNECTORS[key]["icon"]
434
+ typer.echo(f" {icon} Test: memoreei sync {sync_name}")
435
+ typer.echo(f"\n Check all: memoreei config\n")
436
+
437
+
438
+ @app.command()
439
+ def config() -> None:
440
+ """Show current configuration and connector readiness."""
441
+ from memoreei.config import get_config
442
+
443
+ cfg = get_config()
444
+
445
+ def _show(label: str, value: object, *, mask: bool = False) -> None:
446
+ if value:
447
+ display = "***" if mask else str(value)
448
+ typer.echo(f" {label:30s} {display}")
449
+ else:
450
+ typer.echo(typer.style(f" {label:30s} (not set)", fg=typer.colors.YELLOW))
451
+
452
+ typer.echo("=== Core ===")
453
+ _show("MEMOREEI_DB_PATH", cfg.db_path)
454
+ _show("EMBEDDING_PROVIDER", cfg.embedding_provider)
455
+ _show("AUTO_SYNC", cfg.auto_sync)
456
+ _show("SYNC_INTERVAL", cfg.sync_interval)
457
+
458
+ typer.echo("\n=== Discord ===")
459
+ _show("DISCORD_BOT_TOKEN", cfg.discord_token, mask=True)
460
+ _show("DISCORD_CHANNEL_ID", cfg.discord_channel_id)
461
+
462
+ typer.echo("\n=== Telegram ===")
463
+ _show("TELEGRAM_BOT_TOKEN", cfg.telegram_token, mask=True)
464
+ _show("TELEGRAM_CHAT_ID", cfg.telegram_chat_id)
465
+
466
+ typer.echo("\n=== Matrix ===")
467
+ _show("MATRIX_HOMESERVER", cfg.matrix_homeserver)
468
+ _show("MATRIX_ACCESS_TOKEN", cfg.matrix_access_token, mask=True)
469
+ _show("MATRIX_ROOM_ID", cfg.matrix_room_id)
470
+
471
+ typer.echo("\n=== Slack ===")
472
+ _show("SLACK_BOT_TOKEN", cfg.slack_bot_token, mask=True)
473
+ _show("SLACK_CHANNEL_ID", cfg.slack_channel_id)
474
+
475
+ typer.echo("\n=== Gmail ===")
476
+ _show("GMAIL_EMAIL", cfg.gmail_email)
477
+ _show("GMAIL_APP_PASSWORD", cfg.gmail_app_password, mask=True)
478
+
479
+ typer.echo("\n=== Mastodon ===")
480
+ _show("MASTODON_INSTANCE", cfg.mastodon_instance)
481
+ _show("MASTODON_HASHTAG", cfg.mastodon_hashtag)
482
+ _show("MASTODON_ACCESS_TOKEN", cfg.mastodon_access_token, mask=True)
483
+
484
+ typer.echo("\n=== Ready connectors ===")
485
+ connectors = cfg.configured_connectors()
486
+ if connectors:
487
+ for c in connectors:
488
+ typer.echo(typer.style(f" ✓ {c}", fg=typer.colors.GREEN))
489
+ else:
490
+ typer.echo(" (none configured)")
memoreei/config.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ # Load .env once at import time
10
+ _here = Path(__file__).parent
11
+ for _candidate in [_here.parent.parent.parent / ".env", Path(".env")]:
12
+ if _candidate.exists():
13
+ load_dotenv(_candidate)
14
+ break
15
+
16
+
17
+ @dataclass
18
+ class Config:
19
+ # Core
20
+ db_path: str = "./memoreei.db"
21
+ embedding_provider: str = "fastembed"
22
+ openai_api_key: str | None = None
23
+ auto_sync: bool = False
24
+ sync_interval: int = 300
25
+
26
+ # Discord
27
+ discord_token: str | None = None
28
+ discord_channel_id: str | None = None
29
+
30
+ # Telegram
31
+ telegram_token: str | None = None
32
+ telegram_chat_id: str | None = None
33
+
34
+ # Matrix
35
+ matrix_homeserver: str | None = None
36
+ matrix_access_token: str | None = None
37
+ matrix_room_id: str | None = None
38
+
39
+ # Slack
40
+ slack_bot_token: str | None = None
41
+ slack_channel_id: str | None = None
42
+
43
+ # Email (Gmail)
44
+ gmail_email: str | None = None
45
+ gmail_app_password: str | None = None
46
+
47
+ # Mastodon
48
+ mastodon_instance: str | None = None
49
+ mastodon_hashtag: str | None = None
50
+ mastodon_access_token: str | None = None
51
+
52
+ # iMessage (macOS only)
53
+ imessage_db_path: str | None = None
54
+
55
+ # Signal Desktop (local encrypted DB)
56
+ signal_db_path: str | None = None
57
+ signal_config_path: str | None = None
58
+
59
+ def configured_connectors(self) -> list[str]:
60
+ """Return names of connectors that have sufficient config to operate."""
61
+ connectors: list[str] = []
62
+ if self.discord_token and self.discord_channel_id:
63
+ connectors.append("discord")
64
+ if self.telegram_token:
65
+ connectors.append("telegram")
66
+ if self.matrix_homeserver and self.matrix_access_token and self.matrix_room_id:
67
+ connectors.append("matrix")
68
+ if self.slack_bot_token and self.slack_channel_id:
69
+ connectors.append("slack")
70
+ if self.gmail_email and self.gmail_app_password:
71
+ connectors.append("email")
72
+ if self.mastodon_instance or self.mastodon_hashtag:
73
+ connectors.append("mastodon")
74
+ return connectors
75
+
76
+
77
+ _config: Config | None = None
78
+
79
+
80
+ def get_config() -> Config:
81
+ """Return the singleton Config instance, building it from env vars on first call."""
82
+ global _config
83
+ if _config is None:
84
+ _config = Config(
85
+ db_path=os.environ.get("MEMOREEI_DB_PATH", "./memoreei.db"),
86
+ embedding_provider=os.environ.get("EMBEDDING_PROVIDER", "fastembed").lower(),
87
+ openai_api_key=os.environ.get("OPENAI_API_KEY") or None,
88
+ auto_sync=os.environ.get("AUTO_SYNC", "").lower() in ("1", "true", "yes"),
89
+ sync_interval=int(os.environ.get("SYNC_INTERVAL", "300")),
90
+ discord_token=os.environ.get("DISCORD_BOT_TOKEN") or None,
91
+ discord_channel_id=os.environ.get("DISCORD_CHANNEL_ID") or None,
92
+ telegram_token=os.environ.get("TELEGRAM_BOT_TOKEN") or None,
93
+ telegram_chat_id=os.environ.get("TELEGRAM_CHAT_ID") or None,
94
+ matrix_homeserver=os.environ.get("MATRIX_HOMESERVER") or None,
95
+ matrix_access_token=os.environ.get("MATRIX_ACCESS_TOKEN") or None,
96
+ matrix_room_id=os.environ.get("MATRIX_ROOM_ID") or None,
97
+ slack_bot_token=os.environ.get("SLACK_BOT_TOKEN") or None,
98
+ slack_channel_id=os.environ.get("SLACK_CHANNEL_ID") or None,
99
+ gmail_email=os.environ.get("GMAIL_EMAIL") or None,
100
+ gmail_app_password=os.environ.get("GMAIL_APP_PASSWORD") or os.environ.get("GMAIL_PASSWORD") or None,
101
+ mastodon_instance=os.environ.get("MASTODON_INSTANCE") or None,
102
+ mastodon_hashtag=os.environ.get("MASTODON_HASHTAG") or None,
103
+ mastodon_access_token=os.environ.get("MASTODON_ACCESS_TOKEN") or None,
104
+ imessage_db_path=os.environ.get("IMESSAGE_DB_PATH") or None,
105
+ signal_db_path=os.environ.get("SIGNAL_DB_PATH") or None,
106
+ signal_config_path=os.environ.get("SIGNAL_CONFIG_PATH") or None,
107
+ )
108
+ return _config
@@ -0,0 +1,23 @@
1
+ from memoreei.connectors.base import BaseConnector, SyncResult
2
+
3
+ # Registry of available connectors (name -> class)
4
+ # Import lazily to avoid import errors when optional deps are missing
5
+ def get_connector_registry() -> dict[str, type[BaseConnector]]:
6
+ registry = {}
7
+ try:
8
+ from memoreei.connectors.discord_connector import DiscordConnector
9
+ registry["discord"] = DiscordConnector
10
+ except ImportError:
11
+ pass
12
+ try:
13
+ from memoreei.connectors.telegram_connector import TelegramConnector
14
+ registry["telegram"] = TelegramConnector
15
+ except ImportError:
16
+ pass
17
+ try:
18
+ from memoreei.connectors.imessage_connector import IMessageConnector
19
+ registry["imessage"] = IMessageConnector
20
+ except ImportError:
21
+ pass
22
+ # Add others as they're refactored
23
+ return registry
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ @dataclass
7
+ class SyncResult:
8
+ """Result of a connector sync operation."""
9
+ synced: int = 0
10
+ source: str = ""
11
+ errors: list[str] = field(default_factory=list)
12
+
13
+ @property
14
+ def ok(self) -> bool:
15
+ return len(self.errors) == 0
16
+
17
+ def to_dict(self) -> dict[str, Any]:
18
+ d = {"synced": self.synced, "source": self.source}
19
+ if self.errors:
20
+ d["errors"] = self.errors
21
+ return d
22
+
23
+ class BaseConnector(ABC):
24
+ """Abstract base for all Memoreei connectors."""
25
+
26
+ name: str = "unknown"
27
+
28
+ @abstractmethod
29
+ async def sync(self, **kwargs: Any) -> SyncResult:
30
+ """Run an incremental sync. Returns SyncResult."""
31
+ ...
32
+
33
+ @classmethod
34
+ @abstractmethod
35
+ def is_configured(cls) -> bool:
36
+ """Check if this connector has the required config (env vars, files, etc)."""
37
+ ...