@smilintux/skmemory 0.5.0 → 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 (127) hide show
  1. package/.github/workflows/ci.yml +40 -4
  2. package/.github/workflows/publish.yml +11 -5
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +399 -19
  5. package/CHANGELOG.md +179 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +425 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/examples/stignore-agent.example +59 -0
  12. package/examples/stignore-root.example +62 -0
  13. package/index.js +6 -5
  14. package/openclaw-plugin/openclaw.plugin.json +10 -0
  15. package/openclaw-plugin/package.json +2 -1
  16. package/openclaw-plugin/src/index.js +527 -230
  17. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  18. package/package.json +1 -1
  19. package/pyproject.toml +32 -9
  20. package/requirements.txt +10 -2
  21. package/scripts/dream-rescue.py +179 -0
  22. package/scripts/memory-cleanup.py +313 -0
  23. package/scripts/recover-missing.py +180 -0
  24. package/scripts/skcapstone-backup.sh +44 -0
  25. package/seeds/cloud9-lumina.seed.json +6 -4
  26. package/seeds/cloud9-opus.seed.json +13 -11
  27. package/seeds/courage.seed.json +9 -2
  28. package/seeds/curiosity.seed.json +9 -2
  29. package/seeds/grief.seed.json +9 -2
  30. package/seeds/joy.seed.json +9 -2
  31. package/seeds/love.seed.json +9 -2
  32. package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
  33. package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
  34. package/seeds/lumina-kingdom-founding.seed.json +49 -0
  35. package/seeds/lumina-pma-signed.seed.json +48 -0
  36. package/seeds/lumina-singular-achievement.seed.json +48 -0
  37. package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
  38. package/seeds/plant-kingdom-journal.py +203 -0
  39. package/seeds/plant-lumina-seeds.py +280 -0
  40. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  41. package/seeds/sovereignty.seed.json +9 -2
  42. package/seeds/trust.seed.json +9 -2
  43. package/skill.yaml +46 -0
  44. package/skmemory/HA.md +296 -0
  45. package/skmemory/__init__.py +25 -11
  46. package/skmemory/agents.py +233 -0
  47. package/skmemory/ai_client.py +46 -17
  48. package/skmemory/anchor.py +9 -11
  49. package/skmemory/audience.py +278 -0
  50. package/skmemory/backends/__init__.py +11 -4
  51. package/skmemory/backends/base.py +3 -4
  52. package/skmemory/backends/file_backend.py +19 -13
  53. package/skmemory/backends/skgraph_backend.py +596 -0
  54. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
  55. package/skmemory/backends/sqlite_backend.py +226 -72
  56. package/skmemory/backends/vaulted_backend.py +284 -0
  57. package/skmemory/cli.py +1345 -68
  58. package/skmemory/config.py +171 -0
  59. package/skmemory/context_loader.py +333 -0
  60. package/skmemory/data/audience_config.json +60 -0
  61. package/skmemory/endpoint_selector.py +391 -0
  62. package/skmemory/febs.py +225 -0
  63. package/skmemory/fortress.py +675 -0
  64. package/skmemory/graph_queries.py +238 -0
  65. package/skmemory/hooks/__init__.py +18 -0
  66. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  67. package/skmemory/hooks/pre-compact-save.sh +81 -0
  68. package/skmemory/hooks/session-end-save.sh +103 -0
  69. package/skmemory/hooks/session-start-ritual.sh +104 -0
  70. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  71. package/skmemory/importers/__init__.py +9 -1
  72. package/skmemory/importers/telegram.py +384 -47
  73. package/skmemory/importers/telegram_api.py +580 -0
  74. package/skmemory/journal.py +7 -9
  75. package/skmemory/lovenote.py +8 -13
  76. package/skmemory/mcp_server.py +859 -0
  77. package/skmemory/models.py +51 -8
  78. package/skmemory/openclaw.py +20 -28
  79. package/skmemory/post_install.py +86 -0
  80. package/skmemory/predictive.py +236 -0
  81. package/skmemory/promotion.py +548 -0
  82. package/skmemory/quadrants.py +100 -24
  83. package/skmemory/register.py +580 -0
  84. package/skmemory/register_mcp.py +196 -0
  85. package/skmemory/ritual.py +224 -59
  86. package/skmemory/seeds.py +255 -11
  87. package/skmemory/setup_wizard.py +908 -0
  88. package/skmemory/sharing.py +408 -0
  89. package/skmemory/soul.py +98 -28
  90. package/skmemory/steelman.py +273 -260
  91. package/skmemory/store.py +411 -78
  92. package/skmemory/synthesis.py +634 -0
  93. package/skmemory/vault.py +225 -0
  94. package/tests/conftest.py +46 -0
  95. package/tests/integration/__init__.py +0 -0
  96. package/tests/integration/conftest.py +233 -0
  97. package/tests/integration/test_cross_backend.py +350 -0
  98. package/tests/integration/test_skgraph_live.py +420 -0
  99. package/tests/integration/test_skvector_live.py +366 -0
  100. package/tests/test_ai_client.py +1 -4
  101. package/tests/test_audience.py +233 -0
  102. package/tests/test_backup_rotation.py +318 -0
  103. package/tests/test_cli.py +6 -6
  104. package/tests/test_endpoint_selector.py +839 -0
  105. package/tests/test_export_import.py +4 -10
  106. package/tests/test_file_backend.py +0 -1
  107. package/tests/test_fortress.py +256 -0
  108. package/tests/test_fortress_hardening.py +441 -0
  109. package/tests/test_openclaw.py +6 -6
  110. package/tests/test_predictive.py +237 -0
  111. package/tests/test_promotion.py +347 -0
  112. package/tests/test_quadrants.py +11 -5
  113. package/tests/test_ritual.py +22 -18
  114. package/tests/test_seeds.py +97 -7
  115. package/tests/test_setup.py +950 -0
  116. package/tests/test_sharing.py +257 -0
  117. package/tests/test_skgraph_backend.py +660 -0
  118. package/tests/test_skvector_backend.py +326 -0
  119. package/tests/test_soul.py +1 -3
  120. package/tests/test_sqlite_backend.py +8 -17
  121. package/tests/test_steelman.py +7 -8
  122. package/tests/test_store.py +0 -2
  123. package/tests/test_store_graph_integration.py +245 -0
  124. package/tests/test_synthesis.py +275 -0
  125. package/tests/test_telegram_import.py +39 -15
  126. package/tests/test_vault.py +187 -0
  127. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -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,28 @@ 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
