@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
@@ -32,14 +32,14 @@ from __future__ import annotations
32
32
  import asyncio
33
33
  import json
34
34
  import logging
35
- from typing import Any, Optional
35
+ from typing import Any
36
36
 
37
37
  from mcp.server import Server
38
38
  from mcp.server.stdio import stdio_server
39
39
  from mcp.types import TextContent, Tool
40
40
 
41
- from .store import MemoryStore
42
41
  from .models import MemoryLayer
42
+ from .store import MemoryStore
43
43
 
44
44
  logger = logging.getLogger("skmemory.mcp")
45
45
 
@@ -49,7 +49,7 @@ server = Server("skmemory")
49
49
  # Shared store instance
50
50
  # ---------------------------------------------------------------------------
51
51
 
52
- _store: Optional[MemoryStore] = None
52
+ _store: MemoryStore | None = None
53
53
 
54
54
 
55
55
  def _get_store() -> MemoryStore:
@@ -215,9 +215,7 @@ async def list_tools() -> list[Tool]:
215
215
  ),
216
216
  Tool(
217
217
  name="memory_consolidate",
218
- description=(
219
- "Compress a session's short-term memories into one mid-term memory."
220
- ),
218
+ description=("Compress a session's short-term memories into one mid-term memory."),
221
219
  inputSchema={
222
220
  "type": "object",
223
221
  "properties": {
@@ -275,9 +273,7 @@ async def list_tools() -> list[Tool]:
275
273
  ),
276
274
  Tool(
277
275
  name="memory_health",
278
- description=(
279
- "Full health check across all backends (primary, vector, graph)."
280
- ),
276
+ description=("Full health check across all backends (primary, vector, graph)."),
281
277
  inputSchema={"type": "object", "properties": {}, "required": []},
282
278
  ),
283
279
  Tool(
@@ -306,6 +302,67 @@ async def list_tools() -> list[Tool]:
306
302
  "required": ["action"],
307
303
  },
308
304
  ),
305
+ # ── Synthesis & Auto-Context ──────────────────────────────
306
+ Tool(
307
+ name="memory_synthesize_daily",
308
+ description=(
309
+ "Synthesize today's (or a given date's) memories into a single "
310
+ "narrative entry stored in mid-term. No LLM — uses tag frequency, "
311
+ "emotional arc, and template-based narrative."
312
+ ),
313
+ inputSchema={
314
+ "type": "object",
315
+ "properties": {
316
+ "date": {
317
+ "type": "string",
318
+ "description": "Date to synthesize (YYYY-MM-DD). Defaults to today.",
319
+ },
320
+ },
321
+ "required": [],
322
+ },
323
+ ),
324
+ Tool(
325
+ name="memory_synthesize_dreams",
326
+ description=(
327
+ "Process dream-engine memories into curated narrative memories "
328
+ "grouped by theme. Creates one mid-term memory per theme cluster."
329
+ ),
330
+ inputSchema={
331
+ "type": "object",
332
+ "properties": {
333
+ "since": {
334
+ "type": "string",
335
+ "description": (
336
+ "Only process dreams after this date (YYYY-MM-DD). "
337
+ "Defaults to 7 days ago."
338
+ ),
339
+ },
340
+ },
341
+ "required": [],
342
+ },
343
+ ),
344
+ Tool(
345
+ name="memory_auto_context",
346
+ description=(
347
+ "Search all memory layers for context related to keywords. "
348
+ "Deduplicates results and ranks by relevance + emotional intensity + importance. "
349
+ "Returns results within a token budget. Use this for contextual auto-search."
350
+ ),
351
+ inputSchema={
352
+ "type": "object",
353
+ "properties": {
354
+ "keywords": {
355
+ "type": "string",
356
+ "description": "Space-separated keywords to search for.",
357
+ },
358
+ "token_budget": {
359
+ "type": "integer",
360
+ "description": "Max tokens for results (default: 2000).",
361
+ },
362
+ },
363
+ "required": ["keywords"],
364
+ },
365
+ ),
309
366
  # ── Telegram ───────────────────────────────────────────────
310
367
  Tool(
311
368
  name="telegram_import",
@@ -524,21 +581,25 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
524
581
  promoted = store.promote(memory_id, target, summary=summary)
525
582
  if promoted is None:
526
583
  return _error_response(f"Memory not found: {memory_id}")
527
- return _json_response({
528
- "promoted_id": promoted.id,
529
- "source_id": memory_id,
530
- "target_layer": target_str,
531
- })
584
+ return _json_response(
585
+ {
586
+ "promoted_id": promoted.id,
587
+ "source_id": memory_id,
588
+ "target_layer": target_str,
589
+ }
590
+ )
532
591
 
533
592
  elif name == "memory_consolidate":
534
593
  session_id = arguments["session_id"]
535
594
  summary = arguments["summary"]
536
595
  consolidated = store.consolidate_session(session_id, summary)
537
- return _json_response({
538
- "memory_id": consolidated.id,
539
- "session_id": session_id,
540
- "consolidated": True,
541
- })
596
+ return _json_response(
597
+ {
598
+ "memory_id": consolidated.id,
599
+ "session_id": session_id,
600
+ "consolidated": True,
601
+ }
602
+ )
542
603
 
543
604
  elif name == "memory_context":
544
605
  token_budget = int(arguments.get("token_budget", 4000))
@@ -589,8 +650,8 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
589
650
  return _json_response(health)
590
651
 
591
652
  elif name == "memory_verify":
592
- from .fortress import FortifiedMemoryStore
593
653
  from .config import SKMEMORY_HOME
654
+ from .fortress import FortifiedMemoryStore
594
655
 
595
656
  fortress = FortifiedMemoryStore(
596
657
  primary=store.primary,
@@ -601,14 +662,102 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
601
662
  return _json_response(result)
602
663
 
603
664
  elif name == "memory_audit":
604
- from .fortress import AuditLog
605
665
  from .config import SKMEMORY_HOME
666
+ from .fortress import AuditLog
606
667
 
607
668
  n = int(arguments.get("last", 20))
608
669
  audit = AuditLog(path=SKMEMORY_HOME / "audit.jsonl")
609
670
  records = audit.tail(n)
610
671
  return _json_response(records)
611
672
 
673
+ # ── Synthesis & Auto-Context tools ────────────────────
674
+ elif name == "memory_synthesize_daily":
675
+ from .journal import Journal
676
+ from .synthesis import JournalSynthesizer
677
+
678
+ date = arguments.get("date")
679
+ synthesizer = JournalSynthesizer(store, Journal())
680
+ memory = synthesizer.synthesize_daily(date)
681
+ return _json_response(
682
+ {
683
+ "memory_id": memory.id,
684
+ "title": memory.title,
685
+ "themes": memory.metadata.get("themes", []),
686
+ "memory_count": memory.metadata.get("memory_count", 0),
687
+ }
688
+ )
689
+
690
+ elif name == "memory_synthesize_dreams":
691
+ from .journal import Journal
692
+ from .synthesis import JournalSynthesizer
693
+
694
+ since = arguments.get("since")
695
+ synthesizer = JournalSynthesizer(store, Journal())
696
+ memories = synthesizer.synthesize_dreams(since)
697
+ return _json_response(
698
+ {
699
+ "synthesized": len(memories),
700
+ "clusters": [
701
+ {
702
+ "memory_id": m.id,
703
+ "title": m.title,
704
+ "theme": m.metadata.get("theme", ""),
705
+ "dream_count": m.metadata.get("dream_count", 0),
706
+ }
707
+ for m in memories
708
+ ],
709
+ }
710
+ )
711
+
712
+ elif name == "memory_auto_context":
713
+ keywords_str = arguments["keywords"]
714
+ token_budget = int(arguments.get("token_budget", 2000))
715
+ keywords = keywords_str.split()
716
+
717
+ # Search for each keyword and collect results
718
+ seen_ids: set[str] = set()
719
+ all_results: list[dict] = []
720
+
721
+ for kw in keywords[:10]: # cap at 10 keywords
722
+ results = store.search(kw, limit=10)
723
+ for m in results:
724
+ if m.id not in seen_ids:
725
+ seen_ids.add(m.id)
726
+ all_results.append(
727
+ {
728
+ "id": m.id,
729
+ "title": m.title,
730
+ "summary": m.summary or m.content[:200],
731
+ "layer": m.layer.value,
732
+ "intensity": m.emotional.intensity,
733
+ "tags": m.tags[:5],
734
+ "source": m.source,
735
+ }
736
+ )
737
+
738
+ # Rank by intensity (higher = more relevant emotional context)
739
+ all_results.sort(key=lambda r: r["intensity"], reverse=True)
740
+
741
+ # Trim to token budget (estimate: title + summary per entry)
742
+ trimmed: list[dict] = []
743
+ used_tokens = 0
744
+ for entry in all_results:
745
+ text = entry["title"] + " " + entry["summary"]
746
+ est = int(len(text.split()) * 1.3)
747
+ if used_tokens + est > token_budget:
748
+ break
749
+ used_tokens += est
750
+ trimmed.append(entry)
751
+
752
+ return _json_response(
753
+ {
754
+ "results": trimmed,
755
+ "total_found": len(all_results),
756
+ "returned": len(trimmed),
757
+ "token_estimate": used_tokens,
758
+ }
759
+ )
760
+
612
761
  # ── Telegram tools ────────────────────────────────────
613
762
  elif name == "telegram_import":
614
763
  from .importers.telegram import import_telegram
@@ -663,17 +812,21 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
663
812
  elif name == "telegram_catchup":
664
813
  from .importers.telegram_api import import_telegram_api
665
814
 
666
- chat = args["chat"]
667
- limit = args.get("limit", 2000)
668
- since = args.get("since")
669
- min_length = args.get("min_length", 20)
670
- tags_str = args.get("tags", "")
815
+ chat = arguments["chat"]
816
+ limit = arguments.get("limit", 2000)
817
+ since = arguments.get("since")
818
+ min_length = arguments.get("min_length", 20)
819
+ tags_str = arguments.get("tags", "")
671
820
  tags = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else None
672
821
 
673
- store = MemoryStore()
674
822
  stats = import_telegram_api(
675
- store, chat, mode="catchup", limit=limit, since=since,
676
- min_message_length=min_length, tags=tags,
823
+ store,
824
+ chat,
825
+ mode="catchup",
826
+ limit=limit,
827
+ since=since,
828
+ min_message_length=min_length,
829
+ tags=tags,
677
830
  )
678
831
  return _json_response(stats)
679
832
 
@@ -11,7 +11,7 @@ import hashlib
11
11
  import uuid
12
12
  from datetime import datetime, timezone
13
13
  from enum import Enum
14
- from typing import Any, Optional
14
+ from typing import Any
15
15
 
16
16
  from pydantic import BaseModel, Field, field_validator
17
17
 
@@ -90,12 +90,8 @@ class Memory(BaseModel):
90
90
  """
91
91
 
92
92
  id: str = Field(default_factory=lambda: str(uuid.uuid4()))
93
- created_at: str = Field(
94
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
95
- )
96
- updated_at: str = Field(
97
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
98
- )
93
+ created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
94
+ updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
99
95
 
100
96
  layer: MemoryLayer = Field(default=MemoryLayer.SHORT)
101
97
  role: MemoryRole = Field(default=MemoryRole.GENERAL)
@@ -123,11 +119,17 @@ class Memory(BaseModel):
123
119
  default_factory=list,
124
120
  description="IDs of related memories (graph edges)",
125
121
  )
126
- parent_id: Optional[str] = Field(
122
+ parent_id: str | None = Field(
127
123
  default=None,
128
124
  description="ID of parent memory (for hierarchical chains)",
129
125
  )
130
126
 
127
+ context_tag: str = Field(
128
+ default="@chef-only",
129
+ description="Audience context tag: @public, @community, @work-circle, @inner-circle, "
130
+ "@chef-only, or scoped like @work:chiro. Conservative default: @chef-only.",
131
+ )
132
+
131
133
  intent: str = Field(
132
134
  default="",
133
135
  description="WHY this memory was stored — the purpose, not just the content. "
@@ -19,14 +19,11 @@ Or from the OpenClaw JS plugin (calls CLI under the hood).
19
19
  from __future__ import annotations
20
20
 
21
21
  import json
22
- import os
23
22
  from pathlib import Path
24
- from typing import Any, Optional
25
23
 
26
- from .models import EmotionalSnapshot, MemoryLayer, MemoryRole
27
- from .store import MemoryStore
28
24
  from .backends.sqlite_backend import SQLiteBackend
29
-
25
+ from .models import EmotionalSnapshot, MemoryLayer
26
+ from .store import MemoryStore
30
27
 
31
28
  OPENCLAW_BASE = Path.home() / ".openclaw"
32
29
  SKMEMORY_OPENCLAW_DIR = OPENCLAW_BASE / "plugins" / "skmemory"
@@ -48,14 +45,15 @@ class SKMemoryPlugin:
48
45
 
49
46
  def __init__(
50
47
  self,
51
- base_path: Optional[str] = None,
52
- skvector_url: Optional[str] = None,
53
- skvector_key: Optional[str] = None,
48
+ base_path: str | None = None,
49
+ skvector_url: str | None = None,
50
+ skvector_key: str | None = None,
54
51
  ) -> None:
55
52
  vector = None
56
53
  if skvector_url:
57
54
  try:
58
55
  from .backends.skvector_backend import SKVectorBackend
56
+
59
57
  vector = SKVectorBackend(url=skvector_url, api_key=skvector_key)
60
58
  except Exception:
61
59
  pass
@@ -97,10 +95,10 @@ class SKMemoryPlugin:
97
95
  content: str = "",
98
96
  *,
99
97
  layer: str = "short-term",
100
- tags: Optional[list[str]] = None,
98
+ tags: list[str] | None = None,
101
99
  intensity: float = 0.0,
102
100
  valence: float = 0.0,
103
- emotions: Optional[list[str]] = None,
101
+ emotions: list[str] | None = None,
104
102
  source: str = "openclaw",
105
103
  ) -> str:
106
104
  """Capture a memory snapshot.
@@ -152,16 +150,11 @@ class SKMemoryPlugin:
152
150
  "ORDER BY created_at DESC LIMIT ?",
153
151
  (q, q, q, limit),
154
152
  ).fetchall()
155
- return [
156
- self.store.primary._row_to_memory_summary(r) for r in rows
157
- ]
153
+ return [self.store.primary._row_to_memory_summary(r) for r in rows]
158
154
  results = self.store.search(query, limit=limit)
159
- return [
160
- {"id": m.id, "title": m.title, "layer": m.layer.value}
161
- for m in results
162
- ]
155
+ return [{"id": m.id, "title": m.title, "layer": m.layer.value} for m in results]
163
156
 
164
- def recall(self, memory_id: str) -> Optional[dict]:
157
+ def recall(self, memory_id: str) -> dict | None:
165
158
  """Retrieve a full memory by ID.
166
159
 
167
160
  Args:
@@ -191,7 +184,7 @@ class SKMemoryPlugin:
191
184
  "context_prompt": result.context_prompt,
192
185
  }
193
186
 
194
- def export(self, output_path: Optional[str] = None) -> str:
187
+ def export(self, output_path: str | None = None) -> str:
195
188
  """Export all memories to a dated JSON backup.
196
189
 
197
190
  Args:
@@ -229,9 +222,8 @@ class SKMemoryPlugin:
229
222
  """
230
223
  try:
231
224
  from . import __version__
225
+
232
226
  state["skmemory_version"] = __version__
233
- SKMEMORY_STATE_FILE.write_text(
234
- json.dumps(state, indent=2), encoding="utf-8"
235
- )
227
+ SKMEMORY_STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
236
228
  except Exception:
237
229
  pass
@@ -0,0 +1,86 @@
1
+ """Post-install auto-registration for skmemory.
2
+
3
+ Runs `skmemory register` automatically after pip install to ensure:
4
+ - MCP server is registered in Claude Code, Cursor, etc.
5
+ - Auto-save hooks are installed in Claude Code settings
6
+ - Skill symlink is created
7
+
8
+ Called via:
9
+ - `skmemory-post-install` console script (entry point)
10
+ - `pip install skmemory && skmemory-post-install`
11
+ - Automatically on first `skmemory` CLI invocation (if not yet registered)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ def _is_registered() -> bool:
21
+ """Check if hooks are already installed (quick check)."""
22
+ settings = Path.home() / ".claude" / "settings.json"
23
+ if not settings.exists():
24
+ return False
25
+ try:
26
+ import json
27
+
28
+ data = json.loads(settings.read_text())
29
+ hooks = data.get("hooks", {})
30
+ return "PreCompact" in hooks and "SessionEnd" in hooks
31
+ except Exception:
32
+ return False
33
+
34
+
35
+ def run_post_install() -> None:
36
+ """Register skmemory MCP server, hooks, and skill symlinks."""
37
+ from .register import detect_environments, register_package
38
+
39
+ print("skmemory: running post-install registration...")
40
+
41
+ detected = detect_environments()
42
+ if not detected:
43
+ print(" No supported environments detected. Skipping.")
44
+ return
45
+
46
+ print(f" Detected: {', '.join(detected)}")
47
+
48
+ skill_md = Path(__file__).parent.parent / "SKILL.md"
49
+ if not skill_md.exists():
50
+ skill_md = Path(__file__).parent / "SKILL.md"
51
+
52
+ result = register_package(
53
+ name="skmemory",
54
+ skill_md_path=skill_md,
55
+ mcp_command="skmemory-mcp",
56
+ mcp_args=[],
57
+ install_hooks=True,
58
+ environments=detected,
59
+ )
60
+
61
+ skill_action = result.get("skill", {}).get("action", "—")
62
+ print(f" Skill: {skill_action}")
63
+
64
+ mcp = result.get("mcp", {})
65
+ for env_name, action in mcp.items():
66
+ print(f" MCP ({env_name}): {action}")
67
+
68
+ hooks = result.get("hooks", {})
69
+ if hooks:
70
+ print(f" Hooks: {hooks.get('action', '—')}")
71
+
72
+ print("skmemory: post-install complete.")
73
+
74
+
75
+ def main() -> None:
76
+ """Entry point for skmemory-post-install console script."""
77
+ try:
78
+ run_post_install()
79
+ except Exception as exc:
80
+ # Never fail the install — registration is best-effort
81
+ print(f"skmemory: post-install warning: {exc}", file=sys.stderr)
82
+ sys.exit(0)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()
@@ -25,7 +25,6 @@ import math
25
25
  import time
26
26
  from collections import Counter, defaultdict
27
27
  from pathlib import Path
28
- from typing import Optional
29
28
 
30
29
  from pydantic import BaseModel, Field
31
30
 
@@ -62,7 +61,7 @@ class PredictiveRecall:
62
61
 
63
62
  def __init__(
64
63
  self,
65
- log_path: Optional[Path] = None,
64
+ log_path: Path | None = None,
66
65
  max_events: int = 5000,
67
66
  ) -> None:
68
67
  self._log_path = log_path or DEFAULT_ACCESS_LOG
@@ -100,7 +99,10 @@ class PredictiveRecall:
100
99
  current_session: list[AccessEvent] = []
101
100
 
102
101
  for event in sorted(self._events, key=lambda e: e.timestamp):
103
- if current_session and (event.timestamp - current_session[-1].timestamp) > session_window:
102
+ if (
103
+ current_session
104
+ and (event.timestamp - current_session[-1].timestamp) > session_window
105
+ ):
104
106
  sessions.append(current_session)
105
107
  current_session = []
106
108
  current_session.append(event)
@@ -111,7 +113,7 @@ class PredictiveRecall:
111
113
  ids_in_session = [e.memory_id for e in session]
112
114
  for i, mid in enumerate(ids_in_session):
113
115
  self._frequency[mid] += 1
114
- for other in ids_in_session[i + 1:]:
116
+ for other in ids_in_session[i + 1 :]:
115
117
  if other != mid:
116
118
  self._cooccurrence[mid][other] += 1
117
119
  self._cooccurrence[other][mid] += 1
@@ -120,7 +122,9 @@ class PredictiveRecall:
120
122
  for tag in event.tags:
121
123
  self._tag_affinity[tag][event.memory_id] += 1
122
124
 
123
- def log_access(self, memory_id: str, tags: Optional[list[str]] = None, layer: str = "", context: str = "") -> None:
125
+ def log_access(
126
+ self, memory_id: str, tags: list[str] | None = None, layer: str = "", context: str = ""
127
+ ) -> None:
124
128
  """Record a memory access event for pattern learning.
125
129
 
126
130
  Args:
@@ -144,15 +148,15 @@ class PredictiveRecall:
144
148
  self._tag_affinity[tag][memory_id] += 1
145
149
 
146
150
  if len(self._events) > self._max_events:
147
- self._events = self._events[-self._max_events:]
151
+ self._events = self._events[-self._max_events :]
148
152
  self._rebuild_indices()
149
153
 
150
154
  self._save()
151
155
 
152
156
  def predict(
153
157
  self,
154
- recent_ids: Optional[list[str]] = None,
155
- active_tags: Optional[list[str]] = None,
158
+ recent_ids: list[str] | None = None,
159
+ active_tags: list[str] | None = None,
156
160
  limit: int = 10,
157
161
  ) -> list[dict]:
158
162
  """Predict which memories will be needed next.
@@ -228,5 +232,5 @@ class PredictiveRecall:
228
232
  def _save(self) -> None:
229
233
  """Persist the access log to disk."""
230
234
  self._log_path.parent.mkdir(parents=True, exist_ok=True)
231
- data = [e.model_dump() for e in self._events[-self._max_events:]]
235
+ data = [e.model_dump() for e in self._events[-self._max_events :]]
232
236
  self._log_path.write_text(json.dumps(data, indent=2))