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 +3 -0
- mnemon/cli.py +181 -0
- mnemon/config.py +79 -0
- mnemon/contradiction.py +183 -0
- mnemon/embedder.py +84 -0
- mnemon/hooks/__init__.py +0 -0
- mnemon/hooks/context_surfacing.py +101 -0
- mnemon/hooks/framework.py +114 -0
- mnemon/hooks/handoff_generator.py +149 -0
- mnemon/hooks/session_extractor.py +198 -0
- mnemon/llm.py +113 -0
- mnemon/py.typed +0 -0
- mnemon/search.py +216 -0
- mnemon/server.py +324 -0
- mnemon/server_remote.py +32 -0
- mnemon/setup.py +167 -0
- mnemon/store.py +507 -0
- mnemon/sync.py +125 -0
- mnemon/vecstore.py +115 -0
- mnemon_memory-0.2.0.dist-info/METADATA +265 -0
- mnemon_memory-0.2.0.dist-info/RECORD +24 -0
- mnemon_memory-0.2.0.dist-info/WHEEL +4 -0
- mnemon_memory-0.2.0.dist-info/entry_points.txt +2 -0
- mnemon_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
mnemon/__init__.py
ADDED
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"
|
mnemon/contradiction.py
ADDED
|
@@ -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
|
mnemon/hooks/__init__.py
ADDED
|
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()
|