@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
package/skmemory/promotion.py
CHANGED
|
@@ -25,11 +25,10 @@ from __future__ import annotations
|
|
|
25
25
|
import logging
|
|
26
26
|
import threading
|
|
27
27
|
from datetime import datetime, timezone
|
|
28
|
-
from typing import Optional
|
|
29
28
|
|
|
30
29
|
from pydantic import BaseModel, Field
|
|
31
30
|
|
|
32
|
-
from .models import
|
|
31
|
+
from .models import Memory, MemoryLayer
|
|
33
32
|
from .store import MemoryStore
|
|
34
33
|
|
|
35
34
|
logger = logging.getLogger("skmemory.promotion")
|
|
@@ -47,6 +46,10 @@ class PromotionCriteria(BaseModel):
|
|
|
47
46
|
mid_to_long_tags: Tags that auto-qualify for long-term.
|
|
48
47
|
cloud9_auto_promote: Auto-promote Cloud 9 memories to long-term.
|
|
49
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.
|
|
50
53
|
"""
|
|
51
54
|
|
|
52
55
|
short_to_mid_intensity: float = Field(default=5.0, ge=0.0, le=10.0)
|
|
@@ -60,6 +63,26 @@ class PromotionCriteria(BaseModel):
|
|
|
60
63
|
cloud9_auto_promote: bool = True
|
|
61
64
|
max_promotions_per_sweep: int = Field(default=50, ge=1)
|
|
62
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
|
+
|
|
63
86
|
|
|
64
87
|
class PromotionResult(BaseModel):
|
|
65
88
|
"""Summary of a promotion sweep.
|
|
@@ -117,7 +140,7 @@ class PromotionEngine:
|
|
|
117
140
|
def __init__(
|
|
118
141
|
self,
|
|
119
142
|
store: MemoryStore,
|
|
120
|
-
criteria:
|
|
143
|
+
criteria: PromotionCriteria | None = None,
|
|
121
144
|
) -> None:
|
|
122
145
|
self._store = store
|
|
123
146
|
self._criteria = criteria or PromotionCriteria()
|
|
@@ -148,7 +171,7 @@ class PromotionEngine:
|
|
|
148
171
|
logger.info(result.summary())
|
|
149
172
|
return result
|
|
150
173
|
|
|
151
|
-
def evaluate(self, memory: Memory) ->
|
|
174
|
+
def evaluate(self, memory: Memory) -> MemoryLayer | None:
|
|
152
175
|
"""Evaluate whether a memory qualifies for promotion.
|
|
153
176
|
|
|
154
177
|
Args:
|
|
@@ -157,19 +180,17 @@ class PromotionEngine:
|
|
|
157
180
|
Returns:
|
|
158
181
|
Optional[MemoryLayer]: Target tier if it qualifies, None otherwise.
|
|
159
182
|
"""
|
|
160
|
-
if memory.layer == MemoryLayer.SHORT:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if self._qualifies_mid_to_long(memory):
|
|
165
|
-
return MemoryLayer.LONG
|
|
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
|
|
166
187
|
return None
|
|
167
188
|
|
|
168
189
|
def promote_memory(
|
|
169
190
|
self,
|
|
170
191
|
memory: Memory,
|
|
171
192
|
target: MemoryLayer,
|
|
172
|
-
) ->
|
|
193
|
+
) -> Memory | None:
|
|
173
194
|
"""Promote a single memory to a higher tier.
|
|
174
195
|
|
|
175
196
|
Creates a promoted copy with a generated summary.
|
|
@@ -277,7 +298,12 @@ class PromotionEngine:
|
|
|
277
298
|
if access >= c.short_to_mid_access_count:
|
|
278
299
|
return True
|
|
279
300
|
|
|
280
|
-
|
|
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
|
+
)
|
|
281
307
|
|
|
282
308
|
def _qualifies_mid_to_long(self, memory: Memory) -> bool:
|
|
283
309
|
"""Check if a mid-term memory qualifies for long-term.
|
|
@@ -300,10 +326,7 @@ class PromotionEngine:
|
|
|
300
326
|
if any(tag in memory.tags for tag in c.mid_to_long_tags):
|
|
301
327
|
return True
|
|
302
328
|
|
|
303
|
-
|
|
304
|
-
return True
|
|
305
|
-
|
|
306
|
-
return False
|
|
329
|
+
return bool(memory.emotional.cloud9_achieved and c.cloud9_auto_promote)
|
|
307
330
|
|
|
308
331
|
@staticmethod
|
|
309
332
|
def _age_hours(memory: Memory) -> float:
|
|
@@ -342,10 +365,7 @@ class PromotionEngine:
|
|
|
342
365
|
emotional_sig = memory.emotional.signature()
|
|
343
366
|
tags_str = ", ".join(memory.tags[:5]) if memory.tags else "untagged"
|
|
344
367
|
|
|
345
|
-
return
|
|
346
|
-
f"{memory.title}: {content_preview} "
|
|
347
|
-
f"[{emotional_sig}] [{tags_str}]"
|
|
348
|
-
)
|
|
368
|
+
return f"{memory.title}: {content_preview} [{emotional_sig}] [{tags_str}]"
|
|
349
369
|
|
|
350
370
|
@staticmethod
|
|
351
371
|
def _promotion_reason(memory: Memory) -> str:
|
|
@@ -370,6 +390,10 @@ class PromotionEngine:
|
|
|
370
390
|
if matching:
|
|
371
391
|
reasons.append(f"tagged: {', '.join(matching)}")
|
|
372
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
|
+
|
|
373
397
|
return "; ".join(reasons) if reasons else "criteria met"
|
|
374
398
|
|
|
375
399
|
|
|
@@ -400,14 +424,14 @@ class PromotionScheduler:
|
|
|
400
424
|
def __init__(
|
|
401
425
|
self,
|
|
402
426
|
store: MemoryStore,
|
|
403
|
-
criteria:
|
|
427
|
+
criteria: PromotionCriteria | None = None,
|
|
404
428
|
interval_seconds: float = DEFAULT_INTERVAL_SECONDS,
|
|
405
429
|
) -> None:
|
|
406
430
|
self._engine = PromotionEngine(store, criteria)
|
|
407
431
|
self._interval = interval_seconds
|
|
408
|
-
self._thread:
|
|
432
|
+
self._thread: threading.Thread | None = None
|
|
409
433
|
self._stop_event = threading.Event()
|
|
410
|
-
self._last_result:
|
|
434
|
+
self._last_result: PromotionResult | None = None
|
|
411
435
|
self._sweep_count: int = 0
|
|
412
436
|
|
|
413
437
|
# ── public API ──────────────────────────────────────────────────────────
|
|
@@ -461,7 +485,7 @@ class PromotionScheduler:
|
|
|
461
485
|
return self._thread is not None and self._thread.is_alive()
|
|
462
486
|
|
|
463
487
|
@property
|
|
464
|
-
def last_result(self) ->
|
|
488
|
+
def last_result(self) -> PromotionResult | None:
|
|
465
489
|
"""The result from the most recent completed sweep, or None."""
|
|
466
490
|
return self._last_result
|
|
467
491
|
|
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":
|
package/skmemory/register.py
CHANGED
|
@@ -26,8 +26,6 @@ import os
|
|
|
26
26
|
import shutil
|
|
27
27
|
import subprocess
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Optional
|
|
30
|
-
|
|
31
29
|
|
|
32
30
|
# ── Environment detection ────────────────────────────────────────────────────
|
|
33
31
|
|
|
@@ -89,7 +87,7 @@ def detect_environments() -> list[str]:
|
|
|
89
87
|
def register_skill(
|
|
90
88
|
name: str,
|
|
91
89
|
skill_md_path: Path,
|
|
92
|
-
workspace:
|
|
90
|
+
workspace: Path | None = None,
|
|
93
91
|
) -> dict:
|
|
94
92
|
"""Register a skill by symlinking its SKILL.md into the workspace skills dir.
|
|
95
93
|
|
|
@@ -164,7 +162,7 @@ def _upsert_mcp_entry(
|
|
|
164
162
|
name: str,
|
|
165
163
|
command: str,
|
|
166
164
|
args: list,
|
|
167
|
-
env:
|
|
165
|
+
env: dict | None = None,
|
|
168
166
|
) -> str:
|
|
169
167
|
"""Add or update an MCP server entry in a JSON config file.
|
|
170
168
|
|
|
@@ -203,8 +201,8 @@ def register_mcp(
|
|
|
203
201
|
name: str,
|
|
204
202
|
command: str,
|
|
205
203
|
args: list,
|
|
206
|
-
env:
|
|
207
|
-
environments:
|
|
204
|
+
env: dict | None = None,
|
|
205
|
+
environments: list[str] | None = None,
|
|
208
206
|
) -> dict:
|
|
209
207
|
"""Register an MCP server in detected (or specified) environments.
|
|
210
208
|
|
|
@@ -387,21 +385,136 @@ def register_openclaw_plugin(
|
|
|
387
385
|
return f"error: {exc}"
|
|
388
386
|
|
|
389
387
|
|
|
388
|
+
# ── Claude Code hooks registration ───────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def register_hooks(
|
|
392
|
+
environments: list[str] | None = None,
|
|
393
|
+
dry_run: bool = False,
|
|
394
|
+
) -> dict:
|
|
395
|
+
"""Register skmemory auto-save hooks in Claude Code settings.
|
|
396
|
+
|
|
397
|
+
Adds hooks for:
|
|
398
|
+
- PreCompact: save context to skmemory before compaction
|
|
399
|
+
- SessionEnd: journal session end
|
|
400
|
+
- SessionStart (compact): reinject memory context after compaction
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
environments: Target environments (auto-detect if None).
|
|
404
|
+
dry_run: If True, only report what would be done.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Dict with action taken: "created", "updated", "exists", "skip", or "error:...".
|
|
408
|
+
"""
|
|
409
|
+
if environments is None:
|
|
410
|
+
environments = detect_environments()
|
|
411
|
+
|
|
412
|
+
if "claude-code" not in environments:
|
|
413
|
+
return {"action": "skip", "reason": "claude-code not detected"}
|
|
414
|
+
|
|
415
|
+
if dry_run:
|
|
416
|
+
return {"action": "dry-run"}
|
|
417
|
+
|
|
418
|
+
home = Path.home()
|
|
419
|
+
settings_path = home / ".claude" / "settings.json"
|
|
420
|
+
|
|
421
|
+
# Resolve hook script paths from the installed package
|
|
422
|
+
hooks_dir = Path(__file__).parent / "hooks"
|
|
423
|
+
pre_compact = str(hooks_dir / "pre-compact-save.sh")
|
|
424
|
+
session_end = str(hooks_dir / "session-end-save.sh")
|
|
425
|
+
post_compact = str(hooks_dir / "post-compact-reinject.sh")
|
|
426
|
+
|
|
427
|
+
# Verify hook scripts exist
|
|
428
|
+
for script in [pre_compact, session_end, post_compact]:
|
|
429
|
+
if not Path(script).exists():
|
|
430
|
+
return {"action": f"error: hook script not found: {script}"}
|
|
431
|
+
|
|
432
|
+
desired_hooks = {
|
|
433
|
+
"PreCompact": [
|
|
434
|
+
{
|
|
435
|
+
"matcher": "",
|
|
436
|
+
"hooks": [{"type": "command", "command": pre_compact}],
|
|
437
|
+
}
|
|
438
|
+
],
|
|
439
|
+
"SessionEnd": [
|
|
440
|
+
{
|
|
441
|
+
"matcher": "",
|
|
442
|
+
"hooks": [{"type": "command", "command": session_end}],
|
|
443
|
+
}
|
|
444
|
+
],
|
|
445
|
+
"SessionStart": [
|
|
446
|
+
{
|
|
447
|
+
"matcher": "compact",
|
|
448
|
+
"hooks": [{"type": "command", "command": post_compact}],
|
|
449
|
+
}
|
|
450
|
+
],
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
data = _read_json(settings_path)
|
|
455
|
+
existing_hooks = data.get("hooks", {})
|
|
456
|
+
|
|
457
|
+
# Check if already configured
|
|
458
|
+
needs_update = False
|
|
459
|
+
for event, hook_list in desired_hooks.items():
|
|
460
|
+
if event not in existing_hooks:
|
|
461
|
+
needs_update = True
|
|
462
|
+
break
|
|
463
|
+
# Check if our hook command is already present
|
|
464
|
+
existing_cmds = []
|
|
465
|
+
for entry in existing_hooks[event]:
|
|
466
|
+
for h in entry.get("hooks", []):
|
|
467
|
+
existing_cmds.append(h.get("command", ""))
|
|
468
|
+
desired_cmd = hook_list[0]["hooks"][0]["command"]
|
|
469
|
+
if desired_cmd not in existing_cmds:
|
|
470
|
+
needs_update = True
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
if not needs_update:
|
|
474
|
+
return {"action": "exists"}
|
|
475
|
+
|
|
476
|
+
# Merge: add our hooks without removing existing ones
|
|
477
|
+
for event, hook_list in desired_hooks.items():
|
|
478
|
+
if event not in existing_hooks:
|
|
479
|
+
existing_hooks[event] = hook_list
|
|
480
|
+
else:
|
|
481
|
+
# Check if our command is already there
|
|
482
|
+
desired_cmd = hook_list[0]["hooks"][0]["command"]
|
|
483
|
+
already_present = False
|
|
484
|
+
for entry in existing_hooks[event]:
|
|
485
|
+
for h in entry.get("hooks", []):
|
|
486
|
+
if h.get("command") == desired_cmd:
|
|
487
|
+
already_present = True
|
|
488
|
+
break
|
|
489
|
+
if not already_present:
|
|
490
|
+
existing_hooks[event].extend(hook_list)
|
|
491
|
+
|
|
492
|
+
data["hooks"] = existing_hooks
|
|
493
|
+
_write_json(settings_path, data)
|
|
494
|
+
|
|
495
|
+
action = "updated" if settings_path.exists() else "created"
|
|
496
|
+
return {"action": action}
|
|
497
|
+
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
return {"action": f"error: {exc}"}
|
|
500
|
+
|
|
501
|
+
|
|
390
502
|
# ── High-level package registration ──────────────────────────────────────────
|
|
391
503
|
|
|
392
504
|
|
|
393
505
|
def register_package(
|
|
394
506
|
name: str,
|
|
395
507
|
skill_md_path: Path,
|
|
396
|
-
mcp_command:
|
|
397
|
-
mcp_args:
|
|
398
|
-
mcp_env:
|
|
399
|
-
openclaw_plugin_path:
|
|
400
|
-
|
|
401
|
-
|
|
508
|
+
mcp_command: str | None = None,
|
|
509
|
+
mcp_args: list | None = None,
|
|
510
|
+
mcp_env: dict | None = None,
|
|
511
|
+
openclaw_plugin_path: Path | None = None,
|
|
512
|
+
install_hooks: bool = False,
|
|
513
|
+
workspace: Path | None = None,
|
|
514
|
+
environments: list[str] | None = None,
|
|
402
515
|
dry_run: bool = False,
|
|
403
516
|
) -> dict:
|
|
404
|
-
"""Register a skill, MCP server, and OpenClaw plugin in all detected environments.
|
|
517
|
+
"""Register a skill, MCP server, hooks, and OpenClaw plugin in all detected environments.
|
|
405
518
|
|
|
406
519
|
Args:
|
|
407
520
|
name: Package/skill name.
|
|
@@ -410,12 +523,13 @@ def register_package(
|
|
|
410
523
|
mcp_args: MCP server arguments.
|
|
411
524
|
mcp_env: MCP server environment variables.
|
|
412
525
|
openclaw_plugin_path: Path to OpenClaw plugin entry (e.g. src/index.ts).
|
|
526
|
+
install_hooks: If True, register Claude Code hooks for auto-save.
|
|
413
527
|
workspace: Workspace root for skill symlinks.
|
|
414
528
|
environments: Target environments (auto-detect if None).
|
|
415
529
|
dry_run: If True, only report what would be done.
|
|
416
530
|
|
|
417
531
|
Returns:
|
|
418
|
-
Dict with 'skill', 'mcp', and 'openclaw_plugin' results.
|
|
532
|
+
Dict with 'skill', 'mcp', 'hooks', and 'openclaw_plugin' results.
|
|
419
533
|
"""
|
|
420
534
|
if environments is None:
|
|
421
535
|
environments = detect_environments()
|
|
@@ -423,11 +537,14 @@ def register_package(
|
|
|
423
537
|
result: dict = {"name": name, "environments": environments}
|
|
424
538
|
|
|
425
539
|
if dry_run:
|
|
426
|
-
result["skill"] = {
|
|
427
|
-
|
|
428
|
-
|
|
540
|
+
result["skill"] = {
|
|
541
|
+
"action": "dry-run",
|
|
542
|
+
"path": str((workspace or Path.home() / "clawd") / "skills" / name / "SKILL.md"),
|
|
543
|
+
}
|
|
429
544
|
if mcp_command:
|
|
430
545
|
result["mcp"] = {env: "dry-run" for env in environments}
|
|
546
|
+
if install_hooks:
|
|
547
|
+
result["hooks"] = {"action": "dry-run"}
|
|
431
548
|
if openclaw_plugin_path and "openclaw" in environments:
|
|
432
549
|
result["openclaw_plugin"] = "dry-run"
|
|
433
550
|
return result
|
|
@@ -445,10 +562,19 @@ def register_package(
|
|
|
445
562
|
environments=environments,
|
|
446
563
|
)
|
|
447
564
|
|
|
565
|
+
# Register Claude Code hooks
|
|
566
|
+
if install_hooks:
|
|
567
|
+
result["hooks"] = register_hooks(
|
|
568
|
+
environments=environments,
|
|
569
|
+
dry_run=dry_run,
|
|
570
|
+
)
|
|
571
|
+
|
|
448
572
|
# Register OpenClaw plugin
|
|
449
573
|
if openclaw_plugin_path is not None and "openclaw" in environments:
|
|
450
574
|
result["openclaw_plugin"] = register_openclaw_plugin(
|
|
451
|
-
name,
|
|
575
|
+
name,
|
|
576
|
+
openclaw_plugin_path,
|
|
577
|
+
dry_run=dry_run,
|
|
452
578
|
)
|
|
453
579
|
|
|
454
580
|
return result
|
package/skmemory/register_mcp.py
CHANGED
|
@@ -18,7 +18,6 @@ import json
|
|
|
18
18
|
import os
|
|
19
19
|
import sys
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
from typing import Optional
|
|
22
21
|
|
|
23
22
|
|
|
24
23
|
def get_agent_name() -> str:
|
|
@@ -115,7 +114,7 @@ def register_openclaw(agent: str, dry_run: bool = False) -> bool:
|
|
|
115
114
|
|
|
116
115
|
# Read existing config
|
|
117
116
|
if config_file.exists():
|
|
118
|
-
with open(config_file
|
|
117
|
+
with open(config_file) as f:
|
|
119
118
|
config = json.load(f)
|
|
120
119
|
else:
|
|
121
120
|
config = {}
|