@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.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
package/skmemory/models.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
|
package/skmemory/openclaw.py
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
53
|
+
if skvector_url:
|
|
57
54
|
try:
|
|
58
|
-
from .backends.
|
|
59
|
-
|
|
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:
|
|
98
|
+
tags: list[str] | None = None,
|
|
101
99
|
intensity: float = 0.0,
|
|
102
100
|
valence: float = 0.0,
|
|
103
|
-
emotions:
|
|
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) ->
|
|
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:
|
|
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: ~/.
|
|
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))
|