@smilintux/skmemory 0.5.0 → 0.7.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 (87) hide show
  1. package/.github/workflows/ci.yml +39 -3
  2. package/.github/workflows/publish.yml +13 -6
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +101 -19
  5. package/CHANGELOG.md +153 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +419 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/index.js +6 -5
  12. package/openclaw-plugin/openclaw.plugin.json +10 -0
  13. package/openclaw-plugin/src/index.ts +255 -0
  14. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  15. package/package.json +1 -1
  16. package/pyproject.toml +29 -9
  17. package/requirements.txt +10 -2
  18. package/seeds/cloud9-opus.seed.json +7 -7
  19. package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
  20. package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
  21. package/seeds/lumina-kingdom-founding.seed.json +47 -0
  22. package/seeds/lumina-pma-signed.seed.json +46 -0
  23. package/seeds/lumina-singular-achievement.seed.json +46 -0
  24. package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
  25. package/seeds/plant-kingdom-journal.py +203 -0
  26. package/seeds/plant-lumina-seeds.py +280 -0
  27. package/skill.yaml +46 -0
  28. package/skmemory/HA.md +296 -0
  29. package/skmemory/__init__.py +12 -1
  30. package/skmemory/agents.py +233 -0
  31. package/skmemory/ai_client.py +40 -0
  32. package/skmemory/anchor.py +4 -2
  33. package/skmemory/backends/__init__.py +11 -4
  34. package/skmemory/backends/file_backend.py +2 -1
  35. package/skmemory/backends/skgraph_backend.py +608 -0
  36. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
  37. package/skmemory/backends/sqlite_backend.py +122 -51
  38. package/skmemory/backends/vaulted_backend.py +286 -0
  39. package/skmemory/cli.py +1238 -29
  40. package/skmemory/config.py +173 -0
  41. package/skmemory/context_loader.py +335 -0
  42. package/skmemory/endpoint_selector.py +386 -0
  43. package/skmemory/fortress.py +685 -0
  44. package/skmemory/graph_queries.py +238 -0
  45. package/skmemory/importers/__init__.py +9 -1
  46. package/skmemory/importers/telegram.py +351 -43
  47. package/skmemory/importers/telegram_api.py +488 -0
  48. package/skmemory/journal.py +4 -2
  49. package/skmemory/lovenote.py +4 -2
  50. package/skmemory/mcp_server.py +706 -0
  51. package/skmemory/models.py +41 -0
  52. package/skmemory/openclaw.py +8 -8
  53. package/skmemory/predictive.py +232 -0
  54. package/skmemory/promotion.py +524 -0
  55. package/skmemory/register.py +454 -0
  56. package/skmemory/register_mcp.py +197 -0
  57. package/skmemory/ritual.py +121 -47
  58. package/skmemory/seeds.py +257 -8
  59. package/skmemory/setup_wizard.py +920 -0
  60. package/skmemory/sharing.py +402 -0
  61. package/skmemory/soul.py +71 -20
  62. package/skmemory/steelman.py +250 -263
  63. package/skmemory/store.py +271 -60
  64. package/skmemory/vault.py +228 -0
  65. package/tests/integration/__init__.py +0 -0
  66. package/tests/integration/conftest.py +233 -0
  67. package/tests/integration/test_cross_backend.py +355 -0
  68. package/tests/integration/test_skgraph_live.py +424 -0
  69. package/tests/integration/test_skvector_live.py +369 -0
  70. package/tests/test_backup_rotation.py +327 -0
  71. package/tests/test_cli.py +6 -6
  72. package/tests/test_endpoint_selector.py +801 -0
  73. package/tests/test_fortress.py +255 -0
  74. package/tests/test_fortress_hardening.py +444 -0
  75. package/tests/test_openclaw.py +5 -2
  76. package/tests/test_predictive.py +237 -0
  77. package/tests/test_promotion.py +340 -0
  78. package/tests/test_ritual.py +4 -4
  79. package/tests/test_seeds.py +96 -0
  80. package/tests/test_setup.py +835 -0
  81. package/tests/test_sharing.py +250 -0
  82. package/tests/test_skgraph_backend.py +667 -0
  83. package/tests/test_skvector_backend.py +326 -0
  84. package/tests/test_steelman.py +5 -5
  85. package/tests/test_store_graph_integration.py +245 -0
  86. package/tests/test_vault.py +186 -0
  87. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,524 @@
