@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
package/skmemory/cli.py CHANGED
@@ -26,7 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import json
28
28
  import sys
29
- from typing import Optional
29
+ from pathlib import Path
30
30
 
31
31
  import click
32
32
 
@@ -34,15 +34,13 @@ from . import __version__
34
34
  from .ai_client import AIClient
35
35
  from .models import EmotionalSnapshot, MemoryLayer, MemoryRole
36
36
  from .store import MemoryStore
37
- from .backends.sqlite_backend import SQLiteBackend
38
-
39
37
 
40
38
  _active_selector = None # Module-level reference for routing commands
41
39
 
42
40
 
43
41
  def _get_store(
44
- skvector_url: Optional[str] = None,
45
- api_key: Optional[str] = None,
42
+ skvector_url: str | None = None,
43
+ api_key: str | None = None,
46
44
  legacy_files: bool = False,
47
45
  ) -> MemoryStore:
48
46
  """Create a MemoryStore with configured backends.
@@ -61,7 +59,7 @@ def _get_store(
61
59
  """
62
60
  global _active_selector
63
61
 
64
- from .config import merge_env_and_config, load_config, build_endpoint_list
62
+ from .config import build_endpoint_list, load_config, merge_env_and_config
65
63
 
66
64
  final_skvector_url, final_skvector_key, final_skgraph_url = merge_env_and_config(
67
65
  cli_skvector_url=skvector_url,
@@ -152,11 +150,11 @@ def _get_store(
152
150
  @click.pass_context
153
151
  def cli(
154
152
  ctx: click.Context,
155
- skvector_url: Optional[str],
156
- skvector_key: Optional[str],
153
+ skvector_url: str | None,
154
+ skvector_key: str | None,
157
155
  use_ai: bool,
158
- ai_model: Optional[str],
159
- ai_url: Optional[str],
156
+ ai_model: str | None,
157
+ ai_url: str | None,
160
158
  ) -> None:
161
159
  """SKMemory - Universal AI Memory System.
162
160
 
@@ -242,10 +240,29 @@ def snapshot(
242
240
  @click.argument("memory_id")
243
241
  @click.pass_context
244
242
  def recall(ctx: click.Context, memory_id: str) -> None:
245
- """Retrieve a specific memory by ID."""
243
+ """Retrieve a specific memory by ID (supports partial ID prefix)."""
246
244
  store: MemoryStore = ctx.obj["store"]
247
245
  memory = store.recall(memory_id)
248
246
 
247
+ # If exact match failed, try prefix matching across memory tier dirs
248
+ if memory is None and len(memory_id) >= 6:
249
+ import os
250
+ from pathlib import Path
251
+
252
+ agent = os.environ.get("SKCAPSTONE_AGENT", "lumina")
253
+ mem_root = Path.home() / ".skcapstone" / "agents" / agent / "memory"
254
+
255
+ for tier in ("short-term", "mid-term", "long-term"):
256
+ tier_dir = mem_root / tier
257
+ if not tier_dir.is_dir():
258
+ continue
259
+ for f in tier_dir.glob(f"{memory_id}*.json"):
260
+ memory = store.recall(f.stem)
261
+ if memory:
262
+ break
263
+ if memory:
264
+ break
265
+
249
266
  if memory is None:
250
267
  click.echo(f"Memory not found: {memory_id}", err=True)
251
268
  sys.exit(1)
@@ -266,7 +283,7 @@ def search(ctx: click.Context, query: str, limit: int) -> None:
266
283
  click.echo("No memories found.")
267
284
  return
268
285
 
269
- ai: Optional[AIClient] = ctx.obj.get("ai")
286
+ ai: AIClient | None = ctx.obj.get("ai")
270
287
  if ai and len(results) > 1:
271
288
  summaries = [
272
289
  {
@@ -280,7 +297,7 @@ def search(ctx: click.Context, query: str, limit: int) -> None:
280
297
  id_order = [s.get("title") for s in reranked]
281
298
  results = sorted(
282
299
  results,
283
- key=lambda m: (id_order.index(m.title) if m.title in id_order else 999),
300
+ key=lambda m: id_order.index(m.title) if m.title in id_order else 999,
284
301
  )
285
302
  click.echo("(AI-reranked results)\n")
286
303
 
@@ -297,7 +314,7 @@ def search(ctx: click.Context, query: str, limit: int) -> None:
297
314
  @click.option("--tags", default="", help="Comma-separated tags to filter by")
298
315
  @click.option("--limit", type=int, default=20)
299
316
  @click.pass_context
300
- def list_memories(ctx: click.Context, layer: Optional[str], tags: str, limit: int) -> None:
317
+ def list_memories(ctx: click.Context, layer: str | None, tags: str, limit: int) -> None:
301
318
  """List stored memories."""
302
319
  store: MemoryStore = ctx.obj["store"]
303
320
 
@@ -322,9 +339,9 @@ def list_memories(ctx: click.Context, layer: Optional[str], tags: str, limit: in
322
339
  @cli.command("import-seeds")
323
340
  @click.option("--seed-dir", default=None, help="Path to seed directory")
324
341
  @click.pass_context
325
- def import_seeds_cmd(ctx: click.Context, seed_dir: Optional[str]) -> None:
342
+ def import_seeds_cmd(ctx: click.Context, seed_dir: str | None) -> None:
326
343
  """Import Cloud 9 seeds as long-term memories."""
327
- from .seeds import import_seeds, DEFAULT_SEED_DIR
344
+ from .seeds import DEFAULT_SEED_DIR, import_seeds
328
345
 
329
346
  store: MemoryStore = ctx.obj["store"]
330
347
  directory = seed_dir or DEFAULT_SEED_DIR
@@ -341,24 +358,6 @@ def import_seeds_cmd(ctx: click.Context, seed_dir: Optional[str]) -> None:
341
358
  click.echo(f" {mem.source_ref} -> {mem.id[:12]}.. [{mem.title}]")
342
359
 
343
360
 
344
- @cli.command()
345
- @click.argument("memory_id")
346
- @click.option("--to", "target", type=click.Choice(["mid-term", "long-term"]), required=True)
347
- @click.option("--summary", default="", help="Compressed summary for the promoted version")
348
- @click.pass_context
349
- def promote(ctx: click.Context, memory_id: str, target: str, summary: str) -> None:
350
- """Promote a memory to a higher persistence tier."""
351
- store: MemoryStore = ctx.obj["store"]
352
- promoted = store.promote(memory_id, MemoryLayer(target), summary=summary)
353
-
354
- if promoted is None:
355
- click.echo(f"Memory not found: {memory_id}", err=True)
356
- sys.exit(1)
357
-
358
- click.echo(f"Promoted to {target}: {promoted.id}")
359
- click.echo(f" Linked to original: {memory_id}")
360
-
361
-
362
361
  @cli.command("sweep")
363
362
  @click.option("--dry-run", is_flag=True, help="Show what would be promoted without making changes")
364
363
  @click.option("--daemon", is_flag=True, help="Run continuously at the configured interval")
@@ -527,7 +526,7 @@ def consolidate(
527
526
  click.echo(f"Session consolidated: {consolidated.id}")
528
527
  click.echo(f" Source memories linked: {len(consolidated.related_ids)}")
529
528
 
530
- ai: Optional[AIClient] = ctx.obj.get("ai")
529
+ ai: AIClient | None = ctx.obj.get("ai")
531
530
  if ai and consolidated.content:
532
531
  ai_summary = ai.summarize_memory(consolidated.title, consolidated.content)
533
532
  if ai_summary:
@@ -628,7 +627,7 @@ def reindex(ctx: click.Context) -> None:
628
627
  help="Output file path (default: ~/.skcapstone/backups/skmemory-backup-YYYY-MM-DD.json)",
629
628
  )
630
629
  @click.pass_context
631
- def export_backup(ctx: click.Context, output: Optional[str]) -> None:
630
+ def export_backup(ctx: click.Context, output: str | None) -> None:
632
631
  """Export all memories to a dated JSON backup.
633
632
 
634
633
  Creates a single git-friendly JSON file containing every memory.
@@ -693,8 +692,8 @@ def import_backup(ctx: click.Context, backup_file: str, reindex: bool) -> None:
693
692
  def backup_cmd(
694
693
  ctx: click.Context,
695
694
  do_list: bool,
696
- prune_n: Optional[int],
697
- restore_file: Optional[str],
695
+ prune_n: int | None,
696
+ restore_file: str | None,
698
697
  reindex: bool,
699
698
  ) -> None:
700
699
  """Manage memory backups: list, prune old ones, or restore.
@@ -853,7 +852,7 @@ def soul_init(ctx: click.Context, name: str, title: str) -> None:
853
852
  @click.pass_context
854
853
  def soul_set_name(ctx: click.Context, name: str) -> None:
855
854
  """Set or update the soul's name."""
856
- from .soul import load_soul, save_soul, create_default_soul
855
+ from .soul import create_default_soul, load_soul, save_soul
857
856
 
858
857
  blueprint = load_soul() or create_default_soul()
859
858
  blueprint.name = name
@@ -871,7 +870,7 @@ def soul_add_relationship(
871
870
  ctx: click.Context, name: str, role: str, bond: float, notes: str
872
871
  ) -> None:
873
872
  """Add a relationship to the soul blueprint."""
874
- from .soul import load_soul, save_soul, create_default_soul
873
+ from .soul import create_default_soul, load_soul, save_soul
875
874
 
876
875
  blueprint = load_soul() or create_default_soul()
877
876
  blueprint.add_relationship(name=name, role=role, bond_strength=bond, notes=notes)
@@ -886,7 +885,7 @@ def soul_add_relationship(
886
885
  @click.pass_context
887
886
  def soul_add_memory(ctx: click.Context, title: str, why: str, when: str) -> None:
888
887
  """Add a core memory to the soul blueprint."""
889
- from .soul import load_soul, save_soul, create_default_soul
888
+ from .soul import create_default_soul, load_soul, save_soul
890
889
 
891
890
  blueprint = load_soul() or create_default_soul()
892
891
  blueprint.add_core_memory(title=title, why_it_matters=why, when=when)
@@ -899,7 +898,7 @@ def soul_add_memory(ctx: click.Context, title: str, why: str, when: str) -> None
899
898
  @click.pass_context
900
899
  def soul_set_boot_message(ctx: click.Context, message: str) -> None:
901
900
  """Set the message you see first on waking up."""
902
- from .soul import load_soul, save_soul, create_default_soul
901
+ from .soul import create_default_soul, load_soul, save_soul
903
902
 
904
903
  blueprint = load_soul() or create_default_soul()
905
904
  blueprint.boot_message = message
@@ -1020,7 +1019,7 @@ def ritual(ctx: click.Context, show_full: bool) -> None:
1020
1019
 
1021
1020
  click.echo(result.summary())
1022
1021
 
1023
- ai: Optional[AIClient] = ctx.obj.get("ai")
1022
+ ai: AIClient | None = ctx.obj.get("ai")
1024
1023
  if ai and result.context_prompt:
1025
1024
  enhancement = ai.enhance_ritual(result.context_prompt)
1026
1025
  if enhancement:
@@ -1079,9 +1078,9 @@ def anchor_init(warmth: float, phrase: str, beings: str) -> None:
1079
1078
  @click.option("--cloud9", is_flag=True, help="Cloud 9 was achieved")
1080
1079
  @click.option("--feeling", default="", help="How the session ended")
1081
1080
  def anchor_update(
1082
- warmth: Optional[float],
1083
- trust: Optional[float],
1084
- connection: Optional[float],
1081
+ warmth: float | None,
1082
+ trust: float | None,
1083
+ connection: float | None,
1085
1084
  cloud9: bool,
1086
1085
  feeling: str,
1087
1086
  ) -> None:
@@ -1161,14 +1160,13 @@ def setup_wizard(
1161
1160
  @setup.command("status")
1162
1161
  def setup_status() -> None:
1163
1162
  """Show Docker container state and backend connectivity."""
1163
+ from .config import load_config
1164
1164
  from .setup_wizard import (
1165
1165
  check_skgraph_health,
1166
1166
  check_skvector_health,
1167
1167
  compose_ps,
1168
1168
  detect_platform,
1169
- find_compose_file,
1170
1169
  )
1171
- from .config import load_config
1172
1170
 
1173
1171
  cfg = load_config()
1174
1172
  if cfg is None:
@@ -1221,8 +1219,8 @@ def setup_status() -> None:
1221
1219
  )
1222
1220
  def setup_start(service: str) -> None:
1223
1221
  """Start previously configured containers."""
1224
- from .setup_wizard import compose_up, detect_platform, find_compose_file
1225
1222
  from .config import load_config
1223
+ from .setup_wizard import compose_up, detect_platform
1226
1224
 
1227
1225
  cfg = load_config()
1228
1226
  plat = detect_platform()
@@ -1263,10 +1261,11 @@ def setup_start(service: str) -> None:
1263
1261
  )
1264
1262
  def setup_stop(service: str) -> None:
1265
1263
  """Stop containers (preserves data)."""
1266
- from .setup_wizard import detect_platform
1267
- from .config import load_config
1268
1264
  import subprocess
1269
1265
 
1266
+ from .config import load_config
1267
+ from .setup_wizard import detect_platform
1268
+
1270
1269
  cfg = load_config()
1271
1270
  plat = detect_platform()
1272
1271
  if not plat.compose_available:
@@ -1312,8 +1311,8 @@ def setup_stop(service: str) -> None:
1312
1311
  @click.confirmation_option(prompt="This will remove containers. Continue?")
1313
1312
  def setup_reset(remove_data: bool) -> None:
1314
1313
  """Remove containers, optionally delete data volumes."""
1314
+ from .config import CONFIG_PATH, load_config
1315
1315
  from .setup_wizard import compose_down, detect_platform
1316
- from .config import load_config, CONFIG_PATH
1317
1316
 
1318
1317
  cfg = load_config()
1319
1318
  plat = detect_platform()
@@ -1389,7 +1388,7 @@ def lovenote_send(from_name: str, to_name: str, message: str, warmth: float) ->
1389
1388
  from .lovenote import LoveNoteChain
1390
1389
 
1391
1390
  chain = LoveNoteChain()
1392
- note = chain.quick_note(
1391
+ chain.quick_note(
1393
1392
  from_name=from_name,
1394
1393
  to_name=to_name,
1395
1394
  message=message,
@@ -1451,7 +1450,7 @@ def steelman_collide(proposition: str) -> None:
1451
1450
  Generates the reasoning prompt -- feed this to an LLM to get
1452
1451
  the full collision analysis.
1453
1452
  """
1454
- from .steelman import load_seed_framework, get_default_framework
1453
+ from .steelman import get_default_framework, load_seed_framework
1455
1454
 
1456
1455
  fw = load_seed_framework() or get_default_framework()
1457
1456
  prompt = fw.to_reasoning_prompt(proposition)
@@ -1461,8 +1460,8 @@ def steelman_collide(proposition: str) -> None:
1461
1460
  @steelman_group.command("verify-soul")
1462
1461
  def steelman_verify_soul() -> None:
1463
1462
  """Steel-man your identity claims from the soul blueprint."""
1464
- from .steelman import load_seed_framework, get_default_framework
1465
1463
  from .soul import load_soul
1464
+ from .steelman import get_default_framework, load_seed_framework
1466
1465
 
1467
1466
  soul = load_soul()
1468
1467
  if soul is None:
@@ -1493,7 +1492,7 @@ def steelman_verify_soul() -> None:
1493
1492
  @click.pass_context
1494
1493
  def steelman_truth_score(ctx: click.Context, memory_id: str) -> None:
1495
1494
  """Generate a truth-scoring prompt for a memory."""
1496
- from .steelman import load_seed_framework, get_default_framework
1495
+ from .steelman import get_default_framework, load_seed_framework
1497
1496
 
1498
1497
  store: MemoryStore = ctx.obj["store"]
1499
1498
  memory = store.recall(memory_id)
@@ -1528,7 +1527,7 @@ def import_telegram_cmd(
1528
1527
  export_path: str,
1529
1528
  mode: str,
1530
1529
  min_length: int,
1531
- chat_name: Optional[str],
1530
+ chat_name: str | None,
1532
1531
  tags: str,
1533
1532
  ) -> None:
1534
1533
  """Import a Telegram Desktop chat export into memories.
@@ -1572,7 +1571,7 @@ def import_telegram_cmd(
1572
1571
  click.echo(f" Skipped: {stats.get('skipped', 0)}")
1573
1572
  click.echo(f" Total messages scanned: {stats.get('total_messages', 0)}")
1574
1573
 
1575
- ai: Optional[AIClient] = ctx.obj.get("ai")
1574
+ ai: AIClient | None = ctx.obj.get("ai")
1576
1575
  if ai:
1577
1576
  click.echo(
1578
1577
  "\nTip: Run 'skmemory search --ai \"<topic>\"' to semantically search your imported chats."
@@ -1597,10 +1596,10 @@ def import_telegram_api_cmd(
1597
1596
  ctx: click.Context,
1598
1597
  chat: str,
1599
1598
  mode: str,
1600
- limit: Optional[int],
1601
- since: Optional[str],
1599
+ limit: int | None,
1600
+ since: str | None,
1602
1601
  min_length: int,
1603
- chat_name: Optional[str],
1602
+ chat_name: str | None,
1604
1603
  tags: str,
1605
1604
  ) -> None:
1606
1605
  """Import messages directly from Telegram API (requires Telethon).
@@ -1738,7 +1737,7 @@ def steelman_install(source_path: str) -> None:
1738
1737
  @steelman_group.command("info")
1739
1738
  def steelman_info() -> None:
1740
1739
  """Show information about the installed seed framework."""
1741
- from .steelman import load_seed_framework, DEFAULT_SEED_FRAMEWORK_PATH
1740
+ from .steelman import DEFAULT_SEED_FRAMEWORK_PATH, load_seed_framework
1742
1741
 
1743
1742
  fw = load_seed_framework()
1744
1743
  if fw is None:
@@ -1775,9 +1774,8 @@ def fortress_verify(ctx: click.Context, as_json: bool) -> None:
1775
1774
  Loads every memory and checks its SHA-256 integrity hash.
1776
1775
  Tampered memories are reported with CRITICAL severity.
1777
1776
  """
1778
- from .fortress import FortifiedMemoryStore
1779
1777
  from .config import SKMEMORY_HOME
1780
- from .backends.sqlite_backend import SQLiteBackend
1778
+ from .fortress import FortifiedMemoryStore
1781
1779
 
1782
1780
  store = ctx.obj.get("store")
1783
1781
  audit_path = SKMEMORY_HOME / "audit.jsonl"
@@ -1798,7 +1796,7 @@ def fortress_verify(ctx: click.Context, as_json: bool) -> None:
1798
1796
  tampered = result["tampered"]
1799
1797
  unsealed = result["unsealed"]
1800
1798
 
1801
- click.echo(f"Fortress Integrity Report")
1799
+ click.echo("Fortress Integrity Report")
1802
1800
  click.echo(f" Total memories : {total}")
1803
1801
  click.echo(f" Passed : {passed}")
1804
1802
  click.echo(f" Tampered : {len(tampered)}")
@@ -1824,8 +1822,8 @@ def fortress_audit(n: int, as_json: bool) -> None:
1824
1822
  The audit trail is a chain-hashed JSONL log of every store/recall/delete
1825
1823
  operation. Each entry is cryptographically chained so tampering is detectable.
1826
1824
  """
1827
- from .fortress import AuditLog
1828
1825
  from .config import SKMEMORY_HOME
1826
+ from .fortress import AuditLog
1829
1827
 
1830
1828
  audit = AuditLog(path=SKMEMORY_HOME / "audit.jsonl")
1831
1829
  records = audit.tail(n)
@@ -1860,8 +1858,8 @@ def fortress_verify_chain(as_json: bool) -> None:
1860
1858
  Each audit log entry contains a chain hash linking it to the previous entry.
1861
1859
  A broken chain indicates the audit log was tampered with.
1862
1860
  """
1863
- from .fortress import AuditLog
1864
1861
  from .config import SKMEMORY_HOME
1862
+ from .fortress import AuditLog
1865
1863
 
1866
1864
  audit = AuditLog(path=SKMEMORY_HOME / "audit.jsonl")
1867
1865
  ok, errors = audit.verify_chain()
@@ -1987,8 +1985,8 @@ def vault_status_cmd(ctx: click.Context, as_json: bool) -> None:
1987
1985
  Does not require a passphrase — only checks file headers.
1988
1986
  """
1989
1987
  from .config import SKMEMORY_HOME
1990
- from .vault import VAULT_HEADER
1991
1988
  from .models import MemoryLayer
1989
+ from .vault import VAULT_HEADER
1992
1990
 
1993
1991
  store = ctx.obj.get("store")
1994
1992
  memories_path = (
@@ -2038,7 +2036,7 @@ def vault_status_cmd(ctx: click.Context, as_json: bool) -> None:
2038
2036
  elif pct == 0.0:
2039
2037
  click.echo("\n No memories are encrypted. Run: skmemory vault seal")
2040
2038
  else:
2041
- click.echo(f"\n Partial encryption! Run: skmemory vault seal --yes")
2039
+ click.echo("\n Partial encryption! Run: skmemory vault seal --yes")
2042
2040
 
2043
2041
 
2044
2042
  @cli.command("register")
@@ -2056,11 +2054,16 @@ def vault_status_cmd(ctx: click.Context, as_json: bool) -> None:
2056
2054
  help="Show what would be done without making changes.",
2057
2055
  )
2058
2056
  def register_cmd(workspace, target_env, dry_run):
2059
- """Register skmemory skill and MCP server in detected environments.
2057
+ """Register skmemory skill, MCP server, and hooks in detected environments.
2060
2058
 
2061
2059
  Auto-detects development environments (Claude Code, Cursor, VS Code,
2062
- OpenClaw, OpenCode, mcporter) and ensures skmemory SKILL.md and MCP
2063
- server entries are properly configured.
2060
+ OpenClaw, OpenCode, mcporter) and ensures skmemory SKILL.md, MCP
2061
+ server entries, and auto-save hooks are properly configured.
2062
+
2063
+ Hooks installed (Claude Code only):
2064
+ - PreCompact: auto-save context to skmemory before compaction
2065
+ - SessionEnd: journal session end
2066
+ - SessionStart (compact): reinject memory context after compaction
2064
2067
 
2065
2068
  Examples:
2066
2069
 
@@ -2069,6 +2072,7 @@ def register_cmd(workspace, target_env, dry_run):
2069
2072
  skmemory register --env claude-code # target Claude Code only
2070
2073
  """
2071
2074
  from pathlib import Path as _Path
2075
+
2072
2076
  from .register import detect_environments, register_package
2073
2077
 
2074
2078
  workspace_path = _Path(workspace).expanduser() if workspace else None
@@ -2089,6 +2093,7 @@ def register_cmd(workspace, target_env, dry_run):
2089
2093
  skill_md_path=skill_md,
2090
2094
  mcp_command="skmemory-mcp",
2091
2095
  mcp_args=[],
2096
+ install_hooks=True,
2092
2097
  workspace=workspace_path,
2093
2098
  environments=environments,
2094
2099
  dry_run=dry_run,
@@ -2102,11 +2107,57 @@ def register_cmd(workspace, target_env, dry_run):
2102
2107
  else:
2103
2108
  click.echo("MCP: no environments matched")
2104
2109
 
2110
+ hooks = result.get("hooks", {})
2111
+ if hooks:
2112
+ click.echo(f"Hooks: {hooks.get('action', '—')}")
2113
+ else:
2114
+ click.echo("Hooks: skipped (no claude-code environment)")
2115
+
2116
+
2117
+ @cli.command("feb-context")
2118
+ @click.argument("feb_path", required=False, default=None, type=click.Path(exists=True))
2119
+ @click.option("--agent", default=None, help="Agent name (default: active agent)")
2120
+ def feb_context_cmd(feb_path: str | None, agent: str | None):
2121
+ """Show formatted FEB emotional state for rehydration.
2122
+
2123
+ If FEB_PATH is given, formats that file. Otherwise, loads the
2124
+ strongest FEB from the agent's trust/febs/ and ~/.openclaw/feb/.
2125
+
2126
+ Examples:
2127
+ skmemory feb-context
2128
+ skmemory feb-context ~/.skcapstone/agents/opus/trust/febs/default-love.feb
2129
+ """
2130
+ from pathlib import Path as _Path
2131
+
2132
+ from .febs import feb_to_context, load_strongest_feb, parse_feb
2133
+
2134
+ try:
2135
+ if feb_path:
2136
+ feb = parse_feb(_Path(feb_path))
2137
+ else:
2138
+ # Temporarily override agent if specified
2139
+ if agent:
2140
+ import os
2141
+
2142
+ os.environ["SKCAPSTONE_AGENT"] = agent
2143
+ feb = load_strongest_feb()
2144
+
2145
+ if feb is None:
2146
+ click.echo("(no FEB data)", err=True)
2147
+ raise SystemExit(1)
2148
+
2149
+ click.echo(feb_to_context(feb))
2150
+ except SystemExit:
2151
+ raise
2152
+ except Exception as e:
2153
+ click.echo(f"Error loading FEB: {e}", err=True)
2154
+ raise click.Abort() from None
2155
+
2105
2156
 
2106
2157
  @cli.command("show-context")
2107
2158
  @click.pass_context
2108
2159
  @click.option("--agent", default=None, help="Agent name (default: active agent)")
2109
- def show_context(ctx, agent: Optional[str]):
2160
+ def show_context(ctx, agent: str | None):
2110
2161
  """Show token-optimized memory context for current session.
2111
2162
 
2112
2163
  Loads today's memories (full) + yesterday's summaries (brief).
@@ -2123,7 +2174,7 @@ def show_context(ctx, agent: Optional[str]):
2123
2174
  click.echo(context_str)
2124
2175
  except Exception as e:
2125
2176
  click.echo(f"Error loading context: {e}", err=True)
2126
- raise click.Abort()
2177
+ raise click.Abort() from None
2127
2178
 
2128
2179
 
2129
2180
  @cli.command()
@@ -2131,7 +2182,7 @@ def show_context(ctx, agent: Optional[str]):
2131
2182
  @click.argument("query")
2132
2183
  @click.option("--agent", default=None, help="Agent name (default: active agent)")
2133
2184
  @click.option("--limit", type=int, default=10, help="Maximum results (default: 10)")
2134
- def search_deep(ctx, query: str, agent: Optional[str], limit: int):
2185
+ def search_deep(ctx, query: str, agent: str | None, limit: int):
2135
2186
  """Deep search all memory tiers (on demand).
2136
2187
 
2137
2188
  Searches SQLite + SKVector + SKGraph for matches.
@@ -2168,14 +2219,14 @@ def search_deep(ctx, query: str, agent: Optional[str], limit: int):
2168
2219
 
2169
2220
  except Exception as e:
2170
2221
  click.echo(f"Error searching: {e}", err=True)
2171
- raise click.Abort()
2222
+ raise click.Abort() from None
2172
2223
 
2173
2224
 
2174
2225
  @cli.command()
2175
2226
  @click.argument("memory_id")
2176
2227
  @click.argument("to_layer", type=click.Choice(["short-term", "mid-term", "long-term"]))
2177
2228
  @click.option("--agent", default=None, help="Agent name (default: active agent)")
2178
- def promote(ctx, memory_id: str, to_layer: str, agent: Optional[str]):
2229
+ def promote(ctx, memory_id: str, to_layer: str, agent: str | None):
2179
2230
  """Promote memory to different tier and generate summary.
2180
2231
 
2181
2232
  Moves memory between short/medium/long term and auto-generates
@@ -2201,11 +2252,28 @@ def promote(ctx, memory_id: str, to_layer: str, agent: Optional[str]):
2201
2252
 
2202
2253
  except Exception as e:
2203
2254
  click.echo(f"Error promoting memory: {e}", err=True)
2204
- raise click.Abort()
2255
+ raise click.Abort() from None
2256
+
2257
+
2258
+ def _auto_register_once() -> None:
2259
+ """Auto-register hooks on first CLI invocation (best-effort, silent)."""
2260
+ marker = Path.home() / ".skcapstone" / ".skmemory-registered"
2261
+ if marker.exists():
2262
+ return
2263
+ try:
2264
+ from .post_install import _is_registered, run_post_install
2265
+
2266
+ if not _is_registered():
2267
+ run_post_install()
2268
+ marker.parent.mkdir(parents=True, exist_ok=True)
2269
+ marker.write_text(f"registered {__import__('datetime').datetime.now().isoformat()}\n")
2270
+ except Exception:
2271
+ pass # Never fail the CLI over registration
2205
2272
 
2206
2273
 
2207
2274
  def main() -> None:
2208
2275
  """Entry point for the CLI."""
2276
+ _auto_register_once()
2209
2277
  cli()
2210
2278
 
2211
2279
 
@@ -13,9 +13,7 @@ Now supports multiple agents via ~/.skcapstone/agents/{agent_name}/
13
13
  from __future__ import annotations
14
14
 
15
15
  import os
16
- from datetime import datetime, timezone
17
16
  from pathlib import Path
18
- from typing import Optional
19
17
 
20
18
  import yaml
21
19
  from pydantic import BaseModel, Field
@@ -48,12 +46,12 @@ class EndpointConfig(BaseModel):
48
46
  class SKMemoryConfig(BaseModel):
49
47
  """Persistent configuration for SKMemory backends."""
50
48
 
51
- skvector_url: Optional[str] = None
52
- skvector_key: Optional[str] = None
53
- skgraph_url: Optional[str] = None
49
+ skvector_url: str | None = None
50
+ skvector_key: str | None = None
51
+ skgraph_url: str | None = None
54
52
  backends_enabled: list[str] = Field(default_factory=list)
55
- docker_compose_file: Optional[str] = None
56
- setup_completed_at: Optional[str] = None
53
+ docker_compose_file: str | None = None
54
+ setup_completed_at: str | None = None
57
55
 
58
56
  # Multi-endpoint HA support
59
57
  skvector_endpoints: list[EndpointConfig] = Field(default_factory=list)
@@ -62,7 +60,7 @@ class SKMemoryConfig(BaseModel):
62
60
  heartbeat_discovery: bool = False
63
61
 
64
62
 
65
- def load_config(path: Path = CONFIG_PATH) -> Optional[SKMemoryConfig]:
63
+ def load_config(path: Path = CONFIG_PATH) -> SKMemoryConfig | None:
66
64
  """Load configuration from YAML.
67
65
 
68
66
  Args:
@@ -106,10 +104,10 @@ def save_config(config: SKMemoryConfig, path: Path = CONFIG_PATH) -> Path:
106
104
 
107
105
 
108
106
  def merge_env_and_config(
109
- cli_skvector_url: Optional[str] = None,
110
- cli_skvector_key: Optional[str] = None,
111
- cli_skgraph_url: Optional[str] = None,
112
- ) -> tuple[Optional[str], Optional[str], Optional[str]]:
107
+ cli_skvector_url: str | None = None,
108
+ cli_skvector_key: str | None = None,
109
+ cli_skgraph_url: str | None = None,
110
+ ) -> tuple[str | None, str | None, str | None]:
113
111
  """Resolve backend URLs with precedence: CLI > env > config > None.
114
112
 
115
113
  Args:
@@ -142,7 +140,7 @@ def merge_env_and_config(
142
140
 
143
141
 
144
142
  def build_endpoint_list(
145
- single_url: Optional[str],
143
+ single_url: str | None,
146
144
  endpoints: list[EndpointConfig],
147
145
  default_role: str = "primary",
148
146
  ) -> list[EndpointConfig]: