mnemon-memory 0.2.0__py3-none-any.whl

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.
mnemon/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mnemon — Universal long-term memory layer for AI agents via MCP."""
2
+
3
+ __version__ = "0.2.0"
mnemon/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """CLI entry point for mnemon.
2
+
3
+ Usage:
4
+ mnemon serve Start MCP server (stdio transport)
5
+ mnemon serve-remote Start HTTP server (Streamable HTTP)
6
+ mnemon status Show vault health stats
7
+ mnemon search <query> Search memories
8
+ mnemon save <title> <content> Save a memory
9
+ mnemon setup <target> Configure integration (claude-code, cursor, gemini, hooks)
10
+ mnemon sync push Push vault to S3
11
+ mnemon sync pull Pull vault from S3
12
+ mnemon --version Show version
13
+ mnemon --help Show this help
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from . import __version__
21
+
22
+
23
+ def main() -> None:
24
+ args = sys.argv[1:]
25
+ command = args[0] if args else "--help"
26
+
27
+ if command in ("--version", "-v"):
28
+ print(f"mnemon v{__version__}")
29
+ return
30
+
31
+ if command in ("--help", "-h"):
32
+ _print_usage()
33
+ return
34
+
35
+ if command == "serve":
36
+ from .server import run_stdio
37
+ run_stdio()
38
+
39
+ elif command == "serve-remote":
40
+ from .server_remote import run_remote
41
+ run_remote()
42
+
43
+ elif command == "status":
44
+ from .store import Store
45
+ store = Store()
46
+ stats = store.status()
47
+ print(f"Vault: {stats['vault_path']}")
48
+ print(f"Total memories: {stats['total_documents']}")
49
+ print(f"Vectors: {stats['total_vectors']}")
50
+ print(f"Pinned: {stats['pinned']}")
51
+ print(f"Invalidated: {stats['invalidated']}")
52
+ print("\nBy type:")
53
+ for t in stats["by_type"]:
54
+ print(f" {t['content_type']}: {t['count']}")
55
+ store.close()
56
+
57
+ elif command == "search":
58
+ query = " ".join(args[1:])
59
+ if not query:
60
+ print("Usage: mnemon search <query>", file=sys.stderr)
61
+ sys.exit(1)
62
+ from .search import search
63
+ from .store import Store
64
+ store = Store()
65
+ results = search(store, query, limit=10)
66
+ if not results:
67
+ print("No memories found.")
68
+ else:
69
+ for r in results:
70
+ snippet = r.content[:200]
71
+ ellipsis = "..." if len(r.content) > 200 else ""
72
+ print(f"[{r.content_type}] {r.title} (score: {r.composite_score:.3f})")
73
+ print(f" {snippet}{ellipsis}")
74
+ print()
75
+ store.close()
76
+
77
+ elif command == "save":
78
+ if len(args) < 3:
79
+ print("Usage: mnemon save <title> <content>", file=sys.stderr)
80
+ sys.exit(1)
81
+ title = args[1]
82
+ content = " ".join(args[2:])
83
+ from .store import Store
84
+ store = Store()
85
+ doc_id = store.save(title=title, content=content, source_client="cli")
86
+ print(f'Saved memory #{doc_id}: "{title}"')
87
+ store.close()
88
+
89
+ elif command == "forget":
90
+ if len(args) < 2 or not args[1].isdigit():
91
+ print("Usage: mnemon forget <id>", file=sys.stderr)
92
+ sys.exit(1)
93
+ doc_id = int(args[1])
94
+ from .store import Store
95
+ store = Store()
96
+ if store.forget(doc_id):
97
+ print(f"Forgot memory #{doc_id}.")
98
+ else:
99
+ print(f"Memory #{doc_id} not found or already forgotten.", file=sys.stderr)
100
+ sys.exit(1)
101
+ store.close()
102
+
103
+ elif command == "sync":
104
+ subcommand = args[1] if len(args) > 1 else ""
105
+ if subcommand == "push":
106
+ from .sync import push
107
+ result = push()
108
+ if result["pushed"]:
109
+ print("Pushed:")
110
+ for p in result["pushed"]:
111
+ print(f" {p}")
112
+ if result["errors"]:
113
+ print("Errors:", file=sys.stderr)
114
+ for e in result["errors"]:
115
+ print(f" {e}", file=sys.stderr)
116
+ sys.exit(1)
117
+ if not result["pushed"] and not result["errors"]:
118
+ print("No vault files found to push.")
119
+ elif subcommand == "pull":
120
+ from .sync import pull
121
+ result = pull()
122
+ if result["pulled"]:
123
+ print("Pulled:")
124
+ for p in result["pulled"]:
125
+ print(f" {p}")
126
+ if result["errors"]:
127
+ print("Errors:", file=sys.stderr)
128
+ for e in result["errors"]:
129
+ print(f" {e}", file=sys.stderr)
130
+ sys.exit(1)
131
+ if not result["pulled"] and not result["errors"]:
132
+ print("No vault files found on S3.")
133
+ else:
134
+ print("Usage: mnemon sync <push|pull>", file=sys.stderr)
135
+ print("\nEnv vars:")
136
+ print(" MNEMON_S3_BUCKET S3 bucket name (required)")
137
+ print(" MNEMON_S3_PREFIX S3 key prefix (default: mnemon/vaults)")
138
+ print(" MNEMON_VAULT_NAME vault name (default: default)")
139
+ sys.exit(1)
140
+
141
+ elif command == "setup":
142
+ target = args[1] if len(args) > 1 else ""
143
+ if not target:
144
+ print("Usage: mnemon setup <claude-code|cursor|gemini|hooks>", file=sys.stderr)
145
+ sys.exit(1)
146
+ from .setup import run_setup
147
+ print(run_setup(target))
148
+
149
+ else:
150
+ print(f"Unknown command: {command}", file=sys.stderr)
151
+ _print_usage()
152
+ sys.exit(1)
153
+
154
+
155
+ def _print_usage() -> None:
156
+ print(f"""mnemon v{__version__} — Universal long-term memory for AI agents
157
+
158
+ Usage:
159
+ mnemon serve Start MCP server (stdio transport)
160
+ mnemon serve-remote Start HTTP server (Streamable HTTP)
161
+ mnemon status Show vault health stats
162
+ mnemon search <query> Search memories
163
+ mnemon save <title> <c> Save a memory
164
+ mnemon forget <id> Soft-delete a memory
165
+ mnemon setup <target> Configure integration (claude-code, cursor, gemini, hooks)
166
+ mnemon sync push Push vault to S3
167
+ mnemon sync pull Pull vault from S3
168
+ mnemon --version Show version
169
+ mnemon --help Show this help
170
+
171
+ Env vars:
172
+ MNEMON_VAULT_DIR Vault directory (default: ~/.mnemon)
173
+ MNEMON_TOKEN Bearer token for remote server auth
174
+ MNEMON_S3_BUCKET S3 bucket for vault sync
175
+ PORT Remote server port (default: 8502)
176
+
177
+ Docs: https://github.com/cipher813/mnemon""")
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
mnemon/config.py ADDED
@@ -0,0 +1,79 @@
1
+ """Configuration — content types, vault paths, scoring constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+
10
+ class ContentType(str, Enum):
11
+ DECISION = "decision"
12
+ PREFERENCE = "preference"
13
+ ANTIPATTERN = "antipattern"
14
+ OBSERVATION = "observation"
15
+ RESEARCH = "research"
16
+ PROJECT = "project"
17
+ HANDOFF = "handoff"
18
+ NOTE = "note"
19
+
20
+
21
+ class MemoryType(str, Enum):
22
+ EPISODIC = "episodic"
23
+ SEMANTIC = "semantic"
24
+ PROCEDURAL = "procedural"
25
+
26
+
27
+ # Content type → memory type mapping
28
+ MEMORY_TYPE_MAP: dict[ContentType, MemoryType] = {
29
+ ContentType.DECISION: MemoryType.SEMANTIC,
30
+ ContentType.PREFERENCE: MemoryType.SEMANTIC,
31
+ ContentType.ANTIPATTERN: MemoryType.SEMANTIC,
32
+ ContentType.OBSERVATION: MemoryType.SEMANTIC,
33
+ ContentType.RESEARCH: MemoryType.SEMANTIC,
34
+ ContentType.PROJECT: MemoryType.SEMANTIC,
35
+ ContentType.HANDOFF: MemoryType.EPISODIC,
36
+ ContentType.NOTE: MemoryType.SEMANTIC,
37
+ }
38
+
39
+ # Half-lives in days (None = never decay)
40
+ HALF_LIVES: dict[ContentType, int | None] = {
41
+ ContentType.DECISION: None,
42
+ ContentType.PREFERENCE: None,
43
+ ContentType.ANTIPATTERN: None,
44
+ ContentType.OBSERVATION: 90,
45
+ ContentType.RESEARCH: 90,
46
+ ContentType.PROJECT: 120,
47
+ ContentType.HANDOFF: 30,
48
+ ContentType.NOTE: 60,
49
+ }
50
+
51
+ # Default confidence per content type
52
+ DEFAULT_CONFIDENCE: dict[ContentType, float] = {
53
+ ContentType.DECISION: 0.85,
54
+ ContentType.PREFERENCE: 0.80,
55
+ ContentType.ANTIPATTERN: 0.80,
56
+ ContentType.OBSERVATION: 0.70,
57
+ ContentType.RESEARCH: 0.70,
58
+ ContentType.PROJECT: 0.65,
59
+ ContentType.HANDOFF: 0.60,
60
+ ContentType.NOTE: 0.50,
61
+ }
62
+
63
+ # Scoring constants
64
+ RRF_K = 60
65
+ MMR_THRESHOLD = 0.6
66
+ COMPOSITE_WEIGHTS = (0.5, 0.25, 0.25) # (relevance, recency, confidence)
67
+ RECENCY_HALF_LIFE_DAYS = 30
68
+ PIN_BOOST = 0.3
69
+
70
+ # Content type enum values for validation
71
+ CONTENT_TYPE_VALUES = [ct.value for ct in ContentType]
72
+
73
+
74
+ def vault_dir() -> Path:
75
+ return Path(os.environ.get("MNEMON_VAULT_DIR", Path.home() / ".mnemon"))
76
+
77
+
78
+ def vault_path() -> Path:
79
+ return vault_dir() / "default.sqlite"
@@ -0,0 +1,183 @@
1
+ """Contradiction detection — finds and resolves conflicting memories.
2
+
3
+ When a new memory is saved, searches for existing memories on the same
4
+ topic. Uses the local LLM to classify the relationship:
5
+ - same: identical fact, no action (adds "related" relation)
6
+ - update: new supersedes old, decay old confidence
7
+ - contradiction: direct conflict, decay old confidence more aggressively
8
+ - unrelated: different topics, no action
9
+
10
+ Also provides time-based confidence decay with access reinforcement.
11
+
12
+ Phase 3: LLM-based contradiction detection + confidence decay.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ from .store import SearchResult, Store
22
+
23
+ OVERLAP_THRESHOLD = 0.7 # minimum vector similarity to consider overlapping
24
+ UPDATE_DECAY = 0.15 # confidence reduction for superseded memories
25
+ CONTRADICTION_DECAY = 0.25
26
+ CONFIDENCE_FLOOR = 0.2
27
+
28
+ CLASSIFY_SYSTEM_PROMPT = (
29
+ "You classify the relationship between two memories. "
30
+ "Given an existing memory and a new memory, respond with exactly one word:\n\n"
31
+ "- same: they express the same fact or decision\n"
32
+ "- update: the new memory supersedes or refines the old one\n"
33
+ "- contradiction: they directly conflict\n"
34
+ "- unrelated: different topics\n\n"
35
+ "Respond with ONLY the classification word, nothing else."
36
+ )
37
+
38
+ VALID_CLASSIFICATIONS = {"same", "update", "contradiction", "unrelated"}
39
+
40
+
41
+ def check_contradictions(
42
+ store: "Store",
43
+ new_title: str,
44
+ new_content: str,
45
+ new_doc_id: int,
46
+ ) -> dict:
47
+ """Check a new memory against existing memories for contradictions.
48
+
49
+ Returns {decayed: int, relationships: [{doc_id, title, relationship}]}.
50
+ """
51
+ relationships: list[dict] = []
52
+ decayed = 0
53
+
54
+ # Find overlapping memories via vector similarity
55
+ try:
56
+ from .embedder import embed
57
+ query_emb = embed(f"title: {new_title} | text: {new_content}")
58
+ overlapping = store.search_vector(query_emb, 5)
59
+ except Exception:
60
+ return {"decayed": 0, "relationships": []}
61
+
62
+ # Filter to genuinely overlapping results (exclude self)
63
+ candidates = [
64
+ r for r in overlapping
65
+ if r.doc_id != new_doc_id and r.score >= OVERLAP_THRESHOLD
66
+ ]
67
+
68
+ if not candidates:
69
+ return {"decayed": 0, "relationships": []}
70
+
71
+ # Classify each relationship via LLM
72
+ try:
73
+ from .llm import generate
74
+ except ImportError:
75
+ return {"decayed": 0, "relationships": []}
76
+
77
+ for candidate in candidates:
78
+ try:
79
+ prompt = (
80
+ f"Existing memory:\nTitle: {candidate.title}\n"
81
+ f"Content: {candidate.content[:500]}\n\n"
82
+ f"New memory:\nTitle: {new_title}\n"
83
+ f"Content: {new_content[:500]}"
84
+ )
85
+
86
+ response = generate(CLASSIFY_SYSTEM_PROMPT, prompt, max_tokens=10)
87
+ classification = response.strip().lower()
88
+
89
+ if classification not in VALID_CLASSIFICATIONS:
90
+ continue
91
+
92
+ relationships.append({
93
+ "doc_id": candidate.doc_id,
94
+ "title": candidate.title,
95
+ "relationship": classification,
96
+ })
97
+
98
+ # Apply confidence decay
99
+ if classification == "update":
100
+ doc = store.get(candidate.doc_id)
101
+ if doc:
102
+ new_confidence = max(CONFIDENCE_FLOOR, doc.confidence - UPDATE_DECAY)
103
+ store.db.execute(
104
+ "UPDATE documents SET confidence = ?, updated_at = datetime('now') WHERE id = ?",
105
+ (new_confidence, candidate.doc_id),
106
+ )
107
+ store.db.commit()
108
+ store.add_relation(new_doc_id, candidate.doc_id, "supersedes", 0.8)
109
+ decayed += 1
110
+
111
+ elif classification == "contradiction":
112
+ doc = store.get(candidate.doc_id)
113
+ if doc:
114
+ new_confidence = max(CONFIDENCE_FLOOR, doc.confidence - CONTRADICTION_DECAY)
115
+ store.db.execute(
116
+ "UPDATE documents SET confidence = ?, updated_at = datetime('now') WHERE id = ?",
117
+ (new_confidence, candidate.doc_id),
118
+ )
119
+ store.db.commit()
120
+ store.add_relation(new_doc_id, candidate.doc_id, "contradicts", 0.9)
121
+ decayed += 1
122
+
123
+ elif classification == "same":
124
+ store.add_relation(new_doc_id, candidate.doc_id, "related", 1.0)
125
+
126
+ except Exception:
127
+ continue
128
+
129
+ return {"decayed": decayed, "relationships": relationships}
130
+
131
+
132
+ # ── Confidence Decay ────────────────────────────────────────────────────────
133
+
134
+ from .config import DEFAULT_CONFIDENCE, HALF_LIVES # noqa: E402
135
+
136
+
137
+ def apply_confidence_decay(store: "Store") -> int:
138
+ """Apply time-based confidence decay to all documents.
139
+
140
+ Documents with access activity decay slower (access reinforcement).
141
+ Each access extends the effective half-life by 10%, up to 3x.
142
+
143
+ Returns the number of documents whose confidence was updated.
144
+ """
145
+ updated = 0
146
+
147
+ for content_type, half_life in HALF_LIVES.items():
148
+ if half_life is None:
149
+ continue
150
+
151
+ rows = store.db.execute(
152
+ """SELECT id, confidence, access_count, pinned,
153
+ CAST(julianday('now') - julianday(updated_at) AS REAL) AS age_days
154
+ FROM documents
155
+ WHERE content_type = ?
156
+ AND invalidated_at IS NULL
157
+ AND pinned = 0""",
158
+ (content_type.value,),
159
+ ).fetchall()
160
+
161
+ for row in rows:
162
+ # Access reinforcement: each access extends effective half-life
163
+ # More accesses = slower decay (up to 3x half-life extension)
164
+ access_multiplier = min(3.0, 1.0 + row["access_count"] * 0.1)
165
+ effective_half_life = half_life * access_multiplier
166
+
167
+ # Exponential decay: base_confidence * 2^(-age/halflife)
168
+ decay_factor = math.pow(2, -row["age_days"] / effective_half_life)
169
+ base_confidence = DEFAULT_CONFIDENCE.get(content_type, 0.5)
170
+ decayed_confidence = max(CONFIDENCE_FLOOR, base_confidence * decay_factor)
171
+
172
+ # Only update if confidence changed meaningfully
173
+ if abs(decayed_confidence - row["confidence"]) > 0.01:
174
+ store.db.execute(
175
+ "UPDATE documents SET confidence = ? WHERE id = ?",
176
+ (decayed_confidence, row["id"]),
177
+ )
178
+ updated += 1
179
+
180
+ if updated > 0:
181
+ store.db.commit()
182
+
183
+ return updated
mnemon/embedder.py ADDED
@@ -0,0 +1,84 @@
1
+ """Embedding pipeline — FastEmbed with bge-small-en-v1.5 (ONNX).
2
+
3
+ 384-dimensional embeddings via ONNX Runtime. ~13MB model, auto-downloaded.
4
+ No PyTorch dependency needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ import numpy as np
14
+ from .store import Store
15
+
16
+ VECTOR_DIM = 384
17
+ _MODEL_NAME = "BAAI/bge-small-en-v1.5"
18
+
19
+ _model = None
20
+
21
+
22
+ def _get_model():
23
+ """Lazy-load the embedding model (singleton)."""
24
+ global _model
25
+ if _model is None:
26
+ from fastembed import TextEmbedding
27
+ _model = TextEmbedding(model_name=_MODEL_NAME)
28
+ return _model
29
+
30
+
31
+ def embed(text: str) -> "np.ndarray":
32
+ """Embed a single text string. Returns ndarray of shape (384,)."""
33
+ import numpy as np
34
+ model = _get_model()
35
+ result = list(model.embed([text]))
36
+ return np.asarray(result[0], dtype=np.float32)
37
+
38
+
39
+ def embed_batch(texts: list[str]) -> list["np.ndarray"]:
40
+ """Embed multiple texts."""
41
+ import numpy as np
42
+ model = _get_model()
43
+ results = list(model.embed(texts))
44
+ return [np.asarray(r, dtype=np.float32) for r in results]
45
+
46
+
47
+ def fragmentize(title: str, content: str) -> list[dict]:
48
+ """Split a document into fragments for embedding.
49
+
50
+ Returns list of {seq, text} dicts:
51
+ seq=0: full document (title + content, truncated to 2000 chars)
52
+ seq=1-5: individual sections split by markdown headers or double newlines
53
+ """
54
+ fragments = []
55
+
56
+ # seq=0: full document
57
+ full_text = f"title: {title} | text: {content}"[:2000]
58
+ fragments.append({"seq": 0, "text": full_text})
59
+
60
+ # Split by markdown headers or double newlines
61
+ sections = re.split(r"(?=^#{1,3}\s)", content, flags=re.MULTILINE)
62
+ sections = [s.strip() for s in sections if len(s.strip()) > 50]
63
+
64
+ for i, section in enumerate(sections[:5]):
65
+ fragments.append({
66
+ "seq": i + 1,
67
+ "text": f"title: {title} | section: {section[:1000]}",
68
+ })
69
+
70
+ return fragments
71
+
72
+
73
+ def embed_document(store: "Store", content_hash: str, title: str, content: str) -> int:
74
+ """Embed and store all fragments for a document. Returns fragment count."""
75
+ fragments = fragmentize(title, content)
76
+ count = 0
77
+
78
+ for frag in fragments:
79
+ emb = embed(frag["text"])
80
+ store.save_embedding(content_hash, frag["seq"], emb)
81
+ count += 1
82
+
83
+ store.flush_vectors()
84
+ return count
File without changes
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """Context surfacing hook — UserPromptSubmit.
3
+
4
+ Searches the vault for relevant memories and injects them as
5
+ XML context before Claude processes the prompt.
6
+
7
+ Pipeline:
8
+ 1. Skip noise (slash commands, greetings, short prompts, duplicates)
9
+ 2. BM25 + vector search
10
+ 3. Composite scoring (relevance + recency + confidence)
11
+ 4. Tiered injection (HOT/WARM/COLD) within 800 token budget
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+
18
+ TOKEN_BUDGET = 800
19
+ CHARS_PER_TOKEN = 4
20
+ CHAR_BUDGET = TOKEN_BUDGET * CHARS_PER_TOKEN
21
+
22
+ HOT_THRESHOLD = 0.15
23
+ WARM_THRESHOLD = 0.10
24
+ HOT_SNIPPET_LEN = 300
25
+ WARM_SNIPPET_LEN = 150
26
+
27
+
28
+ def build_context(results: list) -> str:
29
+ if not results:
30
+ return ""
31
+
32
+ lines: list[str] = []
33
+ chars_used = 0
34
+
35
+ for r in results:
36
+ if r.composite_score >= HOT_THRESHOLD:
37
+ snippet = r.content[:HOT_SNIPPET_LEN].replace("\n", " ")
38
+ ellipsis = "..." if len(r.content) > HOT_SNIPPET_LEN else ""
39
+ entry = f"[{r.content_type}] {r.title}: {snippet}{ellipsis}"
40
+ elif r.composite_score >= WARM_THRESHOLD:
41
+ snippet = r.content[:WARM_SNIPPET_LEN].replace("\n", " ")
42
+ entry = f"[{r.content_type}] {r.title}: {snippet}..."
43
+ else:
44
+ entry = f"[{r.content_type}] {r.title}"
45
+
46
+ if chars_used + len(entry) > CHAR_BUDGET:
47
+ break
48
+
49
+ lines.append(entry)
50
+ chars_used += len(entry)
51
+
52
+ if not lines:
53
+ return ""
54
+
55
+ return (
56
+ "<mnemon-context>\n"
57
+ "Relevant memories from previous sessions:\n"
58
+ + "\n".join(lines)
59
+ + "\n</mnemon-context>"
60
+ )
61
+
62
+
63
+ def main() -> None:
64
+ try:
65
+ from .framework import read_stdin, write_output, is_noise, is_duplicate
66
+
67
+ hook_input = read_stdin()
68
+ prompt = hook_input.get("prompt", "")
69
+
70
+ if is_noise(prompt):
71
+ return
72
+ if is_duplicate(prompt):
73
+ return
74
+
75
+ from ..store import Store
76
+ from ..search import search
77
+
78
+ store = Store()
79
+ try:
80
+ results = search(store, prompt, limit=8, use_vector=True)
81
+ if not results:
82
+ return
83
+
84
+ context = build_context(results)
85
+ if not context:
86
+ return
87
+
88
+ write_output({
89
+ "hookSpecificOutput": {
90
+ "hookEventName": "UserPromptSubmit",
91
+ "additionalContext": context,
92
+ },
93
+ })
94
+ finally:
95
+ store.close()
96
+ except Exception as e:
97
+ print(f"mnemon context-surfacing error: {e}", file=sys.stderr)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()