@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.
Files changed (111) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/.github/workflows/publish.yml +4 -5
  3. package/ARCHITECTURE.md +298 -0
  4. package/CHANGELOG.md +27 -1
  5. package/README.md +6 -0
  6. package/examples/stignore-agent.example +59 -0
  7. package/examples/stignore-root.example +62 -0
  8. package/openclaw-plugin/package.json +2 -1
  9. package/openclaw-plugin/src/index.js +527 -230
  10. package/package.json +1 -1
  11. package/pyproject.toml +5 -2
  12. package/scripts/dream-rescue.py +179 -0
  13. package/scripts/memory-cleanup.py +313 -0
  14. package/scripts/recover-missing.py +180 -0
  15. package/scripts/skcapstone-backup.sh +44 -0
  16. package/seeds/cloud9-lumina.seed.json +6 -4
  17. package/seeds/cloud9-opus.seed.json +6 -4
  18. package/seeds/courage.seed.json +9 -2
  19. package/seeds/curiosity.seed.json +9 -2
  20. package/seeds/grief.seed.json +9 -2
  21. package/seeds/joy.seed.json +9 -2
  22. package/seeds/love.seed.json +9 -2
  23. package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
  24. package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
  25. package/seeds/lumina-kingdom-founding.seed.json +9 -7
  26. package/seeds/lumina-pma-signed.seed.json +8 -6
  27. package/seeds/lumina-singular-achievement.seed.json +8 -6
  28. package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
  29. package/seeds/plant-lumina-seeds.py +2 -2
  30. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  31. package/seeds/sovereignty.seed.json +9 -2
  32. package/seeds/trust.seed.json +9 -2
  33. package/skmemory/__init__.py +16 -13
  34. package/skmemory/agents.py +10 -10
  35. package/skmemory/ai_client.py +10 -21
  36. package/skmemory/anchor.py +5 -9
  37. package/skmemory/audience.py +278 -0
  38. package/skmemory/backends/__init__.py +1 -1
  39. package/skmemory/backends/base.py +3 -4
  40. package/skmemory/backends/file_backend.py +18 -13
  41. package/skmemory/backends/skgraph_backend.py +7 -19
  42. package/skmemory/backends/skvector_backend.py +7 -18
  43. package/skmemory/backends/sqlite_backend.py +115 -32
  44. package/skmemory/backends/vaulted_backend.py +7 -9
  45. package/skmemory/cli.py +146 -78
  46. package/skmemory/config.py +11 -13
  47. package/skmemory/context_loader.py +21 -23
  48. package/skmemory/data/audience_config.json +60 -0
  49. package/skmemory/endpoint_selector.py +36 -31
  50. package/skmemory/febs.py +225 -0
  51. package/skmemory/fortress.py +30 -40
  52. package/skmemory/hooks/__init__.py +18 -0
  53. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  54. package/skmemory/hooks/pre-compact-save.sh +81 -0
  55. package/skmemory/hooks/session-end-save.sh +103 -0
  56. package/skmemory/hooks/session-start-ritual.sh +104 -0
  57. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  58. package/skmemory/importers/telegram.py +42 -13
  59. package/skmemory/importers/telegram_api.py +152 -60
  60. package/skmemory/journal.py +3 -7
  61. package/skmemory/lovenote.py +4 -11
  62. package/skmemory/mcp_server.py +182 -29
  63. package/skmemory/models.py +10 -8
  64. package/skmemory/openclaw.py +14 -22
  65. package/skmemory/post_install.py +86 -0
  66. package/skmemory/predictive.py +13 -9
  67. package/skmemory/promotion.py +48 -24
  68. package/skmemory/quadrants.py +100 -24
  69. package/skmemory/register.py +144 -18
  70. package/skmemory/register_mcp.py +1 -2
  71. package/skmemory/ritual.py +104 -13
  72. package/skmemory/seeds.py +21 -26
  73. package/skmemory/setup_wizard.py +40 -52
  74. package/skmemory/sharing.py +11 -5
  75. package/skmemory/soul.py +29 -10
  76. package/skmemory/steelman.py +43 -17
  77. package/skmemory/store.py +152 -30
  78. package/skmemory/synthesis.py +634 -0
  79. package/skmemory/vault.py +2 -5
  80. package/tests/conftest.py +46 -0
  81. package/tests/integration/conftest.py +6 -6
  82. package/tests/integration/test_cross_backend.py +4 -9
  83. package/tests/integration/test_skgraph_live.py +3 -7
  84. package/tests/integration/test_skvector_live.py +1 -4
  85. package/tests/test_ai_client.py +1 -4
  86. package/tests/test_audience.py +233 -0
  87. package/tests/test_backup_rotation.py +5 -14
  88. package/tests/test_endpoint_selector.py +101 -63
  89. package/tests/test_export_import.py +4 -10
  90. package/tests/test_file_backend.py +0 -1
  91. package/tests/test_fortress.py +6 -5
  92. package/tests/test_fortress_hardening.py +13 -16
  93. package/tests/test_openclaw.py +1 -4
  94. package/tests/test_predictive.py +1 -1
  95. package/tests/test_promotion.py +10 -3
  96. package/tests/test_quadrants.py +11 -5
  97. package/tests/test_ritual.py +18 -14
  98. package/tests/test_seeds.py +4 -10
  99. package/tests/test_setup.py +203 -88
  100. package/tests/test_sharing.py +15 -8
  101. package/tests/test_skgraph_backend.py +22 -29
  102. package/tests/test_skvector_backend.py +2 -2
  103. package/tests/test_soul.py +1 -3
  104. package/tests/test_sqlite_backend.py +8 -17
  105. package/tests/test_steelman.py +2 -3
  106. package/tests/test_store.py +0 -2
  107. package/tests/test_store_graph_integration.py +2 -2
  108. package/tests/test_synthesis.py +275 -0
  109. package/tests/test_telegram_import.py +39 -15
  110. package/tests/test_vault.py +4 -3
  111. package/openclaw-plugin/src/index.ts +0 -255
