@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.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- 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
|
+
)
|
package/skmemory/journal.py
CHANGED
|
@@ -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 ~/.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
85
|
+
lines.append("### Notes")
|
|
88
86
|
lines.append(self.notes)
|
|
89
87
|
|
|
90
88
|
lines.append("")
|
package/skmemory/lovenote.py
CHANGED
|
@@ -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 ~/.
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.
|