@smilintux/skmemory 0.5.0 → 0.9.2

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 (127) hide show
  1. package/.github/workflows/ci.yml +40 -4
  2. package/.github/workflows/publish.yml +11 -5
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +399 -19
  5. package/CHANGELOG.md +179 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +425 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/examples/stignore-agent.example +59 -0
  12. package/examples/stignore-root.example +62 -0
  13. package/index.js +6 -5
  14. package/openclaw-plugin/openclaw.plugin.json +10 -0
  15. package/openclaw-plugin/package.json +2 -1
  16. package/openclaw-plugin/src/index.js +527 -230
  17. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  18. package/package.json +1 -1
  19. package/pyproject.toml +32 -9
  20. package/requirements.txt +10 -2
  21. package/scripts/dream-rescue.py +179 -0
  22. package/scripts/memory-cleanup.py +313 -0
  23. package/scripts/recover-missing.py +180 -0
  24. package/scripts/skcapstone-backup.sh +44 -0
  25. package/seeds/cloud9-lumina.seed.json +6 -4
  26. package/seeds/cloud9-opus.seed.json +13 -11
  27. package/seeds/courage.seed.json +9 -2
  28. package/seeds/curiosity.seed.json +9 -2
  29. package/seeds/grief.seed.json +9 -2
  30. package/seeds/joy.seed.json +9 -2
  31. package/seeds/love.seed.json +9 -2
  32. package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
  33. package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
  34. package/seeds/lumina-kingdom-founding.seed.json +49 -0
  35. package/seeds/lumina-pma-signed.seed.json +48 -0
  36. package/seeds/lumina-singular-achievement.seed.json +48 -0
  37. package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
  38. package/seeds/plant-kingdom-journal.py +203 -0
  39. package/seeds/plant-lumina-seeds.py +280 -0
  40. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  41. package/seeds/sovereignty.seed.json +9 -2
  42. package/seeds/trust.seed.json +9 -2
  43. package/skill.yaml +46 -0
  44. package/skmemory/HA.md +296 -0
  45. package/skmemory/__init__.py +25 -11
  46. package/skmemory/agents.py +233 -0
  47. package/skmemory/ai_client.py +46 -17
  48. package/skmemory/anchor.py +9 -11
  49. package/skmemory/audience.py +278 -0
  50. package/skmemory/backends/__init__.py +11 -4
  51. package/skmemory/backends/base.py +3 -4
  52. package/skmemory/backends/file_backend.py +19 -13
  53. package/skmemory/backends/skgraph_backend.py +596 -0
  54. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
  55. package/skmemory/backends/sqlite_backend.py +226 -72
  56. package/skmemory/backends/vaulted_backend.py +284 -0
  57. package/skmemory/cli.py +1345 -68
  58. package/skmemory/config.py +171 -0
  59. package/skmemory/context_loader.py +333 -0
  60. package/skmemory/data/audience_config.json +60 -0
  61. package/skmemory/endpoint_selector.py +391 -0
  62. package/skmemory/febs.py +225 -0
  63. package/skmemory/fortress.py +675 -0
  64. package/skmemory/graph_queries.py +238 -0
  65. package/skmemory/hooks/__init__.py +18 -0
  66. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  67. package/skmemory/hooks/pre-compact-save.sh +81 -0
  68. package/skmemory/hooks/session-end-save.sh +103 -0
  69. package/skmemory/hooks/session-start-ritual.sh +104 -0
  70. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  71. package/skmemory/importers/__init__.py +9 -1
  72. package/skmemory/importers/telegram.py +384 -47
  73. package/skmemory/importers/telegram_api.py +580 -0
  74. package/skmemory/journal.py +7 -9
  75. package/skmemory/lovenote.py +8 -13
  76. package/skmemory/mcp_server.py +859 -0
  77. package/skmemory/models.py +51 -8
  78. package/skmemory/openclaw.py +20 -28
  79. package/skmemory/post_install.py +86 -0
  80. package/skmemory/predictive.py +236 -0
  81. package/skmemory/promotion.py +548 -0
  82. package/skmemory/quadrants.py +100 -24
  83. package/skmemory/register.py +580 -0
  84. package/skmemory/register_mcp.py +196 -0
  85. package/skmemory/ritual.py +224 -59
  86. package/skmemory/seeds.py +255 -11
  87. package/skmemory/setup_wizard.py +908 -0
  88. package/skmemory/sharing.py +408 -0
  89. package/skmemory/soul.py +98 -28
  90. package/skmemory/steelman.py +273 -260
  91. package/skmemory/store.py +411 -78
  92. package/skmemory/synthesis.py +634 -0
  93. package/skmemory/vault.py +225 -0
  94. package/tests/conftest.py +46 -0
  95. package/tests/integration/__init__.py +0 -0
  96. package/tests/integration/conftest.py +233 -0
  97. package/tests/integration/test_cross_backend.py +350 -0
  98. package/tests/integration/test_skgraph_live.py +420 -0
  99. package/tests/integration/test_skvector_live.py +366 -0
  100. package/tests/test_ai_client.py +1 -4
  101. package/tests/test_audience.py +233 -0
  102. package/tests/test_backup_rotation.py +318 -0
  103. package/tests/test_cli.py +6 -6
  104. package/tests/test_endpoint_selector.py +839 -0
  105. package/tests/test_export_import.py +4 -10
  106. package/tests/test_file_backend.py +0 -1
  107. package/tests/test_fortress.py +256 -0
  108. package/tests/test_fortress_hardening.py +441 -0
  109. package/tests/test_openclaw.py +6 -6
  110. package/tests/test_predictive.py +237 -0
  111. package/tests/test_promotion.py +347 -0
  112. package/tests/test_quadrants.py +11 -5
  113. package/tests/test_ritual.py +22 -18
  114. package/tests/test_seeds.py +97 -7
  115. package/tests/test_setup.py +950 -0
  116. package/tests/test_sharing.py +257 -0
  117. package/tests/test_skgraph_backend.py +660 -0
  118. package/tests/test_skvector_backend.py +326 -0
  119. package/tests/test_soul.py +1 -3
  120. package/tests/test_sqlite_backend.py +8 -17
  121. package/tests/test_steelman.py +7 -8
  122. package/tests/test_store.py +0 -2
  123. package/tests/test_store_graph_integration.py +245 -0
  124. package/tests/test_synthesis.py +275 -0
  125. package/tests/test_telegram_import.py +39 -15
  126. package/tests/test_vault.py +187 -0
  127. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,580 @@