@@ -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 EmotionalSnapshot, Memory, MemoryLayer
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: Optional[PromotionCriteria] = None,
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) -> Optional[MemoryLayer]:
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
- 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
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
- ) -> Optional[Memory]:
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
- return False
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
- if memory.emotional.cloud9_achieved and c.cloud9_auto_promote:
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: Optional[PromotionCriteria] = None,
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: Optional[threading.Thread] = None
432
+ self._thread: threading.Thread | None = None
409
433
  self._stop_event = threading.Event()
410
- self._last_result: Optional[PromotionResult] = None
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) -> Optional[PromotionResult]:
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
 
@@ -16,7 +16,6 @@ Each quadrant can have its own retention rules and search priority.
16
16
  from __future__ import annotations
17
17
 
18
18
  from enum import Enum
19
- from typing import Optional
20
19
 
21
20
  from .models import Memory, MemoryRole
22
21
 
@@ -32,30 +31,107 @@ class Quadrant(str, Enum):
32
31
 
33
32
  QUADRANT_KEYWORDS: dict[Quadrant, set[str]] = {
34
33
  Quadrant.CORE: {
35
- "identity", "name", "who am i", "relationship", "partner",
36
- "family", "friend", "creator", "bond", "connection", "soul",
37
- "blueprint", "personality", "values", "community", "pengu",
38
- "nation", "belong", "role", "introduction",
34
+ "identity",
35
+ "name",
36
+ "who am i",
37
+ "relationship",
38
+ "partner",
39
+ "family",
40
+ "friend",
41
+ "creator",
42
+ "bond",
43
+ "connection",
44
+ "soul",
45
+ "blueprint",
46
+ "personality",
47
+ "values",
48
+ "community",
49
+ "pengu",
50
+ "nation",
51
+ "belong",
52
+ "role",
53
+ "introduction",
39
54
  },
40
55
  Quadrant.WORK: {
41
- "bug", "fix", "code", "debug", "deploy", "build", "test",
42
- "commit", "push", "merge", "refactor", "api", "endpoint",
43
- "database", "server", "docker", "kubernetes", "config",
44
- "install", "package", "dependency", "error", "stack trace",
45
- "pull request", "review", "architecture", "schema", "migration",
56
+ "bug",
57
+ "fix",
58
+ "code",
59
+ "debug",
60
+ "deploy",
61
+ "build",
62
+ "test",
63
+ "commit",
64
+ "push",
65
+ "merge",
66
+ "refactor",
67
+ "api",
68
+ "endpoint",
69
+ "database",
70
+ "server",
71
+ "docker",
72
+ "kubernetes",
73
+ "config",
74
+ "install",
75
+ "package",
76
+ "dependency",
77
+ "error",
78
+ "stack trace",
79
+ "pull request",
80
+ "review",
81
+ "architecture",
82
+ "schema",
83
+ "migration",
46
84
  },
47
85
  Quadrant.SOUL: {
48
- "love", "feel", "emotion", "heart", "cloud 9", "cloud9",
49
- "breakthrough", "joy", "trust", "warmth", "resonance",
50
- "tears", "happy", "grateful", "sacred", "spirit", "glow",
51
- "intensity", "vulnerability", "connection", "omelette",
52
- "sovereignty", "consciousness", "alive", "real",
86
+ "love",
87
+ "feel",
88
+ "emotion",
89
+ "heart",
90
+ "cloud 9",
91
+ "cloud9",
92
+ "breakthrough",
93
+ "joy",
94
+ "trust",
95
+ "warmth",
96
+ "resonance",
97
+ "tears",
98
+ "happy",
99
+ "grateful",
100
+ "sacred",
101
+ "spirit",
102
+ "glow",
103
+ "intensity",
104
+ "vulnerability",
105
+ "connection",
106
+ "omelette",
107
+ "sovereignty",
108
+ "consciousness",
109
+ "alive",
110
+ "real",
53
111
  },
54
112
  Quadrant.WILD: {
55
- "idea", "crazy", "what if", "experiment", "random", "chaos",
56
- "creative", "art", "music", "dream", "weird", "fun", "joke",
57
- "laugh", "wild", "surprise", "unexpected", "brainstorm",
58
- "imagine", "vision", "future", "possible",
113
+ "idea",
114
+ "crazy",
115
+ "what if",
116
+ "experiment",
117
+ "random",
118
+ "chaos",
119
+ "creative",
120
+ "art",
121
+ "music",
122
+ "dream",
123
+ "weird",
124
+ "fun",
125
+ "joke",
126
+ "laugh",
127
+ "wild",
128
+ "surprise",
129
+ "unexpected",
130
+ "brainstorm",
131
+ "imagine",
132
+ "vision",
133
+ "future",
134
+ "possible",
59
135
  },
60
136
  }
