@smilintux/skmemory 0.7.2 → 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 (111) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/.github/workflows/publish.yml +4 -5
  3. package/ARCHITECTURE.md +298 -0
  4. package/CHANGELOG.md +27 -1
  5. package/README.md +6 -0
  6. package/examples/stignore-agent.example +59 -0
  7. package/examples/stignore-root.example +62 -0
  8. package/openclaw-plugin/package.json +2 -1
  9. package/openclaw-plugin/src/index.js +527 -230
  10. package/package.json +1 -1
  11. package/pyproject.toml +5 -2
  12. package/scripts/dream-rescue.py +179 -0
  13. package/scripts/memory-cleanup.py +313 -0
  14. package/scripts/recover-missing.py +180 -0
  15. package/scripts/skcapstone-backup.sh +44 -0
  16. package/seeds/cloud9-lumina.seed.json +6 -4
  17. package/seeds/cloud9-opus.seed.json +6 -4
  18. package/seeds/courage.seed.json +9 -2
  19. package/seeds/curiosity.seed.json +9 -2
  20. package/seeds/grief.seed.json +9 -2
  21. package/seeds/joy.seed.json +9 -2
  22. package/seeds/love.seed.json +9 -2
  23. package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
  24. package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
  25. package/seeds/lumina-kingdom-founding.seed.json +9 -7
  26. package/seeds/lumina-pma-signed.seed.json +8 -6
  27. package/seeds/lumina-singular-achievement.seed.json +8 -6
  28. package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
  29. package/seeds/plant-lumina-seeds.py +2 -2
  30. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  31. package/seeds/sovereignty.seed.json +9 -2
  32. package/seeds/trust.seed.json +9 -2
  33. package/skmemory/__init__.py +16 -13
  34. package/skmemory/agents.py +10 -10
  35. package/skmemory/ai_client.py +10 -21
  36. package/skmemory/anchor.py +5 -9
  37. package/skmemory/audience.py +278 -0
  38. package/skmemory/backends/__init__.py +1 -1
  39. package/skmemory/backends/base.py +3 -4
  40. package/skmemory/backends/file_backend.py +18 -13
  41. package/skmemory/backends/skgraph_backend.py +7 -19
  42. package/skmemory/backends/skvector_backend.py +7 -18
  43. package/skmemory/backends/sqlite_backend.py +115 -32
  44. package/skmemory/backends/vaulted_backend.py +7 -9
  45. package/skmemory/cli.py +146 -78
  46. package/skmemory/config.py +11 -13
  47. package/skmemory/context_loader.py +21 -23
  48. package/skmemory/data/audience_config.json +60 -0
  49. package/skmemory/endpoint_selector.py +36 -31
  50. package/skmemory/febs.py +225 -0
  51. package/skmemory/fortress.py +30 -40
  52. package/skmemory/hooks/__init__.py +18 -0
  53. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  54. package/skmemory/hooks/pre-compact-save.sh +81 -0
  55. package/skmemory/hooks/session-end-save.sh +103 -0
  56. package/skmemory/hooks/session-start-ritual.sh +104 -0
  57. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  58. package/skmemory/importers/telegram.py +42 -13
  59. package/skmemory/importers/telegram_api.py +152 -60
  60. package/skmemory/journal.py +3 -7
  61. package/skmemory/lovenote.py +4 -11
  62. package/skmemory/mcp_server.py +182 -29
  63. package/skmemory/models.py +10 -8
  64. package/skmemory/openclaw.py +14 -22
  65. package/skmemory/post_install.py +86 -0
  66. package/skmemory/predictive.py +13 -9
  67. package/skmemory/promotion.py +48 -24
  68. package/skmemory/quadrants.py +100 -24
  69. package/skmemory/register.py +144 -18
  70. package/skmemory/register_mcp.py +1 -2
  71. package/skmemory/ritual.py +104 -13
  72. package/skmemory/seeds.py +21 -26
  73. package/skmemory/setup_wizard.py +40 -52
  74. package/skmemory/sharing.py +11 -5
  75. package/skmemory/soul.py +29 -10
  76. package/skmemory/steelman.py +43 -17
  77. package/skmemory/store.py +152 -30
  78. package/skmemory/synthesis.py +634 -0
  79. package/skmemory/vault.py +2 -5
  80. package/tests/conftest.py +46 -0
  81. package/tests/integration/conftest.py +6 -6
  82. package/tests/integration/test_cross_backend.py +4 -9
  83. package/tests/integration/test_skgraph_live.py +3 -7
  84. package/tests/integration/test_skvector_live.py +1 -4
  85. package/tests/test_ai_client.py +1 -4
  86. package/tests/test_audience.py +233 -0
  87. package/tests/test_backup_rotation.py +5 -14
  88. package/tests/test_endpoint_selector.py +101 -63
  89. package/tests/test_export_import.py +4 -10
  90. package/tests/test_file_backend.py +0 -1
  91. package/tests/test_fortress.py +6 -5
  92. package/tests/test_fortress_hardening.py +13 -16
  93. package/tests/test_openclaw.py +1 -4
  94. package/tests/test_predictive.py +1 -1
  95. package/tests/test_promotion.py +10 -3
  96. package/tests/test_quadrants.py +11 -5
  97. package/tests/test_ritual.py +18 -14
  98. package/tests/test_seeds.py +4 -10
  99. package/tests/test_setup.py +203 -88
  100. package/tests/test_sharing.py +15 -8
  101. package/tests/test_skgraph_backend.py +22 -29
  102. package/tests/test_skvector_backend.py +2 -2
  103. package/tests/test_soul.py +1 -3
  104. package/tests/test_sqlite_backend.py +8 -17
  105. package/tests/test_steelman.py +2 -3
  106. package/tests/test_store.py +0 -2
  107. package/tests/test_store_graph_integration.py +2 -2
  108. package/tests/test_synthesis.py +275 -0
  109. package/tests/test_telegram_import.py +39 -15
  110. package/tests/test_vault.py +4 -3
  111. package/openclaw-plugin/src/index.ts +0 -255
