@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.
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- 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)
|