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 +0 -0
- memoreei/cli.py +490 -0
- memoreei/config.py +108 -0
- memoreei/connectors/__init__.py +23 -0
- memoreei/connectors/base.py +37 -0
- memoreei/connectors/discord_connector.py +206 -0
- memoreei/connectors/discord_package_connector.py +274 -0
- memoreei/connectors/email_connector.py +323 -0
- memoreei/connectors/generic_connector.py +327 -0
- memoreei/connectors/imessage_connector.py +245 -0
- memoreei/connectors/instagram_connector.py +189 -0
- memoreei/connectors/mastodon_connector.py +156 -0
- memoreei/connectors/matrix_connector.py +217 -0
- memoreei/connectors/messenger_connector.py +206 -0
- memoreei/connectors/signal_connector.py +454 -0
- memoreei/connectors/slack_connector.py +210 -0
- memoreei/connectors/sms_connector.py +127 -0
- memoreei/connectors/telegram_connector.py +204 -0
- memoreei/connectors/whatsapp.py +122 -0
- memoreei/py.typed +0 -0
- memoreei/search/__init__.py +0 -0
- memoreei/search/embeddings.py +96 -0
- memoreei/search/hybrid.py +97 -0
- memoreei/server.py +427 -0
- memoreei/storage/__init__.py +0 -0
- memoreei/storage/database.py +454 -0
- memoreei/storage/models.py +61 -0
- memoreei/sync_manager.py +112 -0
- memoreei/tools/__init__.py +0 -0
- memoreei/tools/memory_tools.py +275 -0
- memoreei-0.2.0.dist-info/METADATA +638 -0
- memoreei-0.2.0.dist-info/RECORD +34 -0
- memoreei-0.2.0.dist-info/WHEEL +4 -0
- memoreei-0.2.0.dist-info/entry_points.txt +3 -0
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
|
+
...
|