1
+ """Memory auto-promotion engine -- sweep and promote by access and intensity.
2
+
3
+ Periodically evaluates memories and promotes qualifying ones to
4
+ higher persistence tiers:
5
+ short-term -> mid-term: frequently accessed or emotionally intense
6
+ mid-term -> long-term: deeply important or Cloud 9 related
7
+
8
+ Promotion generates a compressed summary for the new tier while
9
+ keeping the original intact as the detailed version.
10
+
11
+ Usage:
12
+ # One-shot sweep
13
+ engine = PromotionEngine(store)
14
+ result = engine.sweep()
15
+
16
+ # Background scheduler (runs every 6 hours by default)
17
+ scheduler = PromotionScheduler(store)
18
+ scheduler.start()
19
+ ...
20
+ scheduler.stop()
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import threading
27
+ from datetime import datetime, timezone
28
+ from typing import Optional
29
+
30
+ from pydantic import BaseModel, Field
31
+
32
+ from .models import EmotionalSnapshot, Memory, MemoryLayer
33
+ from .store import MemoryStore
34
+
35
+ logger = logging.getLogger("skmemory.promotion")
36
+
37
+
38
+ class PromotionCriteria(BaseModel):
39
+ """Thresholds for memory promotion between tiers.
40
+
41
+ Attributes:
42
+ short_to_mid_intensity: Min emotional intensity for short->mid.
43
+ short_to_mid_age_hours: Min age in hours for short->mid.
44
+ short_to_mid_access_count: Min access count for short->mid.
45
+ mid_to_long_intensity: Min emotional intensity for mid->long.
46
+ mid_to_long_age_hours: Min age in hours for mid->long.
47
+ mid_to_long_tags: Tags that auto-qualify for long-term.
48
+ cloud9_auto_promote: Auto-promote Cloud 9 memories to long-term.
49
+ max_promotions_per_sweep: Cap on promotions per sweep.
50
+ """
51
+
52
+ short_to_mid_intensity: float = Field(default=5.0, ge=0.0, le=10.0)
53
+ short_to_mid_age_hours: float = Field(default=24.0, ge=0.0)
54
+ short_to_mid_access_count: int = Field(default=3, ge=0)
55
+ mid_to_long_intensity: float = Field(default=7.0, ge=0.0, le=10.0)
56
+ mid_to_long_age_hours: float = Field(default=168.0, ge=0.0)
57
+ mid_to_long_tags: list[str] = Field(
58
+ default_factory=lambda: ["cloud9:achieved", "milestone", "breakthrough"]
59
+ )
60
+ cloud9_auto_promote: bool = True
61
+ max_promotions_per_sweep: int = Field(default=50, ge=1)
62
+
63
+
64
+ class PromotionResult(BaseModel):
65
+ """Summary of a promotion sweep.
66
+
67
+ Attributes:
68
+ timestamp: When the sweep was performed.
69
+ short_evaluated: Number of short-term memories evaluated.
70
+ mid_evaluated: Number of mid-term memories evaluated.
71
+ short_to_mid: Number promoted from short to mid.
72
+ mid_to_long: Number promoted from mid to long.
73
+ skipped: Number that didn't meet criteria.
74
+ errors: Number of promotion failures.
75
+ promoted_ids: IDs of newly created promoted memories.
76
+ """
77
+
78
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
79
+ short_evaluated: int = 0
80
+ mid_evaluated: int = 0
81
+ short_to_mid: int = 0
82
+ mid_to_long: int = 0
83
+ skipped: int = 0
84
+ errors: int = 0
85
+ promoted_ids: list[str] = Field(default_factory=list)
86
+
87
+ @property
88
+ def total_promoted(self) -> int:
89
+ """Total number of memories promoted."""
90
+ return self.short_to_mid + self.mid_to_long
91
+
92
+ def summary(self) -> str:
93
+ """Human-readable summary.
94
+
95
+ Returns:
96
+ str: Formatted summary string.
97
+ """
98
+ return (
99
+ f"Promotion sweep: {self.total_promoted} promoted "
100
+ f"(S->M: {self.short_to_mid}, M->L: {self.mid_to_long}), "
101
+ f"{self.skipped} skipped, {self.errors} errors"
102
+ )
103
+
104
+
105
+ class PromotionEngine:
106
+ """Evaluates and promotes memories across tiers.
107
+
108
+ Scans memories in each tier, applies promotion criteria,
109
+ and creates promoted copies at the higher tier with
110
+ generated summaries.
111
+
112
+ Args:
113
+ store: SKMemory MemoryStore instance.
114
+ criteria: Promotion thresholds (uses defaults if not provided).
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ store: MemoryStore,
120
+ criteria: Optional[PromotionCriteria] = None,
121
+ ) -> None:
122
+ self._store = store
123
+ self._criteria = criteria or PromotionCriteria()
124
+
125
+ def sweep(self) -> PromotionResult:
126
+ """Run a full promotion sweep across all tiers.
127
+
128
+ Evaluates short-term memories for mid-term promotion,
129
+ then mid-term for long-term promotion.
130
+
131
+ Returns:
132
+ PromotionResult: Summary of what was promoted.
133
+ """
134
+ result = PromotionResult()
135
+
136
+ self._sweep_tier(
137
+ source_layer=MemoryLayer.SHORT,
138
+ target_layer=MemoryLayer.MID,
139
+ result=result,
140
+ )
141
+
142
+ self._sweep_tier(
143
+ source_layer=MemoryLayer.MID,
144
+ target_layer=MemoryLayer.LONG,
145
+ result=result,
146
+ )
147
+
148
+ logger.info(result.summary())
149
+ return result
150
+
151
+ def evaluate(self, memory: Memory) -> Optional[MemoryLayer]:
152
+ """Evaluate whether a memory qualifies for promotion.
153
+
154
+ Args:
155
+ memory: The memory to evaluate.
156
+
157
+ Returns:
158
+ Optional[MemoryLayer]: Target tier if it qualifies, None otherwise.
159
+ """
160
+ if memory.layer == MemoryLayer.SHORT:
161
+ if self._qualifies_short_to_mid(memory):
162
+ return MemoryLayer.MID
163
+ elif memory.layer == MemoryLayer.MID:
164
+ if self._qualifies_mid_to_long(memory):
165
+ return MemoryLayer.LONG
166
+ return None
167
+
168
+ def promote_memory(
169
+ self,
170
+ memory: Memory,
171
+ target: MemoryLayer,
172
+ ) -> Optional[Memory]:
173
+ """Promote a single memory to a higher tier.
174
+
175
+ Creates a promoted copy with a generated summary.
176
+ The original stays in place as the detailed version.
177
+
178
+ Args:
179
+ memory: The memory to promote.
180
+ target: Target tier.
181
+
182
+ Returns:
183
+ Optional[Memory]: The promoted memory, or None on failure.
184
+ """
185
+ summary = self._generate_summary(memory)
186
+ promoted = self._store.promote(memory.id, target, summary=summary)
187
+
188
+ if promoted:
189
+ now_iso = datetime.now(timezone.utc).isoformat()
190
+ promoted.tags = list(set(promoted.tags + ["auto-promoted"]))
191
+ promoted.metadata["promoted_from"] = memory.layer.value
192
+ promoted.metadata["promoted_at"] = now_iso
193
+ promoted.metadata["promotion_reason"] = self._promotion_reason(memory)
194
+ self._store.primary.save(promoted)
195
+
196
+ # Mark the source so it won't be re-promoted on the next sweep
197
+ memory.tags = list(set(memory.tags + ["promoted"]))
198
+ memory.metadata["promoted_to"] = target.value
199
+ memory.metadata["promoted_at"] = now_iso
200
+ memory.metadata["promoted_id"] = promoted.id
201
+ self._store.primary.save(memory)
202
+
203
+ return promoted
204
+
205
+ def _sweep_tier(
206
+ self,
207
+ source_layer: MemoryLayer,
208
+ target_layer: MemoryLayer,
209
+ result: PromotionResult,
210
+ ) -> None:
211
+ """Sweep a single tier for qualifying memories.
212
+
213
+ Args:
214
+ source_layer: Tier to scan.
215
+ target_layer: Tier to promote into.
216
+ result: PromotionResult to update in place.
217
+ """
218
+ memories = self._store.list_memories(
219
+ layer=source_layer,
220
+ limit=self._criteria.max_promotions_per_sweep * 2,
221
+ )
222
+
223
+ if source_layer == MemoryLayer.SHORT:
224
+ result.short_evaluated = len(memories)
225
+ else:
226
+ result.mid_evaluated = len(memories)
227
+
228
+ promoted_count = 0
229
+ for memory in memories:
230
+ if promoted_count >= self._criteria.max_promotions_per_sweep:
231
+ break
232
+
233
+ target = self.evaluate(memory)
234
+ if target != target_layer:
235
+ result.skipped += 1
236
+ continue
237
+
238
+ try:
239
+ promoted = self.promote_memory(memory, target_layer)
240
+ if promoted:
241
+ result.promoted_ids.append(promoted.id)
242
+ promoted_count += 1
243
+ if target_layer == MemoryLayer.MID:
244
+ result.short_to_mid += 1
245
+ else:
246
+ result.mid_to_long += 1
247
+ else:
248
+ result.errors += 1
249
+ except Exception as exc:
250
+ logger.warning("Promotion failed for %s: %s", memory.id[:8], exc)
251
+ result.errors += 1
252
+
253
+ def _qualifies_short_to_mid(self, memory: Memory) -> bool:
254
+ """Check if a short-term memory qualifies for mid-term.
255
+
256
+ Args:
257
+ memory: The memory to check.
258
+
259
+ Returns:
260
+ bool: True if it meets any promotion criterion.
261
+ """
262
+ # Skip already-promoted memories to prevent duplicate promotions
263
+ if memory.metadata.get("promoted_to"):
264
+ return False
265
+
266
+ c = self._criteria
267
+
268
+ if memory.emotional.intensity >= c.short_to_mid_intensity:
269
+ return True
270
+
271
+ if memory.emotional.cloud9_achieved and c.cloud9_auto_promote:
272
+ return True
273
+
274
+ age_hours = self._age_hours(memory)
275
+ if age_hours >= c.short_to_mid_age_hours:
276
+ access = memory.metadata.get("access_count", 0)
277
+ if access >= c.short_to_mid_access_count:
278
+ return True
279
+
280
+ return False
281
+
282
+ def _qualifies_mid_to_long(self, memory: Memory) -> bool:
283
+ """Check if a mid-term memory qualifies for long-term.
284
+
285
+ Args:
286
+ memory: The memory to check.
287
+
288
+ Returns:
289
+ bool: True if it meets any promotion criterion.
290
+ """
291
+ # Skip already-promoted memories to prevent duplicate promotions
292
+ if memory.metadata.get("promoted_to"):
293
+ return False
294
+
295
+ c = self._criteria
296
+
297
+ if memory.emotional.intensity >= c.mid_to_long_intensity:
298
+ return True
299
+
300
+ if any(tag in memory.tags for tag in c.mid_to_long_tags):
301
+ return True
302
+
303
+ if memory.emotional.cloud9_achieved and c.cloud9_auto_promote:
304
+ return True
305
+
306
+ return False
307
+
308
+ @staticmethod
309
+ def _age_hours(memory: Memory) -> float:
310
+ """Compute the age of a memory in hours.
311
+
312
+ Args:
313
+ memory: The memory to check.
314
+
315
+ Returns:
316
+ float: Age in hours.
317
+ """
318
+ try:
319
+ created = datetime.fromisoformat(memory.created_at)
320
+ delta = datetime.now(timezone.utc) - created
321
+ return delta.total_seconds() / 3600
322
+ except (ValueError, TypeError):
323
+ return 0.0
324
+
325
+ @staticmethod
326
+ def _generate_summary(memory: Memory) -> str:
327
+ """Generate a compressed summary for the promoted memory.
328
+
329
+ Args:
330
+ memory: The memory to summarize.
331
+
332
+ Returns:
333
+ str: Condensed summary text.
334
+ """
335
+ if memory.summary:
336
+ return memory.summary
337
+
338
+ content_preview = memory.content[:200]
339
+ if len(memory.content) > 200:
340
+ content_preview += "..."
341
+
342
+ emotional_sig = memory.emotional.signature()
343
+ tags_str = ", ".join(memory.tags[:5]) if memory.tags else "untagged"
344
+
345
+ return (
346
+ f"{memory.title}: {content_preview} "
347
+ f"[{emotional_sig}] [{tags_str}]"
348
+ )
349
+
350
+ @staticmethod
351
+ def _promotion_reason(memory: Memory) -> str:
352
+ """Generate a human-readable reason for the promotion.
353
+
354
+ Args:
355
+ memory: The promoted memory.
356
+
357
+ Returns:
358
+ str: Reason string.
359
+ """
360
+ reasons = []
361
+ if memory.emotional.intensity >= 7.0:
362
+ reasons.append(f"high intensity ({memory.emotional.intensity:.1f})")
363
+ if memory.emotional.cloud9_achieved:
364
+ reasons.append("Cloud 9 achieved")
365
+ if memory.metadata.get("access_count", 0) >= 3:
366
+ reasons.append(f"frequently accessed ({memory.metadata.get('access_count')}x)")
367
+
368
+ qualifying_tags = ["cloud9:achieved", "milestone", "breakthrough"]
369
+ matching = [t for t in memory.tags if t in qualifying_tags]
370
+ if matching:
371
+ reasons.append(f"tagged: {', '.join(matching)}")
372
+
373
+ return "; ".join(reasons) if reasons else "criteria met"
374
+
375
+
376
+ class PromotionScheduler:
377
+ """Runs promotion sweeps on a background daemon thread at a fixed interval.
378
+
379
+ Designed for long-running processes (daemons, MCP servers) that want
380
+ automatic memory consolidation without manual intervention.
381
+
382
+ The scheduler runs at the configured interval *after* each sweep
383
+ completes, so a slow sweep doesn't cause overlapping runs.
384
+
385
+ Args:
386
+ store: The MemoryStore to sweep.
387
+ criteria: Promotion thresholds (uses defaults if not provided).
388
+ interval_seconds: How often to run a sweep (default: 6 hours).
389
+
390
+ Example::
391
+
392
+ scheduler = PromotionScheduler(store, interval_seconds=3600)
393
+ scheduler.start()
394
+ # ... runs in the background ...
395
+ scheduler.stop()
396
+ """
397
+
398
+ DEFAULT_INTERVAL_SECONDS: float = 6.0 * 3600 # 6 hours
399
+
400
+ def __init__(
401
+ self,
402
+ store: MemoryStore,
403
+ criteria: Optional[PromotionCriteria] = None,
404
+ interval_seconds: float = DEFAULT_INTERVAL_SECONDS,
405
+ ) -> None:
406
+ self._engine = PromotionEngine(store, criteria)
407
+ self._interval = interval_seconds
408
+ self._thread: Optional[threading.Thread] = None
409
+ self._stop_event = threading.Event()
410
+ self._last_result: Optional[PromotionResult] = None
411
+ self._sweep_count: int = 0
412
+
413
+ # ── public API ──────────────────────────────────────────────────────────
414
+
415
+ def start(self) -> None:
416
+ """Start the background sweep thread.
417
+
418
+ No-op if already running.
419
+ """
420
+ if self._thread and self._thread.is_alive():
421
+ logger.debug("Promotion scheduler already running.")
422
+ return
423
+ self._stop_event.clear()
424
+ self._thread = threading.Thread(
425
+ target=self._run,
426
+ name="skmemory-promotion",
427
+ daemon=True,
428
+ )
429
+ self._thread.start()
430
+ logger.info(
431
+ "Promotion scheduler started (interval: %.1fh).",
432
+ self._interval / 3600,
433
+ )
434
+
435
+ def stop(self, timeout: float = 5.0) -> None:
436
+ """Stop the background sweep thread.
437
+
438
+ Signals the thread to exit and waits up to *timeout* seconds.
439
+
440
+ Args:
441
+ timeout: Maximum seconds to wait for graceful shutdown.
442
+ """
443
+ self._stop_event.set()
444
+ if self._thread:
445
+ self._thread.join(timeout=timeout)
446
+ logger.info("Promotion scheduler stopped.")
447
+
448
+ def run_once(self) -> PromotionResult:
449
+ """Run a single sweep immediately (synchronous, on the calling thread).
450
+
451
+ Returns:
452
+ PromotionResult: The sweep result.
453
+ """
454
+ result = self._engine.sweep()
455
+ self._last_result = result
456
+ self._sweep_count += 1
457
+ return result
458
+
459
+ def is_running(self) -> bool:
460
+ """Return True if the background thread is alive."""
461
+ return self._thread is not None and self._thread.is_alive()
462
+
463
+ @property
464
+ def last_result(self) -> Optional[PromotionResult]:
465
+ """The result from the most recent completed sweep, or None."""
466
+ return self._last_result
467
+
468
+ @property
469
+ def sweep_count(self) -> int:
470
+ """Total number of sweeps completed since this scheduler was created."""
471
+ return self._sweep_count
472
+
473
+ @property
474
+ def interval_hours(self) -> float:
475
+ """The configured sweep interval in hours."""
476
+ return self._interval / 3600
477
+
478
+ def status(self) -> dict:
479
+ """Return a dict summary suitable for health checks or CLI display.
480
+
481
+ Returns:
482
+ dict: Keys: running, sweep_count, interval_hours, last_sweep,
483
+ last_promoted, last_skipped, last_errors.
484
+ """
485
+ lr = self._last_result
486
+ return {
487
+ "running": self.is_running(),
488
+ "sweep_count": self._sweep_count,
489
+ "interval_hours": self.interval_hours,
490
+ "last_sweep": lr.timestamp.isoformat() if lr else None,
491
+ "last_promoted": lr.total_promoted if lr else None,
492
+ "last_skipped": lr.skipped if lr else None,
493
+ "last_errors": lr.errors if lr else None,
494
+ }
495
+
496
+ # ── internal ─────────────────────────────────────────────────────────
497
+
498
+ def _run(self) -> None:
499
+ """Background thread: sweep, wait interval, repeat until stopped."""
500
+ while not self._stop_event.is_set():
501
+ try:
502
+ result = self._engine.sweep()
503
+ self._last_result = result
504
+ self._sweep_count += 1
505
+ if result.total_promoted > 0:
506
+ logger.info(
507
+ "Promotion sweep #%d: %s",
508
+ self._sweep_count,
509
+ result.summary(),
510
+ )
511
+ else:
512
+ logger.debug(
513
+ "Promotion sweep #%d: nothing to promote.",
514
+ self._sweep_count,
515
+ )
516
+ except Exception as exc:
517
+ logger.error(
518
+ "Promotion sweep failed: %s",
519
+ exc,
520
+ exc_info=True,
521
+ )
522
+
523
+ # Wait for the interval or until stop() is called
524
+ self._stop_event.wait(timeout=self._interval)