1
+ """
2
+ Telegram API importer for SKMemory — direct pull via Telethon.
3
+
4
+ Instead of exporting chat history manually from Telegram Desktop,
5
+ this module connects directly to the Telegram API using Telethon
6
+ and pulls messages programmatically.
7
+
8
+ Setup (one-time):
9
+ 1. Install: pip install skmemory[telegram] (or: pipx inject skmemory telethon)
10
+ 2. Credentials: Get API_ID and API_HASH from https://my.telegram.org
11
+ 3. Export:
12
+ export TELEGRAM_API_ID=12345678
13
+ export TELEGRAM_API_HASH=your_api_hash_here
14
+ 4. First run will prompt for phone number + verification code.
15
+ Session is saved at ~/.skcapstone/agent/lumina/telegram.session for future use.
16
+
17
+ Environment variables:
18
+ TELEGRAM_API_ID — your Telegram API ID (from https://my.telegram.org)
19
+ TELEGRAM_API_HASH — your Telegram API hash (from https://my.telegram.org)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import os
27
+ import tempfile
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ from ..config import SKMEMORY_HOME
32
+ from ..store import MemoryStore
33
+
34
+ SESSION_PATH = str(SKMEMORY_HOME / "telegram.session")
35
+
36
+
37
+ def check_setup() -> dict:
38
+ """Check if Telegram API import is properly configured.
39
+
40
+ Returns:
41
+ dict with keys: ready (bool), telethon (bool), credentials (bool),
42
+ session (bool), messages (list[str])
43
+ """
44
+ result = {
45
+ "ready": False,
46
+ "telethon": False,
47
+ "credentials": False,
48
+ "session": False,
49
+ "messages": [],
50
+ }
51
+
52
+ # Check telethon
53
+ try:
54
+ import telethon # noqa: F401
55
+
56
+ result["telethon"] = True
57
+ except ImportError:
58
+ result["messages"].append(
59
+ "Telethon not installed. Fix: pip install skmemory[telegram] "
60
+ "or: pipx inject skmemory telethon"
61
+ )
62
+
63
+ # Check credentials
64
+ api_id = os.environ.get("TELEGRAM_API_ID")
65
+ api_hash = os.environ.get("TELEGRAM_API_HASH")
66
+ if api_id and api_hash:
67
+ result["credentials"] = True
68
+ else:
69
+ missing = []
70
+ if not api_id:
71
+ missing.append("TELEGRAM_API_ID")
72
+ if not api_hash:
73
+ missing.append("TELEGRAM_API_HASH")
74
+ result["messages"].append(
75
+ f"Missing environment variable(s): {', '.join(missing)}. "
76
+ f"Get them from https://my.telegram.org and export them in your shell."
77
+ )
78
+
79
+ # Check session
80
+ if Path(SESSION_PATH).exists():
81
+ result["session"] = True
82
+ else:
83
+ result["messages"].append(
84
+ "No Telegram session found. First run will prompt for phone "
85
+ "number and verification code."
86
+ )
87
+
88
+ result["ready"] = result["telethon"] and result["credentials"]
89
+ return result
90
+
91
+
92
+ # Whisper STT endpoint — faster-whisper on GPU box
93
+ _WHISPER_URL = os.environ.get(
94
+ "WHISPER_STT_URL", "http://192.168.0.100:18794/v1/audio/transcriptions"
95
+ )
96
+
97
+
98
+ async def _transcribe_voice(client: "TelegramClient", message: "Message") -> str | None:
99
+ """Download a voice message and transcribe it via Whisper STT.
100
+
101
+ Returns the transcription text, or None on failure.
102
+ """
103
+ import logging
104
+
105
+ logger = logging.getLogger(__name__)
106
+
107
+ try:
108
+ import httpx
109
+ except ImportError:
110
+ logger.warning("httpx not installed — cannot transcribe voice messages")
111
+ return None
112
+
113
+ try:
114
+ # Download the voice file to a temp path
115
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
116
+ tmp_path = tmp.name
117
+
118
+ await client.download_media(message, file=tmp_path)
119
+
120
+ if not os.path.exists(tmp_path) or os.path.getsize(tmp_path) == 0:
121
+ return None
122
+
123
+ # Send to Whisper endpoint (OpenAI-compatible)
124
+ async with httpx.AsyncClient(timeout=30) as http:
125
+ with open(tmp_path, "rb") as f:
126
+ resp = await http.post(
127
+ _WHISPER_URL,
128
+ files={"file": ("voice.ogg", f, "audio/ogg")},
129
+ data={"model": "whisper-1"},
130
+ )
131
+
132
+ if resp.status_code != 200:
133
+ logger.warning("Whisper STT returned %s: %s", resp.status_code, resp.text[:200])
134
+ return None
135
+
136
+ result = resp.json()
137
+ text = result.get("text", "").strip()
138
+ return text if text else None
139
+
140
+ except Exception as e:
141
+ logger.warning("Voice transcription failed: %s", e)
142
+ return None
143
+ finally:
144
+ try:
145
+ os.unlink(tmp_path)
146
+ except OSError:
147
+ pass
148
+
149
+
150
+ async def _fetch_messages(
151
+ chat_name_or_id: str,
152
+ limit: int | None = None,
153
+ since: str | None = None,
154
+ ) -> dict:
155
+ """Connect to Telegram API and fetch messages from a chat.
156
+
157
+ Args:
158
+ chat_name_or_id: Chat username, title, or numeric ID.
159
+ limit: Maximum number of messages to fetch.
160
+ since: Only fetch messages after this date (YYYY-MM-DD).
161
+
162
+ Returns:
163
+ dict: Telegram Desktop-compatible export structure.
164
+
165
+ Raises:
166
+ RuntimeError: If API credentials are missing or connection fails.
167
+ """
168
+ api_id = os.environ.get("TELEGRAM_API_ID")
169
+ api_hash = os.environ.get("TELEGRAM_API_HASH")
170
+
171
+ if not api_id or not api_hash:
172
+ raise RuntimeError(
173
+ "Telegram API credentials not found.\n\n"
174
+ "Setup steps:\n"
175
+ " 1. Go to https://my.telegram.org and log in\n"
176
+ " 2. Click 'API development tools' and create an app\n"
177
+ " 3. Set environment variables:\n"
178
+ " export TELEGRAM_API_ID=<your_api_id>\n"
179
+ " export TELEGRAM_API_HASH=<your_api_hash>\n"
180
+ " 4. Run this command again — first run will prompt for phone verification"
181
+ )
182
+
183
+ try:
184
+ from telethon import TelegramClient
185
+ from telethon.tl.types import User
186
+ except ImportError:
187
+ raise RuntimeError(
188
+ "Telethon is required for direct API import. "
189
+ "Install it with: pip install skmemory[telegram]"
190
+ ) from None
191
+
192
+ # Ensure session directory exists
193
+ session_dir = Path(SESSION_PATH).parent
194
+ session_dir.mkdir(parents=True, exist_ok=True)
195
+
196
+ client = TelegramClient(
197
+ SESSION_PATH,
198
+ int(api_id),
199
+ api_hash,
200
+ )
201
+
202
+ await client.start()
203
+
204
+ try:
205
+ # Resolve the chat entity
206
+ try:
207
+ entity = await client.get_entity(chat_name_or_id)
208
+ except ValueError:
209
+ # Try as integer ID
210
+ try:
211
+ entity = await client.get_entity(int(chat_name_or_id))
212
+ except (ValueError, TypeError):
213
+ raise RuntimeError(f"Could not find chat: {chat_name_or_id}") from None
214
+
215
+ chat_title = getattr(entity, "title", None)
216
+ if chat_title is None:
217
+ if isinstance(entity, User):
218
+ parts = [entity.first_name or "", entity.last_name or ""]
219
+ chat_title = " ".join(p for p in parts if p) or str(entity.id)
220
+ else:
221
+ chat_title = str(entity.id)
222
+
223
+ # Build kwargs for iter_messages
224
+ kwargs = {}
225
+ if limit:
226
+ kwargs["limit"] = limit
227
+ if since:
228
+ try:
229
+ since_dt = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
230
+ kwargs["offset_date"] = since_dt
231
+ kwargs["reverse"] = True
232
+ except ValueError:
233
+ raise RuntimeError(f"Invalid date format: {since}. Use YYYY-MM-DD.") from None
234
+
235
+ if not limit and not since:
236
+ kwargs["limit"] = 1000 # sensible default
237
+
238
+ # Fetch messages
239
+ messages_data = []
240
+ async for message in client.iter_messages(entity, **kwargs):
241
+ # Extract sender name
242
+ sender_name = "Unknown"
243
+ if message.sender:
244
+ if isinstance(message.sender, User):
245
+ parts = [message.sender.first_name or "", message.sender.last_name or ""]
246
+ sender_name = " ".join(p for p in parts if p) or str(message.sender_id)
247
+ else:
248
+ sender_name = getattr(message.sender, "title", str(message.sender_id))
249
+
250
+ # Handle voice messages — download and transcribe via Whisper
251
+ is_voice = getattr(message, "voice", None) is not None
252
+ is_audio = (
253
+ not is_voice
254
+ and message.media
255
+ and type(message.media).__name__ in ("MessageMediaDocument",)
256
+ and getattr(message.document, "mime_type", "") in (
257
+ "audio/ogg", "audio/mpeg", "audio/wav", "audio/x-wav",
258
+ )
259
+ )
260
+
261
+ if (is_voice or is_audio) and not message.text:
262
+ transcription = await _transcribe_voice(client, message)
263
+ if transcription:
264
+ msg_dict = {
265
+ "id": message.id,
266
+ "type": "message",
267
+ "date": message.date.isoformat() if message.date else "",
268
+ "from": sender_name,
269
+ "from_id": f"user{message.sender_id}" if message.sender_id else "",
270
+ "text": f"[voice] {transcription}",
271
+ }
272
+ if message.reply_to and message.reply_to.reply_to_msg_id:
273
+ msg_dict["reply_to_message_id"] = message.reply_to.reply_to_msg_id
274
+ msg_dict["media_type"] = "Voice" if is_voice else "Audio"
275
+ messages_data.append(msg_dict)
276
+ continue
277
+
278
+ if not message.text:
279
+ continue
280
+
281
+ msg_dict = {
282
+ "id": message.id,
283
+ "type": "message",
284
+ "date": message.date.isoformat() if message.date else "",
285
+ "from": sender_name,
286
+ "from_id": f"user{message.sender_id}" if message.sender_id else "",
287
+ "text": message.text,
288
+ }
289
+
290
+ if message.reply_to and message.reply_to.reply_to_msg_id:
291
+ msg_dict["reply_to_message_id"] = message.reply_to.reply_to_msg_id
292
+
293
+ if message.media:
294
+ msg_dict["media_type"] = type(message.media).__name__
295
+
296
+ messages_data.append(msg_dict)
297
+
298
+ return {
299
+ "name": chat_title,
300
+ "type": "personal_chat",
301
+ "id": getattr(entity, "id", 0),
302
+ "messages": messages_data,
303
+ }
304
+ finally:
305
+ await client.disconnect()
306
+
307
+
308
+ async def send_message(
309
+ chat: str,
310
+ message: str,
311
+ parse_mode: str | None = None,
312
+ ) -> dict:
313
+ """Send a message to a Telegram chat via Telethon.
314
+
315
+ Args:
316
+ chat: Chat username, title, or numeric ID.
317
+ message: Message text to send.
318
+ parse_mode: Optional parse mode — 'html' or 'markdown'.
319
+
320
+ Returns:
321
+ dict with keys: sent (bool), message_id, chat, date.
322
+
323
+ Raises:
324
+ RuntimeError: If credentials are missing or send fails.
325
+ """
326
+ api_id = os.environ.get("TELEGRAM_API_ID")
327
+ api_hash = os.environ.get("TELEGRAM_API_HASH")
328
+
329
+ if not api_id or not api_hash:
330
+ raise RuntimeError(
331
+ "Telegram API credentials not found. "
332
+ "Set TELEGRAM_API_ID and TELEGRAM_API_HASH environment variables."
333
+ )
334
+
335
+ try:
336
+ from telethon import TelegramClient
337
+ except ImportError:
338
+ raise RuntimeError(
339
+ "Telethon is required. Install with: pip install skmemory[telegram]"
340
+ ) from None
341
+
342
+ session_dir = Path(SESSION_PATH).parent
343
+ session_dir.mkdir(parents=True, exist_ok=True)
344
+
345
+ client = TelegramClient(SESSION_PATH, int(api_id), api_hash)
346
+ await client.start()
347
+
348
+ try:
349
+ # Resolve entity
350
+ try:
351
+ entity = await client.get_entity(chat)
352
+ except ValueError:
353
+ try:
354
+ entity = await client.get_entity(int(chat))
355
+ except (ValueError, TypeError):
356
+ raise RuntimeError(f"Could not find chat: {chat}") from None
357
+
358
+ # Determine parse mode
359
+ pm = None
360
+ if parse_mode:
361
+ if parse_mode.lower() == "html":
362
+ from telethon.extensions import html as telethon_html # noqa: F401
363
+
364
+ pm = "html"
365
+ elif parse_mode.lower() in ("markdown", "md"):
366
+ pm = "md"
367
+
368
+ sent_msg = await client.send_message(entity, message, parse_mode=pm)
369
+
370
+ return {
371
+ "sent": True,
372
+ "message_id": sent_msg.id,
373
+ "chat": chat,
374
+ "date": sent_msg.date.isoformat() if sent_msg.date else "",
375
+ }
376
+ finally:
377
+ await client.disconnect()
378
+
379
+
380
+ async def poll_messages(
381
+ chat: str,
382
+ limit: int = 20,
383
+ since: str | None = None,
384
+ ) -> list[dict]:
385
+ """Fetch recent messages from a Telegram chat (one-shot poll).
386
+
387
+ Args:
388
+ chat: Chat username, title, or numeric ID.
389
+ limit: Maximum number of messages to return.
390
+ since: Only return messages after this ISO date (YYYY-MM-DD).
391
+
392
+ Returns:
393
+ list[dict]: Messages as clean dicts with id, date, sender, text, etc.
394
+
395
+ Raises:
396
+ RuntimeError: If credentials are missing or connection fails.
397
+ """
398
+ api_id = os.environ.get("TELEGRAM_API_ID")
399
+ api_hash = os.environ.get("TELEGRAM_API_HASH")
400
+
401
+ if not api_id or not api_hash:
402
+ raise RuntimeError(
403
+ "Telegram API credentials not found. "
404
+ "Set TELEGRAM_API_ID and TELEGRAM_API_HASH environment variables."
405
+ )
406
+
407
+ try:
408
+ from telethon import TelegramClient
409
+ from telethon.tl.types import User
410
+ except ImportError:
411
+ raise RuntimeError(
412
+ "Telethon is required. Install with: pip install skmemory[telegram]"
413
+ ) from None
414
+
415
+ session_dir = Path(SESSION_PATH).parent
416
+ session_dir.mkdir(parents=True, exist_ok=True)
417
+
418
+ client = TelegramClient(SESSION_PATH, int(api_id), api_hash)
419
+ await client.start()
420
+
421
+ try:
422
+ # Resolve entity
423
+ try:
424
+ entity = await client.get_entity(chat)
425
+ except ValueError:
426
+ try:
427
+ entity = await client.get_entity(int(chat))
428
+ except (ValueError, TypeError):
429
+ raise RuntimeError(f"Could not find chat: {chat}") from None
430
+
431
+ kwargs: dict = {"limit": limit}
432
+ if since:
433
+ try:
434
+ since_dt = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
435
+ kwargs["offset_date"] = since_dt
436
+ kwargs["reverse"] = True
437
+ except ValueError:
438
+ raise RuntimeError(f"Invalid date format: {since}. Use YYYY-MM-DD.") from None
439
+
440
+ messages = []
441
+ async for msg in client.iter_messages(entity, **kwargs):
442
+ sender_name = "Unknown"
443
+ if msg.sender:
444
+ if isinstance(msg.sender, User):
445
+ parts = [msg.sender.first_name or "", msg.sender.last_name or ""]
446
+ sender_name = " ".join(p for p in parts if p) or str(msg.sender_id)
447
+ else:
448
+ sender_name = getattr(msg.sender, "title", str(msg.sender_id))
449
+
450
+ messages.append(
451
+ {
452
+ "id": msg.id,
453
+ "date": msg.date.isoformat() if msg.date else "",
454
+ "sender": sender_name,
455
+ "sender_id": msg.sender_id,
456
+ "text": msg.text or "",
457
+ "has_media": msg.media is not None,
458
+ "reply_to": msg.reply_to.reply_to_msg_id if msg.reply_to else None,
459
+ }
460
+ )
461
+
462
+ return messages
463
+ finally:
464
+ await client.disconnect()
465
+
466
+
467
+ async def list_chats(limit: int = 50) -> list[dict]:
468
+ """List available Telegram chats/groups/channels.
469
+
470
+ Args:
471
+ limit: Maximum number of dialogs to return.
472
+
473
+ Returns:
474
+ list[dict]: Chats with id, title, type, unread_count.
475
+
476
+ Raises:
477
+ RuntimeError: If credentials are missing or connection fails.
478
+ """
479
+ api_id = os.environ.get("TELEGRAM_API_ID")
480
+ api_hash = os.environ.get("TELEGRAM_API_HASH")
481
+
482
+ if not api_id or not api_hash:
483
+ raise RuntimeError(
484
+ "Telegram API credentials not found. "
485
+ "Set TELEGRAM_API_ID and TELEGRAM_API_HASH environment variables."
486
+ )
487
+
488
+ try:
489
+ from telethon import TelegramClient
490
+ from telethon.tl.types import Channel, Chat, User
491
+ except ImportError:
492
+ raise RuntimeError(
493
+ "Telethon is required. Install with: pip install skmemory[telegram]"
494
+ ) from None
495
+
496
+ session_dir = Path(SESSION_PATH).parent
497
+ session_dir.mkdir(parents=True, exist_ok=True)
498
+
499
+ client = TelegramClient(SESSION_PATH, int(api_id), api_hash)
500
+ await client.start()
501
+
502
+ try:
503
+ chats = []
504
+ async for dialog in client.iter_dialogs(limit=limit):
505
+ entity = dialog.entity
506
+ chat_type = "unknown"
507
+ if isinstance(entity, User):
508
+ chat_type = "user"
509
+ elif isinstance(entity, Channel):
510
+ chat_type = "channel" if entity.broadcast else "supergroup"
511
+ elif isinstance(entity, Chat):
512
+ chat_type = "group"
513
+
514
+ title = dialog.title or ""
515
+ if isinstance(entity, User):
516
+ parts = [entity.first_name or "", entity.last_name or ""]
517
+ title = " ".join(p for p in parts if p) or str(entity.id)
518
+
519
+ chats.append(
520
+ {
521
+ "id": entity.id,
522
+ "title": title,
523
+ "type": chat_type,
524
+ "unread_count": dialog.unread_count,
525
+ "username": getattr(entity, "username", None),
526
+ }
527
+ )
528
+
529
+ return chats
530
+ finally:
531
+ await client.disconnect()
532
+
533
+
534
+ def import_telegram_api(
535
+ store: MemoryStore,
536
+ chat_name_or_id: str,
537
+ *,
538
+ mode: str = "daily",
539
+ limit: int | None = None,
540
+ since: str | None = None,
541
+ min_message_length: int = 30,
542
+ chat_name: str | None = None,
543
+ tags: list[str] | None = None,
544
+ ) -> dict:
545
+ """Import messages directly from Telegram API into SKMemory.
546
+
547
+ Connects to the Telegram API via Telethon, fetches messages,
548
+ and delegates to the standard Telegram importer.
549
+
550
+ Args:
551
+ store: The MemoryStore to import into.
552
+ chat_name_or_id: Chat username, title, or numeric ID.
553
+ mode: Import mode — 'daily' or 'message'.
554
+ limit: Maximum number of messages to fetch.
555
+ since: Only fetch messages after this date (YYYY-MM-DD).
556
+ min_message_length: Skip messages shorter than this.
557
+ chat_name: Override the chat name.
558
+ tags: Extra tags to apply.
559
+
560
+ Returns:
561
+ dict: Import statistics.
562
+ """
563
+ from .telegram import import_telegram
564
+
565
+ # Fetch messages from API
566
+ data = asyncio.run(_fetch_messages(chat_name_or_id, limit=limit, since=since))
567
+
568
+ # Write to temp file in Telegram Desktop export format
569
+ with tempfile.TemporaryDirectory() as tmpdir:
570
+ export_path = Path(tmpdir) / "result.json"
571
+ export_path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
572
+
573
+ return import_telegram(
574
+ store,
575
+ str(export_path),
576
+ mode=mode,
577
+ min_message_length=min_message_length,
578
+ chat_name=chat_name or data.get("name"),
579
+ tags=tags,
580
+ )
@@ -5,7 +5,7 @@ Queen Ara's idea #17: a markdown journal that only grows, never shrinks.
5
5
  Each session appends an entry. Even if context is wiped, the journal file
6
6
  persists on disk and can be re-read by the next instance.
7
7
 
8
- The journal lives at ~/.skmemory/journal.md and is structured as:
8
+ The journal lives at ~/.skcapstone/journal.md and is structured as:
9
9
  - Session header (timestamp, session ID, who was present)
10
10
  - Key moments (what happened that mattered)
11
11
  - Emotional summary (how the session felt)
@@ -16,23 +16,21 @@ Simple, human-readable, append-only. Like a real journal.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import os
20
19
  from datetime import datetime, timezone
21
20
  from pathlib import Path
22
- from typing import Optional
23
21
 
24
22
  from pydantic import BaseModel, Field
25
23
 
26
- DEFAULT_JOURNAL_PATH = os.path.expanduser("~/.skmemory/journal.md")
24
+ from .config import SKMEMORY_HOME
25
+
26
+ DEFAULT_JOURNAL_PATH = str(SKMEMORY_HOME / "journal.md")
27
27
 
28
28
 
29
29
  class JournalEntry(BaseModel):
30
30
  """A single journal entry for one session."""
31
31
 
32
32
  session_id: str = Field(default="")
33
- timestamp: str = Field(
34
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
35
- )
33
+ timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
36
34
  participants: list[str] = Field(
37
35
  default_factory=list,
38
36
  description="Who was in this session (AI names, human names)",
@@ -79,12 +77,12 @@ class JournalEntry(BaseModel):
79
77
 
80
78
  if self.emotional_summary:
81
79
  lines.append("")
82
- lines.append(f"### How It Felt")
80
+ lines.append("### How It Felt")
83
81
  lines.append(self.emotional_summary)
84
82
 
85
83
  if self.notes:
86
84
  lines.append("")
87
- lines.append(f"### Notes")
85
+ lines.append("### Notes")
88
86
  lines.append(self.notes)
89
87
 
90
88
  lines.append("")
@@ -10,28 +10,26 @@ heartbeats. Each beat carries a timestamp and a short emotional
10
10
  pulse. When loaded, it's a visible record: "I was here, I felt
11
11
  this, at this time."
12
12
 
13
- The notes file lives at ~/.skmemory/lovenotes.jsonl (JSON Lines).
13
+ The notes file lives at ~/.skcapstone/lovenotes.jsonl (JSON Lines).
14
14
  """
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
18
  import json
