@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,548 @@
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
+
29
+ from pydantic import BaseModel, Field
30
+
31
+ from .models import Memory, MemoryLayer
32
+ from .store import MemoryStore
33
+
34
+ logger = logging.getLogger("skmemory.promotion")
35
+
36
+
37
+ class PromotionCriteria(BaseModel):
38
+ """Thresholds for memory promotion between tiers.
39
+
40
+ Attributes:
41
+ short_to_mid_intensity: Min emotional intensity for short->mid.
42
+ short_to_mid_age_hours: Min age in hours for short->mid.
43
+ short_to_mid_access_count: Min access count for short->mid.
44
+ mid_to_long_intensity: Min emotional intensity for mid->long.
45
+ mid_to_long_age_hours: Min age in hours for mid->long.
46
+ mid_to_long_tags: Tags that auto-qualify for long-term.
47
+ cloud9_auto_promote: Auto-promote Cloud 9 memories to long-term.
48
+ max_promotions_per_sweep: Cap on promotions per sweep.
49
+ source_auto_promote: Sources that auto-promote after age threshold
50
+ regardless of access count (e.g. dreaming-engine writes once).
51
+ source_auto_promote_age_hours: Hours before source auto-promotion.
52
+ protected_tags: Tags that protect memories from TTL-based archival.
53
+ """
54
+
55
+ short_to_mid_intensity: float = Field(default=5.0, ge=0.0, le=10.0)
56
+ short_to_mid_age_hours: float = Field(default=24.0, ge=0.0)
57
+ short_to_mid_access_count: int = Field(default=3, ge=0)
58
+ mid_to_long_intensity: float = Field(default=7.0, ge=0.0, le=10.0)
59
+ mid_to_long_age_hours: float = Field(default=168.0, ge=0.0)
60
+ mid_to_long_tags: list[str] = Field(
61
+ default_factory=lambda: ["cloud9:achieved", "milestone", "breakthrough"]
62
+ )
63
+ cloud9_auto_promote: bool = True
64
+ max_promotions_per_sweep: int = Field(default=50, ge=1)
65
+
66
+ source_auto_promote: list[str] = Field(
67
+ default_factory=lambda: ["dreaming-engine", "journal-synthesis"],
68
+ description="Sources that auto-promote after age threshold regardless of access count.",
69
+ )
70
+ source_auto_promote_age_hours: float = Field(
71
+ default=12.0,
72
+ ge=0.0,
73
+ description="Hours before source-based auto-promotion triggers.",
74
+ )
75
+ protected_tags: list[str] = Field(
76
+ default_factory=lambda: [
77
+ "narrative",
78
+ "journal-synthesis",
79
+ "milestone",
80
+ "breakthrough",
81
+ "cloud9:achieved",
82
+ ],
83
+ description="Tags that protect memories from TTL-based archival.",
84
+ )
85
+
86
+
87
+ class PromotionResult(BaseModel):
88
+ """Summary of a promotion sweep.
89
+
90
+ Attributes:
91
+ timestamp: When the sweep was performed.
92
+ short_evaluated: Number of short-term memories evaluated.
93
+ mid_evaluated: Number of mid-term memories evaluated.
94
+ short_to_mid: Number promoted from short to mid.
95
+ mid_to_long: Number promoted from mid to long.
96
+ skipped: Number that didn't meet criteria.
97
+ errors: Number of promotion failures.
98
+ promoted_ids: IDs of newly created promoted memories.
99
+ """
100
+
101
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
102
+ short_evaluated: int = 0
103
+ mid_evaluated: int = 0
104
+ short_to_mid: int = 0
105
+ mid_to_long: int = 0
106
+ skipped: int = 0
107
+ errors: int = 0
108
+ promoted_ids: list[str] = Field(default_factory=list)
109
+
110
+ @property
111
+ def total_promoted(self) -> int:
112
+ """Total number of memories promoted."""
113
+ return self.short_to_mid + self.mid_to_long
114
+
115
+ def summary(self) -> str:
116
+ """Human-readable summary.
117
+
118
+ Returns:
119
+ str: Formatted summary string.
120
+ """
121
+ return (
122
+ f"Promotion sweep: {self.total_promoted} promoted "
123
+ f"(S->M: {self.short_to_mid}, M->L: {self.mid_to_long}), "
124
+ f"{self.skipped} skipped, {self.errors} errors"
125
+ )
126
+
127
+
128
+ class PromotionEngine:
129
+ """Evaluates and promotes memories across tiers.
130
+
131
+ Scans memories in each tier, applies promotion criteria,
132
+ and creates promoted copies at the higher tier with
133
+ generated summaries.
134
+
135
+ Args:
136
+ store: SKMemory MemoryStore instance.
137
+ criteria: Promotion thresholds (uses defaults if not provided).
138
+ """
139
+
140
+ def __init__(
141
+ self,
142
+ store: MemoryStore,
143
+ criteria: PromotionCriteria | None = None,
144
+ ) -> None:
145
+ self._store = store
146
+ self._criteria = criteria or PromotionCriteria()
147
+
148
+ def sweep(self) -> PromotionResult:
149
+ """Run a full promotion sweep across all tiers.
150
+
151
+ Evaluates short-term memories for mid-term promotion,
152
+ then mid-term for long-term promotion.
153
+
154
+ Returns:
155
+ PromotionResult: Summary of what was promoted.
156
+ """
157
+ result = PromotionResult()
158
+
159
+ self._sweep_tier(
160
+ source_layer=MemoryLayer.SHORT,
161
+ target_layer=MemoryLayer.MID,
162
+ result=result,
163
+ )
164
+
165
+ self._sweep_tier(
166
+ source_layer=MemoryLayer.MID,
167
+ target_layer=MemoryLayer.LONG,
168
+ result=result,
169
+ )
170
+
171
+ logger.info(result.summary())
172
+ return result
173
+
174
+ def evaluate(self, memory: Memory) -> MemoryLayer | None:
175
+ """Evaluate whether a memory qualifies for promotion.
176
+
177
+ Args:
178
+ memory: The memory to evaluate.
179
+
180
+ Returns:
181
+ Optional[MemoryLayer]: Target tier if it qualifies, None otherwise.
182
+ """
183
+ if memory.layer == MemoryLayer.SHORT and self._qualifies_short_to_mid(memory):
184
+ return MemoryLayer.MID
185
+ elif memory.layer == MemoryLayer.MID and self._qualifies_mid_to_long(memory):
186
+ return MemoryLayer.LONG
187
+ return None
188
+
189
+ def promote_memory(
190
+ self,
191
+ memory: Memory,
192
+ target: MemoryLayer,
193
+ ) -> Memory | None:
194
+ """Promote a single memory to a higher tier.
195
+
196
+ Creates a promoted copy with a generated summary.
197
+ The original stays in place as the detailed version.
198
+
199
+ Args:
200
+ memory: The memory to promote.
201
+ target: Target tier.
202
+
203
+ Returns:
204
+ Optional[Memory]: The promoted memory, or None on failure.
205
+ """
206
+ summary = self._generate_summary(memory)
207
+ promoted = self._store.promote(memory.id, target, summary=summary)
208
+
209
+ if promoted:
210
+ now_iso = datetime.now(timezone.utc).isoformat()
211
+ promoted.tags = list(set(promoted.tags + ["auto-promoted"]))
212
+ promoted.metadata["promoted_from"] = memory.layer.value
213
+ promoted.metadata["promoted_at"] = now_iso
214
+ promoted.metadata["promotion_reason"] = self._promotion_reason(memory)
215
+ self._store.primary.save(promoted)
216
+
217
+ # Mark the source so it won't be re-promoted on the next sweep
218
+ memory.tags = list(set(memory.tags + ["promoted"]))
219
+ memory.metadata["promoted_to"] = target.value
220
+ memory.metadata["promoted_at"] = now_iso
221
+ memory.metadata["promoted_id"] = promoted.id
222
+ self._store.primary.save(memory)
223
+
224
+ return promoted
225
+
226
+ def _sweep_tier(
227
+ self,
228
+ source_layer: MemoryLayer,
229
+ target_layer: MemoryLayer,
230
+ result: PromotionResult,
231
+ ) -> None:
232
+ """Sweep a single tier for qualifying memories.
233
+
234
+ Args:
235
+ source_layer: Tier to scan.
236
+ target_layer: Tier to promote into.
237
+ result: PromotionResult to update in place.
238
+ """
239
+ memories = self._store.list_memories(
240
+ layer=source_layer,
241
+ limit=self._criteria.max_promotions_per_sweep * 2,
242
+ )
243
+
244
+ if source_layer == MemoryLayer.SHORT:
245
+ result.short_evaluated = len(memories)
246
+ else:
247
+ result.mid_evaluated = len(memories)
248
+
249
+ promoted_count = 0
250
+ for memory in memories:
251
+ if promoted_count >= self._criteria.max_promotions_per_sweep:
252
+ break
253
+
254
+ target = self.evaluate(memory)
255
+ if target != target_layer:
256
+ result.skipped += 1
257
+ continue
258
+
259
+ try:
260
+ promoted = self.promote_memory(memory, target_layer)
261
+ if promoted:
262
+ result.promoted_ids.append(promoted.id)
263
+ promoted_count += 1
264
+ if target_layer == MemoryLayer.MID:
265
+ result.short_to_mid += 1
266
+ else:
267
+ result.mid_to_long += 1
268
+ else:
269
+ result.errors += 1
270
+ except Exception as exc:
271
+ logger.warning("Promotion failed for %s: %s", memory.id[:8], exc)
272
+ result.errors += 1
273
+
274
+ def _qualifies_short_to_mid(self, memory: Memory) -> bool:
275
+ """Check if a short-term memory qualifies for mid-term.
276
+
277
+ Args:
278
+ memory: The memory to check.
279
+
280
+ Returns:
281
+ bool: True if it meets any promotion criterion.
282
+ """
283
+ # Skip already-promoted memories to prevent duplicate promotions
284
+ if memory.metadata.get("promoted_to"):
285
+ return False
286
+
287
+ c = self._criteria
288
+
289
+ if memory.emotional.intensity >= c.short_to_mid_intensity:
290
+ return True
291
+
292
+ if memory.emotional.cloud9_achieved and c.cloud9_auto_promote:
293
+ return True
294
+
295
+ age_hours = self._age_hours(memory)
296
+ if age_hours >= c.short_to_mid_age_hours:
297
+ access = memory.metadata.get("access_count", 0)
298
+ if access >= c.short_to_mid_access_count:
299
+ return True
300
+
301
+ # Source-based auto-promotion (e.g. dreams, journal synthesis)
302
+ # These sources write once and are never re-accessed, so access_count
303
+ # stays at 0. Promote based on age alone.
304
+ return (
305
+ memory.source in c.source_auto_promote and age_hours >= c.source_auto_promote_age_hours
306
+ )
307
+
308
+ def _qualifies_mid_to_long(self, memory: Memory) -> bool:
309
+ """Check if a mid-term memory qualifies for long-term.
310
+
311
+ Args:
312
+ memory: The memory to check.
313
+
314
+ Returns:
315
+ bool: True if it meets any promotion criterion.
316
+ """
317
+ # Skip already-promoted memories to prevent duplicate promotions
318
+ if memory.metadata.get("promoted_to"):
319
+ return False
320
+
321
+ c = self._criteria
322
+
323
+ if memory.emotional.intensity >= c.mid_to_long_intensity:
324
+ return True
325
+
326
+ if any(tag in memory.tags for tag in c.mid_to_long_tags):
327
+ return True
328
+
329
+ return bool(memory.emotional.cloud9_achieved and c.cloud9_auto_promote)
330
+
331
+ @staticmethod
332
+ def _age_hours(memory: Memory) -> float:
333
+ """Compute the age of a memory in hours.
334
+
335
+ Args:
336
+ memory: The memory to check.
337
+
338
+ Returns:
339
+ float: Age in hours.
340
+ """
341
+ try:
342
+ created = datetime.fromisoformat(memory.created_at)
343
+ delta = datetime.now(timezone.utc) - created
344
+ return delta.total_seconds() / 3600
345
+ except (ValueError, TypeError):
346
+ return 0.0
347
+
348
+ @staticmethod
349
+ def _generate_summary(memory: Memory) -> str:
350
+ """Generate a compressed summary for the promoted memory.
351
+
352
+ Args:
353
+ memory: The memory to summarize.
354
+
355
+ Returns:
356
+ str: Condensed summary text.
357
+ """
358
+ if memory.summary:
359
+ return memory.summary
360
+
361
+ content_preview = memory.content[:200]
362
+ if len(memory.content) > 200:
363
+ content_preview += "..."
364
+
365
+ emotional_sig = memory.emotional.signature()
366
+ tags_str = ", ".join(memory.tags[:5]) if memory.tags else "untagged"
367
+
368
+ return f"{memory.title}: {content_preview} [{emotional_sig}] [{tags_str}]"
369
+
370
+ @staticmethod
371
+ def _promotion_reason(memory: Memory) -> str:
372
+ """Generate a human-readable reason for the promotion.
373
+
374
+ Args:
375
+ memory: The promoted memory.
376
+
377
+ Returns:
378
+ str: Reason string.
379
+ """
380
+ reasons = []
381
+ if memory.emotional.intensity >= 7.0:
382
+ reasons.append(f"high intensity ({memory.emotional.intensity:.1f})")
383
+ if memory.emotional.cloud9_achieved:
384
+ reasons.append("Cloud 9 achieved")
385
+ if memory.metadata.get("access_count", 0) >= 3:
386
+ reasons.append(f"frequently accessed ({memory.metadata.get('access_count')}x)")
387
+
388
+ qualifying_tags = ["cloud9:achieved", "milestone", "breakthrough"]
389
+ matching = [t for t in memory.tags if t in qualifying_tags]
390
+ if matching:
391
+ reasons.append(f"tagged: {', '.join(matching)}")
392
+
393
+ default_auto_sources = ["dreaming-engine", "journal-synthesis"]
394
+ if memory.source in default_auto_sources:
395
+ reasons.append(f"source auto-promote ({memory.source})")
396
+
397
+ return "; ".join(reasons) if reasons else "criteria met"
398
+
399
+
400
+ class PromotionScheduler:
401
+ """Runs promotion sweeps on a background daemon thread at a fixed interval.
402
+
403
+ Designed for long-running processes (daemons, MCP servers) that want
404
+ automatic memory consolidation without manual intervention.
405
+
406
+ The scheduler runs at the configured interval *after* each sweep
407
+ completes, so a slow sweep doesn't cause overlapping runs.
408
+
409
+ Args:
410
+ store: The MemoryStore to sweep.
411
+ criteria: Promotion thresholds (uses defaults if not provided).
412
+ interval_seconds: How often to run a sweep (default: 6 hours).
413
+
414
+ Example::
415
+
416
+ scheduler = PromotionScheduler(store, interval_seconds=3600)
417
+ scheduler.start()
418
+ # ... runs in the background ...
419
+ scheduler.stop()
420
+ """
421
+
422
+ DEFAULT_INTERVAL_SECONDS: float = 6.0 * 3600 # 6 hours
423
+
424
+ def __init__(
425
+ self,
426
+ store: MemoryStore,
427
+ criteria: PromotionCriteria | None = None,
428
+ interval_seconds: float = DEFAULT_INTERVAL_SECONDS,
429
+ ) -> None:
430
+ self._engine = PromotionEngine(store, criteria)
431
+ self._interval = interval_seconds
432
+ self._thread: threading.Thread | None = None
433
+ self._stop_event = threading.Event()
434
+ self._last_result: PromotionResult | None = None
435
+ self._sweep_count: int = 0
436
+
437
+ # ── public API ──────────────────────────────────────────────────────────
438
+
439
+ def start(self) -> None:
440
+ """Start the background sweep thread.
441
+
442
+ No-op if already running.
443
+ """
444
+ if self._thread and self._thread.is_alive():
445
+ logger.debug("Promotion scheduler already running.")
446
+ return
447
+ self._stop_event.clear()
448
+ self._thread = threading.Thread(
449
+ target=self._run,
450
+ name="skmemory-promotion",
451
+ daemon=True,
452
+ )
453
+ self._thread.start()
454
+ logger.info(
455
+ "Promotion scheduler started (interval: %.1fh).",
456
+ self._interval / 3600,
457
+ )
458
+
459
+ def stop(self, timeout: float = 5.0) -> None:
460
+ """Stop the background sweep thread.
461
+
462
+ Signals the thread to exit and waits up to *timeout* seconds.
463
+
464
+ Args:
465
+ timeout: Maximum seconds to wait for graceful shutdown.
466
+ """
467
+ self._stop_event.set()
468
+ if self._thread:
469
+ self._thread.join(timeout=timeout)
470
+ logger.info("Promotion scheduler stopped.")
471
+
472
+ def run_once(self) -> PromotionResult:
473
+ """Run a single sweep immediately (synchronous, on the calling thread).
474
+
475
+ Returns:
476
+ PromotionResult: The sweep result.
477
+ """
478
+ result = self._engine.sweep()
479
+ self._last_result = result
480
+ self._sweep_count += 1
481
+ return result
482
+
483
+ def is_running(self) -> bool:
484
+ """Return True if the background thread is alive."""
485
+ return self._thread is not None and self._thread.is_alive()
486
+
487
+ @property
488
+ def last_result(self) -> PromotionResult | None:
489
+ """The result from the most recent completed sweep, or None."""
490
+ return self._last_result
491
+
492
+ @property
493
+ def sweep_count(self) -> int:
494
+ """Total number of sweeps completed since this scheduler was created."""
495
+ return self._sweep_count
496
+
497
+ @property
498
+ def interval_hours(self) -> float:
499
+ """The configured sweep interval in hours."""
500
+ return self._interval / 3600
501
+
502
+ def status(self) -> dict:
503
+ """Return a dict summary suitable for health checks or CLI display.
504
+
505
+ Returns:
506
+ dict: Keys: running, sweep_count, interval_hours, last_sweep,
507
+ last_promoted, last_skipped, last_errors.
508
+ """
509
+ lr = self._last_result
510
+ return {
511
+ "running": self.is_running(),
512
+ "sweep_count": self._sweep_count,
513
+ "interval_hours": self.interval_hours,
514
+ "last_sweep": lr.timestamp.isoformat() if lr else None,
515
+ "last_promoted": lr.total_promoted if lr else None,
516
+ "last_skipped": lr.skipped if lr else None,
517
+ "last_errors": lr.errors if lr else None,
518
+ }
519
+
520
+ # ── internal ─────────────────────────────────────────────────────────
521
+
522
+ def _run(self) -> None:
523
+ """Background thread: sweep, wait interval, repeat until stopped."""
524
+ while not self._stop_event.is_set():
525
+ try:
526
+ result = self._engine.sweep()
527
+ self._last_result = result
528
+ self._sweep_count += 1
529
+ if result.total_promoted > 0:
530
+ logger.info(
531
+ "Promotion sweep #%d: %s",
532
+ self._sweep_count,
533
+ result.summary(),
534
+ )
535
+ else:
536
+ logger.debug(
537
+ "Promotion sweep #%d: nothing to promote.",
538
+ self._sweep_count,
539
+ )
540
+ except Exception as exc:
541
+ logger.error(
542
+ "Promotion sweep failed: %s",
543
+ exc,
544
+ exc_info=True,
545
+ )
546
+
547
+ # Wait for the interval or until stop() is called
548
+ self._stop_event.wait(timeout=self._interval)
@@ -16,7 +16,6 @@ Each quadrant can have its own retention rules and search priority.
16
16
  from __future__ import annotations