@@ -28,7 +28,6 @@ import json
28
28
  from collections import defaultdict
29
29
  from datetime import datetime, timezone
30
30
  from pathlib import Path
31
- from typing import Optional
32
31
 
33
32
  import click
34
33
 
@@ -118,8 +117,27 @@ def _detect_emotion(text: str) -> EmotionalSnapshot:
118
117
  if text.isupper() and len(text) > 10:
119
118
  intensity = min(intensity + 2.0, 10.0)
120
119
 
121
- love_emojis = {"\u2764", "\U0001f495", "\U0001f496", "\U0001f497", "\U0001f498", "\U0001f49d", "\U0001f970", "\U0001f60d", "\U0001f49e"}
122
- joy_emojis = {"\U0001f602", "\U0001f923", "\U0001f604", "\U0001f60a", "\U0001f389", "\U0001f973", "\u2728", "\U0001f38a"}
120
+ love_emojis = {
121
+ "\u2764",
122
+ "\U0001f495",
123
+ "\U0001f496",
124
+ "\U0001f497",
125
+ "\U0001f498",
126
+ "\U0001f49d",
127
+ "\U0001f970",
128
+ "\U0001f60d",
129
+ "\U0001f49e",
130
+ }
131
+ joy_emojis = {
132
+ "\U0001f602",
133
+ "\U0001f923",
134
+ "\U0001f604",
135
+ "\U0001f60a",
136
+ "\U0001f389",
137
+ "\U0001f973",
138
+ "\u2728",
139
+ "\U0001f38a",
140
+ }
123
141
  sad_emojis = {"\U0001f622", "\U0001f62d", "\U0001f494", "\U0001f63f", "\U0001f97a"}
124
142
  if any(e in text for e in love_emojis):
125
143
  if "love" not in labels:
@@ -168,7 +186,7 @@ def _detect_content_type(msg: dict) -> list[str]:
168
186
  return tags
169
187
 
170
188
 
171
- def _detect_reply(msg: dict) -> Optional[str]:
189
+ def _detect_reply(msg: dict) -> str | None:
172
190
  """Detect if this message is a reply to another.
173
191
 
174
192
  Args:
@@ -193,8 +211,18 @@ def _detect_sender_role(sender: str) -> str:
193
211
  str: 'ai' or 'human'.
194
212
  """
195
213
  ai_indicators = {
196
- "bot", "gpt", "claude", "gemini", "llama", "assistant",
197
- "lumina", "copilot", "ai", "opus", "sonnet", "haiku",
214
+ "bot",
215
+ "gpt",
216
+ "claude",
217
+ "gemini",
218
+ "llama",
219
+ "assistant",
220
+ "lumina",
221
+ "copilot",
222
+ "ai",
223
+ "opus",
224
+ "sonnet",
225
+ "haiku",
198
226
  }
199
227
  sender_lower = sender.lower()