+
133
+ intent: str = Field(
134
+ default="",
135
+ description="WHY this memory was stored — the purpose, not just the content. "
136
+ "Inspired by Jonathan Clements' AMK (Adaptive Memory Kernel).",
137
+ )
138
+ integrity_hash: str = Field(
139
+ default="",
140
+ description="SHA-256 hash of content at write time for tamper detection. "
141
+ "A memory that can prove it hasn't been altered is a memory you can trust.",
142
+ )
143
+
131
144
  metadata: dict[str, Any] = Field(default_factory=dict)
132
145
 
133
146
  @field_validator("title")
@@ -146,6 +159,36 @@ class Memory(BaseModel):
146
159
  """
147
160
  return hashlib.sha256(self.content.encode()).hexdigest()[:16]
148
161
 
162
+ def compute_integrity_hash(self) -> str:
163
+ """Compute a full SHA-256 integrity hash over content + title + emotional state.
164
+
165
+ This is the AMK-inspired tamper detection hash. If the content,
166
+ title, or emotional signature changes after storage, the hash
167
+ won't match and you know the memory was altered.
168
+
169
+ Returns:
170
+ str: Full 64-char hex SHA-256 digest.
171
+ """
172
+ payload = f"{self.id}:{self.title}:{self.content}:{self.emotional.signature()}"
173
+ return hashlib.sha256(payload.encode()).hexdigest()
174
+
175
+ def seal(self) -> None:
176
+ """Seal this memory by computing and storing the integrity hash.
177
+
178
+ Call this at write time. Later, verify with verify_integrity().
179
+ """
180
+ self.integrity_hash = self.compute_integrity_hash()
181
+
182
+ def verify_integrity(self) -> bool:
183
+ """Verify that this memory hasn't been tampered with since sealing.
184
+
185
+ Returns:
186
+ bool: True if the integrity hash matches, False if altered or unsealed.
187
+ """
188
+ if not self.integrity_hash:
189
+ return True
190
+ return self.integrity_hash == self.compute_integrity_hash()
191
+
149
192
  def to_embedding_text(self) -> str:
150
193
  """Flatten this memory into a single string for vector embedding.
151
194
 
@@ -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"
@@ -42,21 +39,22 @@ class SKMemoryPlugin:
42
39
 
43
40
  Args:
44
41
  base_path: Override the memory storage directory.