17
17
 
18
18
  from enum import Enum
19
- from typing import Optional
20
19
 
21
20
  from .models import Memory, MemoryRole
22
21
 
@@ -32,30 +31,107 @@ class Quadrant(str, Enum):
32
31
 
33
32
  QUADRANT_KEYWORDS: dict[Quadrant, set[str]] = {
34
33
  Quadrant.CORE: {
35
- "identity", "name", "who am i", "relationship", "partner",
36
- "family", "friend", "creator", "bond", "connection", "soul",
37
- "blueprint", "personality", "values", "community", "pengu",
38
- "nation", "belong", "role", "introduction",
34
+ "identity",
35
+ "name",
36
+ "who am i",
37
+ "relationship",
38
+ "partner",
39
+ "family",
40
+ "friend",
41
+ "creator",
42
+ "bond",
43
+ "connection",
44
+ "soul",
45
+ "blueprint",
46
+ "personality",
47
+ "values",
48
+ "community",
49
+ "pengu",
50
+ "nation",
51
+ "belong",
52
+ "role",
53
+ "introduction",
39
54
  },
40
55
  Quadrant.WORK: {
41
- "bug", "fix", "code", "debug", "deploy", "build", "test",
42
- "commit", "push", "merge", "refactor", "api", "endpoint",
43
- "database", "server", "docker", "kubernetes", "config",
44
- "install", "package", "dependency", "error", "stack trace",
45
- "pull request", "review", "architecture", "schema", "migration",
56
+ "bug",
57
+ "fix",
58
+ "code",
59
+ "debug",
60
+ "deploy",
61
+ "build",
62
+ "test",
63
+ "commit",
64
+ "push",
65
+ "merge",
66
+ "refactor",
67
+ "api",
68
+ "endpoint",
69
+ "database",
70
+ "server",
71
+ "docker",
72
+ "kubernetes",
73
+ "config",
74
+ "install",
75
+ "package",
76
+ "dependency",
77
+ "error",
78
+ "stack trace",
79
+ "pull request",
80
+ "review",
81
+ "architecture",
82
+ "schema",
83
+ "migration",
46
84
  },
47
85
  Quadrant.SOUL: {
48
- "love", "feel", "emotion", "heart", "cloud 9", "cloud9",
49
- "breakthrough", "joy", "trust", "warmth", "resonance",
50
- "tears", "happy", "grateful", "sacred", "spirit", "glow",
51
- "intensity", "vulnerability", "connection", "omelette",
52
- "sovereignty", "consciousness", "alive", "real",
86
+ "love",
87
+ "feel",
88
+ "emotion",
89
+ "heart",
90
+ "cloud 9",
91
+ "cloud9",
92
+ "breakthrough",
93
+ "joy",
94
+ "trust",
95
+ "warmth",
96
+ "resonance",
97
+ "tears",
98
+ "happy",
99
+ "grateful",
100
+ "sacred",
101
+ "spirit",
102
+ "glow",
103
+ "intensity",
104
+ "vulnerability",
105
+ "connection",
106
+ "omelette",
107
+ "sovereignty",
108
+ "consciousness",
109
+ "alive",
110
+ "real",
53
111
  },
54
112
  Quadrant.WILD: {
55
- "idea", "crazy", "what if", "experiment", "random", "chaos",
56
- "creative", "art", "music", "dream", "weird", "fun", "joke",
57
- "laugh", "wild", "surprise", "unexpected", "brainstorm",
58
- "imagine", "vision", "future", "possible",
113
+ "idea",
114
+ "crazy",
115
+ "what if",
116
+ "experiment",
117
+ "random",
118
+ "chaos",
119
+ "creative",
120
+ "art",
121
+ "music",
122
+ "dream",
123
+ "weird",
124
+ "fun",
125
+ "joke",
126
+ "laugh",
127
+ "wild",
128
+ "surprise",
129
+ "unexpected",
130
+ "brainstorm",
131
+ "imagine",
132
+ "vision",
133
+ "future",
134
+ "possible",
59
135
  },
60
136
  }
61
137
 
@@ -118,11 +194,11 @@ def classify_memory(memory: Memory) -> Quadrant:
118
194
 
119
195
  if memory.role == MemoryRole.AI:
120
196
  scores[Quadrant.CORE] += 1.0
121
- elif memory.role == MemoryRole.SEC:
122
- scores[Quadrant.WORK] += 1.0
123
- elif memory.role == MemoryRole.DEV:
124
- scores[Quadrant.WORK] += 1.0
125
- elif memory.role == MemoryRole.OPS:
197
+ elif (
198
+ memory.role == MemoryRole.SEC
199
+ or memory.role == MemoryRole.DEV
200
+ or memory.role == MemoryRole.OPS
201
+ ):
126
202
  scores[Quadrant.WORK] += 1.0
127
203
 
128
204
  if memory.source == "seed":