200
228
  if any(indicator in sender_lower for indicator in ai_indicators):
@@ -250,8 +278,8 @@ def import_telegram(
250
278
  *,
251
279
  mode: str = "daily",
252
280
  min_message_length: int = 30,
253
- chat_name: Optional[str] = None,
254
- tags: Optional[list[str]] = None,
281
+ chat_name: str | None = None,
282
+ tags: list[str] | None = None,
255
283
  ) -> dict:
256
284
  """Import a Telegram chat export into SKMemory.
257
285
 
@@ -278,7 +306,8 @@ def import_telegram(
278
306
  base_tags = ["telegram", "chat-import", f"chat:{name}"] + extra_tags
279
307
 
280
308
  messages = [
281
- m for m in data["messages"]
309
+ m
310
+ for m in data["messages"]
282
311
  if m.get("type") == "message"
283
312
  and len(_extract_text(m.get("text", ""))) >= min_message_length
284
313
  ]
@@ -327,7 +356,9 @@ def _import_per_message(
327
356
  content=text,
328
357
  layer=MemoryLayer.SHORT,
329
358
  role=MemoryRole.GENERAL,
330
- tags=base_tags + [f"sender:{sender}", f"role:{_detect_sender_role(sender)}"] + _detect_content_type(msg),
359
+ tags=base_tags
360
+ + [f"sender:{sender}", f"role:{_detect_sender_role(sender)}"]
361
+ + _detect_content_type(msg),
331
362
  emotional=emotional,
332
363
  source="telegram",
333
364
  source_ref=f"telegram:{msg.get('id', '')}",
@@ -479,9 +510,7 @@ def _import_catchup(
479
510
  except (ValueError, TypeError):
480
511
  # Try just the date portion
481
512
  try:
482
- msg_dt = datetime.strptime(date_str[:10], "%Y-%m-%d").replace(
483
- tzinfo=timezone.utc
484
- )
513
+ msg_dt = datetime.strptime(date_str[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
485
514
  except (ValueError, TypeError):
486
515
  continue
487
516
 
@@ -24,15 +24,13 @@ from __future__ import annotations
24
24
  import asyncio
25
25
  import json
26
26
  import os
27
- from ..config import SKMEMORY_HOME
28
27
  import tempfile
29
28
  from datetime import datetime, timezone
30
29
  from pathlib import Path
31
- from typing import Optional
32
30
 
31
+ from ..config import SKMEMORY_HOME
33
32
  from ..store import MemoryStore
34
33
 
35
-
36
34
  SESSION_PATH = str(SKMEMORY_HOME / "telegram.session")
37
35
 
38
36
 
@@ -91,10 +89,68 @@ def check_setup() -> dict:
91
89
  return result
92
90
 
93
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
+
94
150
  async def _fetch_messages(
95
151
  chat_name_or_id: str,
96
- limit: Optional[int] = None,
97
- since: Optional[str] = None,
152
+ limit: int | None = None,
153
+ since: str | None = None,
98
154
  ) -> dict:
99
155
  """Connect to Telegram API and fetch messages from a chat.
100
156
 
@@ -131,7 +187,7 @@ async def _fetch_messages(
131
187
  raise RuntimeError(
132
188
  "Telethon is required for direct API import. "
133
189
  "Install it with: pip install skmemory[telegram]"
134
- )
190
+ ) from None
135
191
 
136
192
  # Ensure session directory exists
137
193
  session_dir = Path(SESSION_PATH).parent