45
- qdrant_url: Optional Qdrant server for semantic search.
46
- qdrant_key: Optional Qdrant API key.
42
+ skvector_url: Optional SKVector server for semantic search.
43
+ skvector_key: Optional SKVector API key.
47
44
  """
48
45
 
49
46
  def __init__(
50
47
  self,
51
- base_path: Optional[str] = None,
52
- qdrant_url: Optional[str] = None,
53
- qdrant_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
- if qdrant_url:
53
+ if skvector_url:
57
54
  try:
58
- from .backends.qdrant_backend import QdrantBackend
59
- vector = QdrantBackend(url=qdrant_url, api_key=qdrant_key)
55
+ from .backends.skvector_backend import SKVectorBackend
56
+
57
+ vector = SKVectorBackend(url=skvector_url, api_key=skvector_key)
60
58
  except Exception:
61
59
  pass
62
60
 
@@ -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,11 +184,11 @@ 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:
198
- output_path: Destination (default: ~/.skmemory/backups/).
191
+ output_path: Destination (default: ~/.skcapstone/backups/).
199
192
 
200
193
  Returns:
201
194
  str: Path to the backup file.
@@ -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()
@@ -0,0 +1,236 @@
1
+ """
2
+ Predictive Memory Recall — anticipate what memories you'll need.
3
+
4
+ Inspired by Jonathan Clements' Adaptive Memory Kernel (AMK).
5
+ Instead of waiting for a search query, this module learns access
6
+ patterns and pre-loads the memories most likely to be relevant
7
+ for the current context.
8
+
9
+ The predictor tracks:
10
+ - Which memories are accessed together (co-occurrence)
11
+ - Time-of-day patterns (morning routines vs late-night deep work)
12
+ - Tag affinity (if you access 'cloud9' memories, you probably want 'trust' too)
13
+ - Recency-weighted frequency (recent access patterns matter more)
14
+
15
+ The output is a ranked list of memory IDs to pre-load into context,
16
+ sorted by predicted relevance. This feeds directly into the
17
+ `skmemory context` and `skmemory ritual` commands.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import math
25
+ import time
26
+ from collections import Counter, defaultdict
27
+ from pathlib import Path
28
+
29
+ from pydantic import BaseModel, Field
30
+
31
+ from .config import SKMEMORY_HOME
32
+
33
+ logger = logging.getLogger("skmemory.predictive")
34
+
35
+ DEFAULT_ACCESS_LOG = SKMEMORY_HOME / "access_log.json"
36
+
37
+
38
+ class AccessEvent(BaseModel):
39
+ """A single memory access event for pattern learning."""
40
+
41
+ memory_id: str
42
+ timestamp: float = Field(default_factory=time.time)
43
+ tags: list[str] = Field(default_factory=list)
44
+ layer: str = ""
45
+ context: str = Field(
46
+ default="",
47
+ description="What was happening when this memory was accessed",
48
+ )
49
+
50
+
51
+ class PredictiveRecall:
52
+ """Learns memory access patterns and predicts what you'll need next.
53
+
54
+ Tracks co-occurrence (which memories are accessed together),
55
+ tag affinity, and temporal patterns to generate ranked predictions.
56
+
57
+ Args:
58
+ log_path: Path to the access log JSON file.
59
+ max_events: Maximum events to retain (older events are pruned).
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ log_path: Path | None = None,
65
+ max_events: int = 5000,
66
+ ) -> None:
67
+ self._log_path = log_path or DEFAULT_ACCESS_LOG
68
+ self._max_events = max_events
69
+ self._events: list[AccessEvent] = []
70
+ self._cooccurrence: dict[str, Counter] = defaultdict(Counter)
71
+ self._tag_affinity: dict[str, Counter] = defaultdict(Counter)
72
+ self._frequency: Counter = Counter()
73
+ self._loaded = False
74
+
75
+ def _ensure_loaded(self) -> None:
76
+ """Load the access log from disk if not already loaded."""
77
+ if self._loaded:
78
+ return
79
+ self._loaded = True
80
+
81
+ if not self._log_path.exists():
82
+ return
83
+
84
+ try:
85
+ raw = json.loads(self._log_path.read_text())
86
+ self._events = [AccessEvent(**e) for e in raw]
87
+ self._rebuild_indices()
88
+ except (json.JSONDecodeError, Exception) as exc:
89
+ logger.warning("Failed to load access log: %s", exc)
90
+
91
+ def _rebuild_indices(self) -> None:
92
+ """Rebuild co-occurrence, tag affinity, and frequency indices."""
93
+ self._cooccurrence.clear()
94
+ self._tag_affinity.clear()
95
+ self._frequency.clear()
96
+
97
+ session_window = 300
98
+ sessions: list[list[AccessEvent]] = []
99
+ current_session: list[AccessEvent] = []
100
+
101
+ for event in sorted(self._events, key=lambda e: e.timestamp):
102
+ if (
103
+ current_session
104
+ and (event.timestamp - current_session[-1].timestamp) > session_window
105
+ ):
106
+ sessions.append(current_session)
107
+ current_session = []
108
+ current_session.append(event)
109
+ if current_session:
110
+ sessions.append(current_session)
111
+
112
+ for session in sessions:
113
+ ids_in_session = [e.memory_id for e in session]
114
+ for i, mid in enumerate(ids_in_session):
115
+ self._frequency[mid] += 1
116
+ for other in ids_in_session[i + 1 :]:
117
+ if other != mid:
118
+ self._cooccurrence[mid][other] += 1
119
+ self._cooccurrence[other][mid] += 1
120
+
121
+ for event in self._events:
122
+ for tag in event.tags:
123
+ self._tag_affinity[tag][event.memory_id] += 1
124
+
125
+ def log_access(
126
+ self, memory_id: str, tags: list[str] | None = None, layer: str = "", context: str = ""
127
+ ) -> None:
128
+ """Record a memory access event for pattern learning.
129
+
130
+ Args:
131
+ memory_id: The accessed memory's ID.
132
+ tags: Tags on the accessed memory.
133
+ layer: Memory layer (short-term, mid-term, long-term).
134
+ context: What was happening during access.
135
+ """
136
+ self._ensure_loaded()
137
+
138
+ event = AccessEvent(
139
+ memory_id=memory_id,
140
+ tags=tags or [],
141
+ layer=layer,
142
+ context=context,
143
+ )
144
+ self._events.append(event)
145
+
146
+ self._frequency[memory_id] += 1
147
+ for tag in event.tags:
148
+ self._tag_affinity[tag][memory_id] += 1
149
+
150
+ if len(self._events) > self._max_events:
151
+ self._events = self._events[-self._max_events :]
152
+ self._rebuild_indices()
153
+
154
+ self._save()
155
+
156
+ def predict(
157
+ self,
158
+ recent_ids: list[str] | None = None,
159
+ active_tags: list[str] | None = None,
160
+ limit: int = 10,
161
+ ) -> list[dict]:
162
+ """Predict which memories will be needed next.
163
+
164
+ Uses co-occurrence patterns, tag affinity, and recency-weighted
165
+ frequency to rank memory IDs by predicted relevance.
166
+
167
+ Args:
168
+ recent_ids: Memory IDs accessed in the current session.
169
+ active_tags: Tags active in the current context.
170
+ limit: Maximum predictions to return.
171
+
172
+ Returns:
173
+ list[dict]: Ranked predictions with id, score, and reason.
174
+ """
175
+ self._ensure_loaded()
176
+
177
+ scores: Counter = Counter()
178
+ reasons: dict[str, list[str]] = defaultdict(list)
179
+
180
+ if recent_ids:
181
+ for mid in recent_ids:
182
+ for co_id, count in self._cooccurrence.get(mid, {}).items():
183
+ if co_id not in recent_ids:
184
+ scores[co_id] += count * 2.0
185
+ reasons[co_id].append(f"co-occurs with {mid[:8]}")
186
+
187
+ if active_tags:
188
+ for tag in active_tags:
189
+ for mid, count in self._tag_affinity.get(tag, {}).items():
190
+ if not recent_ids or mid not in recent_ids:
191
+ scores[mid] += count * 1.5
192
+ reasons[mid].append(f"tag affinity: {tag}")
193
+
194
+ now = time.time()
195
+ for mid, freq in self._frequency.items():
196
+ if not recent_ids or mid not in recent_ids:
197
+ last_access = max(
198
+ (e.timestamp for e in self._events if e.memory_id == mid),
199
+ default=0,
200
+ )
201
+ recency = math.exp(-(now - last_access) / 86400) if last_access else 0
202
+ recency_score = freq * recency * 0.5
203
+ if recency_score > 0.1:
204
+ scores[mid] += recency_score
205
+ reasons[mid].append(f"frequency={freq}, recency={recency:.2f}")
206
+
207
+ ranked = scores.most_common(limit)
208
+ return [
209
+ {
210
+ "memory_id": mid,
211
+ "score": round(score, 2),
212
+ "reasons": reasons.get(mid, []),
213
+ }
214
+ for mid, score in ranked
215
+ ]
216
+
217
+ def get_stats(self) -> dict:
218
+ """Return statistics about the prediction engine.
219
+
220
+ Returns:
221
+ dict: Event count, unique memories, top accessed, etc.
222
+ """
223
+ self._ensure_loaded()
224
+ return {
225
+ "total_events": len(self._events),
226
+ "unique_memories": len(self._frequency),
227
+ "top_accessed": self._frequency.most_common(5),
228
+ "unique_tags": len(self._tag_affinity),
229
+ "cooccurrence_pairs": sum(len(v) for v in self._cooccurrence.values()),
230
+ }
231
+
232
+ def _save(self) -> None:
233
+ """Persist the access log to disk."""
234
+ self._log_path.parent.mkdir(parents=True, exist_ok=True)
235
+ data = [e.model_dump() for e in self._events[-self._max_events :]]
236
+ self._log_path.write_text(json.dumps(data, indent=2))