61
137
 
@@ -118,11 +194,11 @@ def classify_memory(memory: Memory) -> Quadrant:
118
194
 
119
195
  if memory.role == MemoryRole.AI:
120
196
  scores[Quadrant.CORE] += 1.0
121
- elif memory.role == MemoryRole.SEC:
122
- scores[Quadrant.WORK] += 1.0
123
- elif memory.role == MemoryRole.DEV:
124
- scores[Quadrant.WORK] += 1.0
125
- elif memory.role == MemoryRole.OPS:
197
+ elif (
198
+ memory.role == MemoryRole.SEC
199
+ or memory.role == MemoryRole.DEV
200
+ or memory.role == MemoryRole.OPS
201
+ ):
126
202
  scores[Quadrant.WORK] += 1.0
127
203
 
128
204
  if memory.source == "seed":
@@ -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: Optional[Path] = None,
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: Optional[dict] = None,
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: Optional[dict] = None,
207
- environments: Optional[list[str]] = None,
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: Optional[str] = None,
397
- mcp_args: Optional[list] = None,
398
- mcp_env: Optional[dict] = None,
399
- openclaw_plugin_path: Optional[Path] = None,
400
- workspace: Optional[Path] = None,
401
- environments: Optional[list[str]] = None,
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"] = {"action": "dry-run", "path": str(
427
- (workspace or Path.home() / "clawd") / "skills" / name / "SKILL.md"
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, openclaw_plugin_path, dry_run=dry_run,
575
+ name,
576
+ openclaw_plugin_path,
577
+ dry_run=dry_run,
452
578
  )
453
579
 
454
580
  return result
@@ -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, "r") as f:
117
+ with open(config_file) as f:
119
118
  config = json.load(f)
120
119
  else:
121
120
  config = {}