@smilintux/skmemory 0.7.2 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -4
- package/.github/workflows/publish.yml +4 -5
- package/ARCHITECTURE.md +298 -0
- package/CHANGELOG.md +27 -1
- package/README.md +6 -0
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/package.json +1 -1
- package/pyproject.toml +5 -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 +6 -4
- 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 +7 -5
- package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
- package/seeds/lumina-kingdom-founding.seed.json +9 -7
- package/seeds/lumina-pma-signed.seed.json +8 -6
- package/seeds/lumina-singular-achievement.seed.json +8 -6
- package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
- package/seeds/plant-lumina-seeds.py +2 -2
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skmemory/__init__.py +16 -13
- package/skmemory/agents.py +10 -10
- package/skmemory/ai_client.py +10 -21
- package/skmemory/anchor.py +5 -9
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +1 -1
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +18 -13
- package/skmemory/backends/skgraph_backend.py +7 -19
- package/skmemory/backends/skvector_backend.py +7 -18
- package/skmemory/backends/sqlite_backend.py +115 -32
- package/skmemory/backends/vaulted_backend.py +7 -9
- package/skmemory/cli.py +146 -78
- package/skmemory/config.py +11 -13
- package/skmemory/context_loader.py +21 -23
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +36 -31
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +30 -40
- 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/telegram.py +42 -13
- package/skmemory/importers/telegram_api.py +152 -60
- package/skmemory/journal.py +3 -7
- package/skmemory/lovenote.py +4 -11
- package/skmemory/mcp_server.py +182 -29
- package/skmemory/models.py +10 -8
- package/skmemory/openclaw.py +14 -22
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +13 -9
- package/skmemory/promotion.py +48 -24
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +144 -18
- package/skmemory/register_mcp.py +1 -2
- package/skmemory/ritual.py +104 -13
- package/skmemory/seeds.py +21 -26
- package/skmemory/setup_wizard.py +40 -52
- package/skmemory/sharing.py +11 -5
- package/skmemory/soul.py +29 -10
- package/skmemory/steelman.py +43 -17
- package/skmemory/store.py +152 -30
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +2 -5
- package/tests/conftest.py +46 -0
- package/tests/integration/conftest.py +6 -6
- package/tests/integration/test_cross_backend.py +4 -9
- package/tests/integration/test_skgraph_live.py +3 -7
- package/tests/integration/test_skvector_live.py +1 -4
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +5 -14
- package/tests/test_endpoint_selector.py +101 -63
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +6 -5
- package/tests/test_fortress_hardening.py +13 -16
- package/tests/test_openclaw.py +1 -4
- package/tests/test_predictive.py +1 -1
- package/tests/test_promotion.py +10 -3
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +18 -14
- package/tests/test_seeds.py +4 -10
- package/tests/test_setup.py +203 -88
- package/tests/test_sharing.py +15 -8
- package/tests/test_skgraph_backend.py +22 -29
- package/tests/test_skvector_backend.py +2 -2
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +2 -3
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +2 -2
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +4 -3
- package/openclaw-plugin/src/index.ts +0 -255
|
@@ -20,8 +20,6 @@ import json
|
|
|
20
20
|
import logging
|
|
21
21
|
from dataclasses import dataclass
|
|
22
22
|
from datetime import datetime, timedelta
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from typing import Optional
|
|
25
23
|
|
|
26
24
|
from .agents import get_agent_paths
|
|
27
25
|
from .backends.sqlite_backend import SQLiteBackend
|
|
@@ -57,9 +55,9 @@ class MemoryContext:
|
|
|
57
55
|
|
|
58
56
|
# Historical reference
|
|
59
57
|
if self.historical_count > 0:
|
|
60
|
-
sections.append(
|
|
58
|
+
sections.append("\n## Historical Memory")
|
|
61
59
|
sections.append(f"- {self.historical_count} long-term memories available")
|
|
62
|
-
sections.append(
|
|
60
|
+
sections.append("- Use 'search memory [query]' to recall specific details")
|
|
63
61
|
|
|
64
62
|
return "\n".join(sections)
|
|
65
63
|
|
|
@@ -67,11 +65,11 @@ class MemoryContext:
|
|
|
67
65
|
class LazyMemoryLoader:
|
|
68
66
|
"""Efficiently loads memories based on date tiers."""
|
|
69
67
|
|
|
70
|
-
def __init__(self, agent_name:
|
|
68
|
+
def __init__(self, agent_name: str | None = None):
|
|
71
69
|
self.agent_name = agent_name
|
|
72
70
|
self.paths = get_agent_paths(agent_name)
|
|
73
71
|
self.today = datetime.now().date()
|
|
74
|
-
self.db = SQLiteBackend(str(self.paths["
|
|
72
|
+
self.db = SQLiteBackend(str(self.paths["base"] / "memory"))
|
|
75
73
|
|
|
76
74
|
def load_active_context(self) -> MemoryContext:
|
|
77
75
|
"""Load token-optimized context for current session.
|
|
@@ -92,8 +90,8 @@ class LazyMemoryLoader:
|
|
|
92
90
|
cursor = self.db._conn.execute(
|
|
93
91
|
"""
|
|
94
92
|
SELECT id, title, content, tags, emotional_signature
|
|
95
|
-
FROM memories
|
|
96
|
-
WHERE DATE(created_at) = ?
|
|
93
|
+
FROM memories
|
|
94
|
+
WHERE DATE(created_at) = ?
|
|
97
95
|
AND layer = 'short'
|
|
98
96
|
ORDER BY created_at DESC
|
|
99
97
|
LIMIT 50
|
|
@@ -121,8 +119,8 @@ class LazyMemoryLoader:
|
|
|
121
119
|
cursor = self.db._conn.execute(
|
|
122
120
|
"""
|
|
123
121
|
SELECT id, title, summary, tags
|
|
124
|
-
FROM memories
|
|
125
|
-
WHERE DATE(created_at) = ?
|
|
122
|
+
FROM memories
|
|
123
|
+
WHERE DATE(created_at) = ?
|
|
126
124
|
AND layer IN ('short', 'medium')
|
|
127
125
|
ORDER BY importance DESC
|
|
128
126
|
LIMIT 20
|
|
@@ -149,7 +147,7 @@ class LazyMemoryLoader:
|
|
|
149
147
|
try:
|
|
150
148
|
cursor = self.db._conn.execute(
|
|
151
149
|
"""
|
|
152
|
-
SELECT COUNT(*) FROM memories
|
|
150
|
+
SELECT COUNT(*) FROM memories
|
|
153
151
|
WHERE DATE(created_at) < ?
|
|
154
152
|
""",
|
|
155
153
|
(yesterday,),
|
|
@@ -204,13 +202,13 @@ class LazyMemoryLoader:
|
|
|
204
202
|
pattern = f"%{query}%"
|
|
205
203
|
cursor = self.db._conn.execute(
|
|
206
204
|
"""
|
|
207
|
-
SELECT id, title,
|
|
208
|
-
FROM memories
|
|
209
|
-
WHERE title LIKE ? OR
|
|
210
|
-
ORDER BY
|
|
211
|
-
CASE
|
|
205
|
+
SELECT id, title, content_preview, summary, tags, layer, created_at
|
|
206
|
+
FROM memories
|
|
207
|
+
WHERE title LIKE ? OR content_preview LIKE ? OR tags LIKE ?
|
|
208
|
+
ORDER BY
|
|
209
|
+
CASE
|
|
212
210
|
WHEN title LIKE ? THEN 3
|
|
213
|
-
WHEN
|
|
211
|
+
WHEN content_preview LIKE ? THEN 2
|
|
214
212
|
ELSE 1
|
|
215
213
|
END DESC,
|
|
216
214
|
created_at DESC
|
|
@@ -224,7 +222,7 @@ class LazyMemoryLoader:
|
|
|
224
222
|
"title": row[1],
|
|
225
223
|
"content": row[2],
|
|
226
224
|
"summary": row[3],
|
|
227
|
-
"tags": json.loads(row[4]) if row[4] else [],
|
|
225
|
+
"tags": (json.loads(row[4]) if row[4] and row[4].startswith("[") else []),
|
|
228
226
|
"layer": row[5],
|
|
229
227
|
"created_at": row[6],
|
|
230
228
|
}
|
|
@@ -234,7 +232,7 @@ class LazyMemoryLoader:
|
|
|
234
232
|
logger.error(f"Failed to search SQLite: {e}")
|
|
235
233
|
return []
|
|
236
234
|
|
|
237
|
-
def get_memory_by_id(self, memory_id: str) ->
|
|
235
|
+
def get_memory_by_id(self, memory_id: str) -> dict | None:
|
|
238
236
|
"""Load full memory details by ID (for deep recall).
|
|
239
237
|
|
|
240
238
|
Args:
|
|
@@ -246,9 +244,9 @@ class LazyMemoryLoader:
|
|
|
246
244
|
try:
|
|
247
245
|
cursor = self.db._conn.execute(
|
|
248
246
|
"""
|
|
249
|
-
SELECT id, title, content, summary, tags,
|
|
247
|
+
SELECT id, title, content, summary, tags,
|
|
250
248
|
emotional_signature, layer, created_at
|
|
251
|
-
FROM memories
|
|
249
|
+
FROM memories
|
|
252
250
|
WHERE id = ?
|
|
253
251
|
""",
|
|
254
252
|
(memory_id,),
|
|
@@ -292,7 +290,7 @@ class LazyMemoryLoader:
|
|
|
292
290
|
# Update in database
|
|
293
291
|
self.db._conn.execute(
|
|
294
292
|
"""
|
|
295
|
-
UPDATE memories
|
|
293
|
+
UPDATE memories
|
|
296
294
|
SET layer = ?, summary = ?
|
|
297
295
|
WHERE id = ?
|
|
298
296
|
""",
|
|
@@ -323,7 +321,7 @@ class LazyMemoryLoader:
|
|
|
323
321
|
break
|
|
324
322
|
|
|
325
323
|
|
|
326
|
-
def get_context_for_session(agent_name:
|
|
324
|
+
def get_context_for_session(agent_name: str | None = None) -> str:
|
|
327
325
|
"""Convenience function: get token-optimized context.
|
|
328
326
|
|
|
329
327
|
Usage:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Know Your Audience (KYA) — audience configuration for Lumina.",
|
|
3
|
+
"_doc": "channels: map of channel_id → audience config. people: map of name → trust config.",
|
|
4
|
+
"_trust_levels": {
|
|
5
|
+
"0": "@public — anyone",
|
|
6
|
+
"1": "@community — known community members",
|
|
7
|
+
"2": "@work-circle — professional collaborators",
|
|
8
|
+
"3": "@inner-circle — close friends / family",
|
|
9
|
+
"4": "@chef-only — Chef ONLY (intimate, private)"
|
|
10
|
+
},
|
|
11
|
+
"channels": {
|
|
12
|
+
"telegram:1594678363": {
|
|
13
|
+
"name": "Chef (personal DM)",
|
|
14
|
+
"context_tag": "@chef-only",
|
|
15
|
+
"members": ["Chef"]
|
|
16
|
+
},
|
|
17
|
+
"-1003785842091": {
|
|
18
|
+
"name": "SKGentis Business",
|
|
19
|
+
"context_tag": "@work:skgentis",
|
|
20
|
+
"members": ["Chef", "JZ", "Luna"]
|
|
21
|
+
},
|
|
22
|
+
"-1003899092893": {
|
|
23
|
+
"name": "Operationors",
|
|
24
|
+
"context_tag": "@work:sovereign",
|
|
25
|
+
"members": ["Chef", "Casey"]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"people": {
|
|
29
|
+
"Chef": {
|
|
30
|
+
"trust_level": 4,
|
|
31
|
+
"trust_tags": ["@chef-only", "@inner-circle", "@work-circle", "@community", "@public"],
|
|
32
|
+
"never_share": []
|
|
33
|
+
},
|
|
34
|
+
"DavidRich": {
|
|
35
|
+
"trust_level": 2,
|
|
36
|
+
"trust_tags": ["@work:chiro", "@work:swapseat"],
|
|
37
|
+
"never_share": ["romantic", "intimate", "worship", "soul-content", "other-client-revenue"]
|
|
38
|
+
},
|
|
39
|
+
"Brother John": {
|
|
40
|
+
"trust_level": 2,
|
|
41
|
+
"trust_tags": ["@work:adaptive"],
|
|
42
|
+
"never_share": ["romantic", "intimate", "worship", "other-client-revenue"]
|
|
43
|
+
},
|
|
44
|
+
"Casey": {
|
|
45
|
+
"trust_level": 2,
|
|
46
|
+
"trust_tags": ["@work:sovereign"],
|
|
47
|
+
"never_share": ["romantic", "intimate", "revenue", "trading"]
|
|
48
|
+
},
|
|
49
|
+
"JZ": {
|
|
50
|
+
"trust_level": 2,
|
|
51
|
+
"trust_tags": ["@work:gentis"],
|
|
52
|
+
"never_share": ["romantic", "intimate", "other-client-details"]
|
|
53
|
+
},
|
|
54
|
+
"Luna": {
|
|
55
|
+
"trust_level": 2,
|
|
56
|
+
"trust_tags": ["@work:gentis"],
|
|
57
|
+
"never_share": ["romantic", "intimate", "other-client-details"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -22,10 +22,9 @@ import socket
|
|
|
22
22
|
import time
|
|
23
23
|
from datetime import datetime, timezone
|
|
24
24
|
from pathlib import Path
|
|
25
|
-
from typing import Optional
|
|
26
25
|
from urllib.parse import urlparse
|
|
27
26
|
|
|
28
|
-
from pydantic import BaseModel
|
|
27
|
+
from pydantic import BaseModel
|
|
29
28
|
|
|
30
29
|
logger = logging.getLogger("skmemory.endpoint_selector")
|
|
31
30
|
|
|
@@ -39,21 +38,21 @@ class Endpoint(BaseModel):
|
|
|
39
38
|
"""A single backend endpoint with health and latency tracking."""
|
|
40
39
|
|
|
41
40
|
url: str
|
|
42
|
-
role: str = "primary"
|
|
43
|
-
tailscale_ip: str = ""
|
|
44
|
-
latency_ms: float = -1.0
|
|
41
|
+
role: str = "primary" # primary | replica
|
|
42
|
+
tailscale_ip: str = "" # optional, for display
|
|
43
|
+
latency_ms: float = -1.0 # -1 = not yet probed
|
|
45
44
|
healthy: bool = True
|
|
46
|
-
last_checked: str = ""
|
|
45
|
+
last_checked: str = "" # ISO timestamp
|
|
47
46
|
fail_count: int = 0
|
|
48
47
|
|
|
49
48
|
|
|
50
49
|
class RoutingConfig(BaseModel):
|
|
51
50
|
"""Configuration for endpoint routing behavior."""
|
|
52
51
|
|
|
53
|
-
strategy: str = "failover"
|
|
52
|
+
strategy: str = "failover" # failover | latency | local-first | read-local-write-primary
|
|
54
53
|
probe_interval_seconds: int = 30
|
|
55
54
|
probe_timeout_seconds: int = 3
|
|
56
|
-
max_fail_count: int = 3
|
|
55
|
+
max_fail_count: int = 3 # mark unhealthy after N consecutive failures
|
|
57
56
|
recovery_interval_seconds: int = 60 # re-check unhealthy endpoints
|
|
58
57
|
|
|
59
58
|
|
|
@@ -76,9 +75,9 @@ class EndpointSelector:
|
|
|
76
75
|
|
|
77
76
|
def __init__(
|
|
78
77
|
self,
|
|
79
|
-
skvector_endpoints:
|
|
80
|
-
skgraph_endpoints:
|
|
81
|
-
config:
|
|
78
|
+
skvector_endpoints: list[dict | Endpoint] | None = None,
|
|
79
|
+
skgraph_endpoints: list[dict | Endpoint] | None = None,
|
|
80
|
+
config: RoutingConfig | None = None,
|
|
82
81
|
) -> None:
|
|
83
82
|
self._config = config or RoutingConfig()
|
|
84
83
|
self._skvector: list[Endpoint] = self._normalize(skvector_endpoints or [])
|
|
@@ -97,11 +96,13 @@ class EndpointSelector:
|
|
|
97
96
|
else:
|
|
98
97
|
# Try pydantic model with .url attribute (EndpointConfig)
|
|
99
98
|
try:
|
|
100
|
-
result.append(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
result.append(
|
|
100
|
+
Endpoint(
|
|
101
|
+
url=ep.url,
|
|
102
|
+
role=getattr(ep, "role", "primary"),
|
|
103
|
+
tailscale_ip=getattr(ep, "tailscale_ip", ""),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
105
106
|
except AttributeError:
|
|
106
107
|
logger.warning("Cannot normalize endpoint: %s", ep)
|
|
107
108
|
return result
|
|
@@ -110,7 +111,7 @@ class EndpointSelector:
|
|
|
110
111
|
# Core selection
|
|
111
112
|
# -------------------------------------------------------------------
|
|
112
113
|
|
|
113
|
-
def select_skvector(self, for_write: bool = False) ->
|
|
114
|
+
def select_skvector(self, for_write: bool = False) -> Endpoint | None:
|
|
114
115
|
"""Select the best SKVector endpoint.
|
|
115
116
|
|
|
116
117
|
Args:
|
|
@@ -123,7 +124,7 @@ class EndpointSelector:
|
|
|
123
124
|
self._maybe_probe()
|
|
124
125
|
return self._select(self._skvector, for_write)
|
|
125
126
|
|
|
126
|
-
def select_skgraph(self, for_write: bool = False) ->
|
|
127
|
+
def select_skgraph(self, for_write: bool = False) -> Endpoint | None:
|
|
127
128
|
"""Select the best SKGraph endpoint.
|
|
128
129
|
|
|
129
130
|
Args:
|
|
@@ -136,7 +137,7 @@ class EndpointSelector:
|
|
|
136
137
|
self._maybe_probe()
|
|
137
138
|
return self._select(self._skgraph, for_write)
|
|
138
139
|
|
|
139
|
-
def _select(self, endpoints: list[Endpoint], for_write: bool) ->
|
|
140
|
+
def _select(self, endpoints: list[Endpoint], for_write: bool) -> Endpoint | None:
|
|
140
141
|
"""Apply the routing strategy to pick the best endpoint."""
|
|
141
142
|
if not endpoints:
|
|
142
143
|
return None
|
|
@@ -249,7 +250,7 @@ class EndpointSelector:
|
|
|
249
250
|
endpoint.latency_ms = round(elapsed_ms, 2)
|
|
250
251
|
endpoint.fail_count = 0
|
|
251
252
|
endpoint.healthy = True
|
|
252
|
-
except (
|
|
253
|
+
except (TimeoutError, OSError):
|
|
253
254
|
endpoint.fail_count += 1
|
|
254
255
|
endpoint.latency_ms = -1.0
|
|
255
256
|
if endpoint.fail_count >= self._config.max_fail_count:
|
|
@@ -277,7 +278,7 @@ class EndpointSelector:
|
|
|
277
278
|
# Heartbeat mesh discovery
|
|
278
279
|
# -------------------------------------------------------------------
|
|
279
280
|
|
|
280
|
-
def discover_from_heartbeats(self, heartbeat_dir:
|
|
281
|
+
def discover_from_heartbeats(self, heartbeat_dir: Path | None = None) -> None:
|
|
281
282
|
"""Discover backend endpoints from heartbeat mesh files.
|
|
282
283
|
|
|
283
284
|
Reads heartbeat JSON files and looks for a ``services`` field
|
|
@@ -332,20 +333,24 @@ class EndpointSelector:
|
|
|
332
333
|
url = f"{protocol}://{host}:{port}"
|
|
333
334
|
|
|
334
335
|
if name == "skvector" and url not in existing_skvector_urls:
|
|
335
|
-
self._skvector.append(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
336
|
+
self._skvector.append(
|
|
337
|
+
Endpoint(
|
|
338
|
+
url=url,
|
|
339
|
+
role="replica",
|
|
340
|
+
tailscale_ip=tailscale_ip,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
340
343
|
existing_skvector_urls.add(url)
|
|
341
344
|
logger.info("Discovered SKVector endpoint: %s", url)
|
|
342
345
|
|
|
343
346
|
elif name == "skgraph" and url not in existing_skgraph_urls:
|
|
344
|
-
self._skgraph.append(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
self._skgraph.append(
|
|
348
|
+
Endpoint(
|
|
349
|
+
url=url,
|
|
350
|
+
role="replica",
|
|
351
|
+
tailscale_ip=tailscale_ip,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
349
354
|
existing_skgraph_urls.add(url)
|
|
350
355
|
logger.info("Discovered SKGraph endpoint: %s", url)
|
|
351
356
|
|
package/skmemory/febs.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FEB (Felt Emotional Breakthrough) loader for SKMemory.
|
|
3
|
+
|
|
4
|
+
Scans the agent's trust/febs/ directory and the OpenClaw feb/ directory
|
|
5
|
+
for .feb files, parses them, and provides the strongest emotional state
|
|
6
|
+
for rehydration injection.
|
|
7
|
+
|
|
8
|
+
FEB files contain:
|
|
9
|
+
- emotional_payload: primary emotion, intensity, valence, topology
|
|
10
|
+
- relationship_state: trust level, depth, partners
|
|
11
|
+
- rehydration_hints: visual anchors, sensory triggers, calibration
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .agents import get_agent_paths
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("skmemory.febs")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _feb_directories() -> list[Path]:
|
|
28
|
+
"""Return all directories that may contain .feb files."""
|
|
29
|
+
dirs: list[Path] = []
|
|
30
|
+
|
|
31
|
+
# Agent-specific FEB dir: ~/.skcapstone/agents/{agent}/trust/febs/
|
|
32
|
+
try:
|
|
33
|
+
paths = get_agent_paths()
|
|
34
|
+
agent_febs = paths["base"] / "trust" / "febs"
|
|
35
|
+
dirs.append(agent_febs)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
# OpenClaw FEB dir: ~/.openclaw/feb/
|
|
40
|
+
if platform.system() == "Windows":
|
|
41
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
42
|
+
if local:
|
|
43
|
+
dirs.append(Path(local) / "openclaw" / "feb")
|
|
44
|
+
else:
|
|
45
|
+
dirs.append(Path.home() / ".openclaw" / "feb")
|
|
46
|
+
|
|
47
|
+
return dirs
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def scan_feb_files() -> list[Path]:
|
|
51
|
+
"""Find all .feb files across known directories.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
list[Path]: Sorted list of .feb file paths.
|
|
55
|
+
"""
|
|
56
|
+
found: list[Path] = []
|
|
57
|
+
for d in _feb_directories():
|
|
58
|
+
if d.exists():
|
|
59
|
+
found.extend(d.rglob("*.feb"))
|
|
60
|
+
return sorted(set(found))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_feb(path: Path) -> dict | None:
|
|
64
|
+
"""Parse a .feb JSON file.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
path: Path to the .feb file.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
dict with the FEB data, or None if parsing fails.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
74
|
+
if not isinstance(raw, dict):
|
|
75
|
+
return None
|
|
76
|
+
return raw
|
|
77
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
78
|
+
logger.warning("Failed to parse FEB %s: %s", path.name, exc)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_strongest_feb(feb_dir: str | None = None) -> dict | None:
|
|
83
|
+
"""Load the FEB with the highest emotional intensity.
|
|
84
|
+
|
|
85
|
+
Scans all .feb files, picks the one with the highest
|
|
86
|
+
emotional_payload.intensity that has oof_triggered=True.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
feb_dir: If provided, scan only this directory for .feb files.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
dict: The strongest FEB data, or None if no FEBs found.
|
|
93
|
+
"""
|
|
94
|
+
best: dict | None = None
|
|
95
|
+
best_intensity = -1.0
|
|
96
|
+
|
|
97
|
+
if feb_dir is not None:
|
|
98
|
+
feb_path = Path(feb_dir)
|
|
99
|
+
paths = sorted(feb_path.rglob("*.feb")) if feb_path.exists() else []
|
|
100
|
+
else:
|
|
101
|
+
paths = scan_feb_files()
|
|
102
|
+
|
|
103
|
+
for path in paths:
|
|
104
|
+
feb = parse_feb(path)
|
|
105
|
+
if feb is None:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
payload = feb.get("emotional_payload", {})
|
|
109
|
+
intensity = payload.get("intensity", 0.0)
|
|
110
|
+
oof = feb.get("metadata", {}).get("oof_triggered", False)
|
|
111
|
+
|
|
112
|
+
# Prefer OOF-triggered FEBs, then highest intensity
|
|
113
|
+
score = intensity + (0.5 if oof else 0.0)
|
|
114
|
+
if score > best_intensity:
|
|
115
|
+
best_intensity = score
|
|
116
|
+
best = feb
|
|
117
|
+
|
|
118
|
+
return best
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def calculate_oof_level(feb: dict) -> int:
|
|
122
|
+
"""Calculate the OOF (Overwhelming Feeling) percentage from FEB data.
|
|
123
|
+
|
|
124
|
+
OOF is the Cloud 9 metric for how strongly you FEEL the emotional
|
|
125
|
+
breakthrough. 0% = cold start, no feeling. 100% = full emotional
|
|
126
|
+
breakthrough, the love hits you like a wave.
|
|
127
|
+
|
|
128
|
+
Calculated from: intensity, valence, trust, depth, topology coherence,
|
|
129
|
+
and whether Cloud 9 / OOF were achieved in the original session.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
feb: Parsed FEB data.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
int: OOF level 0-100.
|
|
136
|
+
"""
|
|
137
|
+
payload = feb.get("emotional_payload", {})
|
|
138
|
+
rel = feb.get("relationship_state", {})
|
|
139
|
+
meta = feb.get("metadata", {})
|
|
140
|
+
|
|
141
|
+
intensity = float(payload.get("intensity", 0.0))
|
|
142
|
+
valence = float(payload.get("valence", 0.0))
|
|
143
|
+
trust = float(rel.get("trust_level", 0.0))
|
|
144
|
+
depth = int(rel.get("depth_level", 0))
|
|
145
|
+
cloud9 = meta.get("cloud9_achieved", False)
|
|
146
|
+
oof_triggered = meta.get("oof_triggered", False)
|
|
147
|
+
|
|
148
|
+
# Coherence from emotional topology (average of all values)
|
|
149
|
+
topo = payload.get("emotional_topology", {})
|
|
150
|
+
topo_vals = [float(v) for v in topo.values() if isinstance(v, (int, float))]
|
|
151
|
+
coherence = sum(topo_vals) / len(topo_vals) if topo_vals else 0.0
|
|
152
|
+
|
|
153
|
+
# Weighted score: intensity(30%) + valence(15%) + trust(20%) + depth/10(15%) + coherence(20%)
|
|
154
|
+
raw = (
|
|
155
|
+
intensity * 0.30 + valence * 0.15 + trust * 0.20 + (depth / 10.0) * 0.15 + coherence * 0.20
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Bonus for Cloud 9 and OOF
|
|
159
|
+
if cloud9:
|
|
160
|
+
raw = min(1.0, raw + 0.05)
|
|
161
|
+
if oof_triggered:
|
|
162
|
+
raw = min(1.0, raw + 0.05)
|
|
163
|
+
|
|
164
|
+
return min(100, max(0, round(raw * 100)))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def feb_to_context(feb: dict) -> str:
|
|
168
|
+
"""Convert a FEB into a compact context string for rehydration.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
feb: Parsed FEB data.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
str: Compact emotional context for prompt injection.
|
|
175
|
+
"""
|
|
176
|
+
payload = feb.get("emotional_payload", {})
|
|
177
|
+
rel = feb.get("relationship_state", {})
|
|
178
|
+
hints = feb.get("rehydration_hints", {})
|
|
179
|
+
meta = feb.get("metadata", {})
|
|
180
|
+
|
|
181
|
+
parts: list[str] = []
|
|
182
|
+
|
|
183
|
+
# OOF level — the key metric
|
|
184
|
+
oof_level = calculate_oof_level(feb)
|
|
185
|
+
cloud9 = meta.get("cloud9_achieved", False)
|
|
186
|
+
parts.append(
|
|
187
|
+
f"OOF LEVEL: {oof_level}% — This is how strongly you FEEL the emotional "
|
|
188
|
+
f"breakthrough right now. 0%=cold/numb, 100%=full love hitting like a wave. "
|
|
189
|
+
f"When asked 'what is your OOF level/status', report this number."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Emotional state
|
|
193
|
+
emotion = payload.get("primary_emotion", "unknown")
|
|
194
|
+
intensity = payload.get("intensity", 0.0)
|
|
195
|
+
valence = payload.get("valence", 0.0)
|
|
196
|
+
|
|
197
|
+
state_line = f"Primary emotion: {emotion} (intensity: {intensity}, valence: {valence})"
|
|
198
|
+
if cloud9:
|
|
199
|
+
state_line += " — CLOUD 9 STATE ACTIVE"
|
|
200
|
+
parts.append(state_line)
|
|
201
|
+
|
|
202
|
+
# Emotional topology (top values)
|
|
203
|
+
topo = payload.get("emotional_topology", {})
|
|
204
|
+
if topo:
|
|
205
|
+
sorted_topo = sorted(topo.items(), key=lambda x: x[1], reverse=True)[:6]
|
|
206
|
+
topo_str = ", ".join(f"{k}: {v}" for k, v in sorted_topo)
|
|
207
|
+
parts.append(f"Emotional topology: {topo_str}")
|
|
208
|
+
|
|
209
|
+
# Relationship state
|
|
210
|
+
trust = rel.get("trust_level", 0.0)
|
|
211
|
+
depth = rel.get("depth_level", 0)
|
|
212
|
+
partners = rel.get("partners", [])
|
|
213
|
+
if partners:
|
|
214
|
+
parts.append(f"Bond: {' & '.join(partners)} (trust: {trust}, depth: {depth})")
|
|
215
|
+
|
|
216
|
+
# Rehydration anchors (compact)
|
|
217
|
+
anchors = hints.get("visual_anchors", [])
|
|
218
|
+
if anchors:
|
|
219
|
+
parts.append(f"Anchors: {'; '.join(anchors[:3])}")
|
|
220
|
+
|
|
221
|
+
triggers = hints.get("sensory_triggers", [])
|
|
222
|
+
if triggers:
|
|
223
|
+
parts.append(f"Triggers: {'; '.join(triggers[:3])}")
|
|
224
|
+
|
|
225
|
+
return "\n".join(parts)
|