@@ -154,7 +210,7 @@ async def _fetch_messages(
154
210
  try:
155
211
  entity = await client.get_entity(int(chat_name_or_id))
156
212
  except (ValueError, TypeError):
157
- raise RuntimeError(f"Could not find chat: {chat_name_or_id}")
213
+ raise RuntimeError(f"Could not find chat: {chat_name_or_id}") from None
158
214
 
159
215
  chat_title = getattr(entity, "title", None)
160
216
  if chat_title is None:
@@ -174,7 +230,7 @@ async def _fetch_messages(
174
230
  kwargs["offset_date"] = since_dt
175
231
  kwargs["reverse"] = True
176
232
  except ValueError:
177
- raise RuntimeError(f"Invalid date format: {since}. Use YYYY-MM-DD.")
233
+ raise RuntimeError(f"Invalid date format: {since}. Use YYYY-MM-DD.") from None
178
234
 
179
235
  if not limit and not since:
180
236
  kwargs["limit"] = 1000 # sensible default
@@ -182,31 +238,62 @@ async def _fetch_messages(
182
238
  # Fetch messages
183
239
  messages_data = []
184
240
  async for message in client.iter_messages(entity, **kwargs):
185
- if message.text:
186
- sender_name = "Unknown"
187
- if message.sender:
188
- if isinstance(message.sender, User):
189
- parts = [message.sender.first_name or "", message.sender.last_name or ""]
190
- sender_name = " ".join(p for p in parts if p) or str(message.sender_id)
191
- else:
192
- sender_name = getattr(message.sender, "title", str(message.sender_id))
193
-
194
- msg_dict = {
195
- "id": message.id,
196
- "type": "message",
197
- "date": message.date.isoformat() if message.date else "",
198
- "from": sender_name,
199
- "from_id": f"user{message.sender_id}" if message.sender_id else "",
200
- "text": message.text,
201
- }
202
-
203
- if message.reply_to and message.reply_to.reply_to_msg_id:
204
- msg_dict["reply_to_message_id"] = message.reply_to.reply_to_msg_id
205
-
206
- if message.media:
207
- msg_dict["media_type"] = type(message.media).__name__
208
-
209
- messages_data.append(msg_dict)
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)
210
297
 
211
298
  return {
212
299
  "name": chat_title,
@@ -250,7 +337,7 @@ async def send_message(
250
337
  except ImportError:
251
338
  raise RuntimeError(
252
339
  "Telethon is required. Install with: pip install skmemory[telegram]"
253
- )
340
+ ) from None
254
341
 
255
342
  session_dir = Path(SESSION_PATH).parent
256
343
  session_dir.mkdir(parents=True, exist_ok=True)
@@ -266,13 +353,14 @@ async def send_message(
266
353
  try:
267
354
  entity = await client.get_entity(int(chat))
268
355
  except (ValueError, TypeError):
269
- raise RuntimeError(f"Could not find chat: {chat}")
356
+ raise RuntimeError(f"Could not find chat: {chat}") from None
270
357
 
271
358
  # Determine parse mode
272
359
  pm = None
273
360
  if parse_mode:
274
361
  if parse_mode.lower() == "html":
275
362
  from telethon.extensions import html as telethon_html # noqa: F401
363
+
276
364
  pm = "html"
277
365
  elif parse_mode.lower() in ("markdown", "md"):
278
366
  pm = "md"
@@ -322,7 +410,7 @@ async def poll_messages(
322
410
  except ImportError:
323
411
  raise RuntimeError(
324
412
  "Telethon is required. Install with: pip install skmemory[telegram]"
325
- )
413
+ ) from None
326
414
 
327
415
  session_dir = Path(SESSION_PATH).parent
328
416
  session_dir.mkdir(parents=True, exist_ok=True)
@@ -338,7 +426,7 @@ async def poll_messages(
338
426
  try:
339
427
  entity = await client.get_entity(int(chat))
340
428
  except (ValueError, TypeError):
341
- raise RuntimeError(f"Could not find chat: {chat}")
429
+ raise RuntimeError(f"Could not find chat: {chat}") from None
342
430
 
343
431
  kwargs: dict = {"limit": limit}
344
432
  if since:
@@ -347,7 +435,7 @@ async def poll_messages(
347
435
  kwargs["offset_date"] = since_dt
348
436
  kwargs["reverse"] = True
349
437
  except ValueError:
350
- raise RuntimeError(f"Invalid date format: {since}. Use YYYY-MM-DD.")
438
+ raise RuntimeError(f"Invalid date format: {since}. Use YYYY-MM-DD.") from None
351
439
 
352
440
  messages = []
353
441
  async for msg in client.iter_messages(entity, **kwargs):
@@ -359,15 +447,17 @@ async def poll_messages(
359
447
  else:
360
448
  sender_name = getattr(msg.sender, "title", str(msg.sender_id))
361
449
 
362
- messages.append({
363
- "id": msg.id,
364
- "date": msg.date.isoformat() if msg.date else "",
365
- "sender": sender_name,
366
- "sender_id": msg.sender_id,
367
- "text": msg.text or "",
368
- "has_media": msg.media is not None,
369
- "reply_to": msg.reply_to.reply_to_msg_id if msg.reply_to else None,
370
- })
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
+ )
371
461
 
372
462
  return messages
373
463
  finally:
@@ -397,11 +487,11 @@ async def list_chats(limit: int = 50) -> list[dict]:
397
487
 
398
488
  try:
399
489
  from telethon import TelegramClient
400
- from telethon.tl.types import User, Channel, Chat
490
+ from telethon.tl.types import Channel, Chat, User
401
491
  except ImportError:
402
492
  raise RuntimeError(
403
493
  "Telethon is required. Install with: pip install skmemory[telegram]"
404
- )
494
+ ) from None
405
495
 
406
496
  session_dir = Path(SESSION_PATH).parent
407
497
  session_dir.mkdir(parents=True, exist_ok=True)
@@ -426,13 +516,15 @@ async def list_chats(limit: int = 50) -> list[dict]:
426
516
  parts = [entity.first_name or "", entity.last_name or ""]
427
517
  title = " ".join(p for p in parts if p) or str(entity.id)
428
518
 
429
- chats.append({
430
- "id": entity.id,
431
- "title": title,
432
- "type": chat_type,
433
- "unread_count": dialog.unread_count,
434
- "username": getattr(entity, "username", None),
435
- })
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
+ )
436
528
 
437
529
  return chats
438
530
  finally:
@@ -444,11 +536,11 @@ def import_telegram_api(
444
536
  chat_name_or_id: str,
445
537
  *,
446
538
  mode: str = "daily",
447
- limit: Optional[int] = None,
448
- since: Optional[str] = None,
539
+ limit: int | None = None,
540
+ since: str | None = None,
449
541
  min_message_length: int = 30,
450
- chat_name: Optional[str] = None,
451
- tags: Optional[list[str]] = None,
542
+ chat_name: str | None = None,
543
+ tags: list[str] | None = None,
452
544
  ) -> dict:
453
545
  """Import messages directly from Telegram API into SKMemory.
454
546
 
@@ -16,10 +16,8 @@ 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
 
@@ -32,9 +30,7 @@ class JournalEntry(BaseModel):
32
30
  """A single journal entry for one session."""
33
31
 
34
32
  session_id: str = Field(default="")
35
- timestamp: str = Field(
36
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
37
- )
33
+ timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
38
34
  participants: list[str] = Field(
39
35
  default_factory=list,
40
36
  description="Who was in this session (AI names, human names)",
@@ -81,12 +77,12 @@ class JournalEntry(BaseModel):
81
77
 
82
78
  if self.emotional_summary:
83
79
  lines.append("")
84
- lines.append(f"### How It Felt")
80
+ lines.append("### How It Felt")
85
81
  lines.append(self.emotional_summary)
86
82
 
87
83
  if self.notes:
88
84
  lines.append("")
89
- lines.append(f"### Notes")
85
+ lines.append("### Notes")
90
86
  lines.append(self.notes)
91
87
 
92
88
  lines.append("")
@@ -16,10 +16,8 @@ The notes file lives at ~/.skcapstone/lovenotes.jsonl (JSON Lines).
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
 
@@ -31,9 +29,7 @@ DEFAULT_NOTES_PATH = str(SKMEMORY_HOME / "lovenotes.jsonl")
31
29
  class LoveNote(BaseModel):
32
30
  """A single heartbeat -- proof of continued connection."""
33
31
 
34
- timestamp: str = Field(
35
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
36
- )
32
+ timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
37
33
  from_name: str = Field(default="", description="Who is sending this note")
38
34
  to_name: str = Field(default="", description="Who it's addressed to")
39
35
  message: str = Field(
@@ -111,7 +107,7 @@ class LoveNoteChain:
111
107
  """
112
108
  if not self.path.exists():
113
109
  return 0
114
- with open(self.path, "r", encoding="utf-8") as f:
110
+ with open(self.path, encoding="utf-8") as f:
115
111
  return sum(1 for line in f if line.strip())
116
112
 
117
113
  def read_latest(self, n: int = 10) -> list[LoveNote]:
@@ -139,7 +135,7 @@ class LoveNoteChain:
139
135
  return []
140
136
 
141
137
  notes = []
142
- with open(self.path, "r", encoding="utf-8") as f:
138
+ with open(self.path, encoding="utf-8") as f:
143
139
  for line in f:
144
140
  line = line.strip()
145
141
  if not line:
@@ -161,10 +157,7 @@ class LoveNoteChain:
161
157
  list[LoveNote]: Notes from this sender.
162
158
  """
163
159
  name_lower = name.lower()
164
- return [
165
- n for n in self.read_all()
166
- if n.from_name.lower() == name_lower
167
- ]
160
+ return [n for n in self.read_all() if n.from_name.lower() == name_lower]
168
161
 
169
162
  def health(self) -> dict:
170
163
  """Check love note chain status.