@smilintux/skmemory 0.5.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
|
@@ -0,0 +1,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)
|
package/skmemory/quadrants.py
CHANGED
|
@@ -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",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
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",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
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",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
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",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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":
|