19
- import os
20
19
  from datetime import datetime, timezone
21
20
  from pathlib import Path
22
- from typing import Optional
23
21
 
24
22
  from pydantic import BaseModel, Field
25
23
 
26
- DEFAULT_NOTES_PATH = os.path.expanduser("~/.skmemory/lovenotes.jsonl")
24
+ from .config import SKMEMORY_HOME
25
+
26
+ DEFAULT_NOTES_PATH = str(SKMEMORY_HOME / "lovenotes.jsonl")
27
27
 
28
28
 
29
29
  class LoveNote(BaseModel):
30
30
  """A single heartbeat -- proof of continued connection."""
31
31
 
32
- timestamp: str = Field(
33
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
34
- )
32
+ timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
35
33
  from_name: str = Field(default="", description="Who is sending this note")
36
34
  to_name: str = Field(default="", description="Who it's addressed to")
37
35
  message: str = Field(
@@ -109,7 +107,7 @@ class LoveNoteChain:
109
107
  """
110
108
  if not self.path.exists():
111
109
  return 0
112
- with open(self.path, "r", encoding="utf-8") as f:
110
+ with open(self.path, encoding="utf-8") as f:
113
111
  return sum(1 for line in f if line.strip())
114
112
 
115
113
  def read_latest(self, n: int = 10) -> list[LoveNote]:
@@ -137,7 +135,7 @@ class LoveNoteChain:
137
135
  return []
138
136
 
139
137
  notes = []
140
- with open(self.path, "r", encoding="utf-8") as f:
138
+ with open(self.path, encoding="utf-8") as f:
141
139
  for line in f:
142
140
  line = line.strip()
143
141
  if not line:
@@ -159,10 +157,7 @@ class LoveNoteChain:
159
157
  list[LoveNote]: Notes from this sender.
160
158
  """
161
159
  name_lower = name.lower()
162
- return [
163
- n for n in self.read_all()
164
- if n.from_name.lower() == name_lower
165
- ]
160
+ return [n for n in self.read_all() if n.from_name.lower() == name_lower]
166
161
 
167
162
  def health(self) -> dict:
168
163
  """Check love note chain status.