semantic-memory-mcp 1.0.2__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.
- memory_server.py +1147 -0
- semantic_memory_mcp-1.0.2.dist-info/METADATA +135 -0
- semantic_memory_mcp-1.0.2.dist-info/RECORD +7 -0
- semantic_memory_mcp-1.0.2.dist-info/WHEEL +5 -0
- semantic_memory_mcp-1.0.2.dist-info/entry_points.txt +2 -0
- semantic_memory_mcp-1.0.2.dist-info/licenses/LICENSE +21 -0
- semantic_memory_mcp-1.0.2.dist-info/top_level.txt +1 -0
memory_server.py
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Memory MCP Server — ONNX + SQLite (sqlite-vec + FTS5) + FastMCP
|
|
4
|
+
|
|
5
|
+
Hybrid search (vector + full-text) via Reciprocal Rank Fusion (RRF).
|
|
6
|
+
Multilingual embeddings via Xenova/multilingual-e5-small (quantized ONNX).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import socket
|
|
15
|
+
import sqlite3
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from contextlib import closing
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import onnxruntime as ort
|
|
26
|
+
import sqlite_vec
|
|
27
|
+
from fastmcp import FastMCP
|
|
28
|
+
from tokenizers import Tokenizer
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Paths
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
MODEL_DIR = Path(os.environ.get(
|
|
34
|
+
"MEMORY_MODEL_DIR", str(Path.home() / ".qwen" / "models" / "multilingual-e5-small")))
|
|
35
|
+
MODEL_PATH = MODEL_DIR / "model_quantized.onnx"
|
|
36
|
+
TOKENIZER_PATH = MODEL_DIR / "tokenizer.json"
|
|
37
|
+
DB_PATH = Path(os.environ.get("MEMORY_DB_PATH", str(Path.home() / ".qwen" / "memory.db")))
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Zotero sync configuration (memory_zotero_sync)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
ZOTERO_SYNC_CONFIG = Path(os.environ.get(
|
|
43
|
+
"ZOTERO_SYNC_CONFIG", str(Path.home() / ".qwen" / "zotero-sync.json")))
|
|
44
|
+
ZOTERO_SYNC_KEY_DEFAULT = Path.home() / ".qwen" / "zotero-api-key"
|
|
45
|
+
ZOTERO_SYNC_INTERVAL_H = float(os.environ.get("ZOTERO_SYNC_INTERVAL_H", "6"))
|
|
46
|
+
ZOTERO_SYNC_LAST_FILE = DB_PATH.parent / ".zotero-sync-last"
|
|
47
|
+
ZOTERO_SYNC_PREFIX = "KI-Gedächtnis-Backup"
|
|
48
|
+
ZOTERO_SYNC_PROTECT_THRESHOLD = 5
|
|
49
|
+
ZOTERO_SYNC_CHUNK = 49
|
|
50
|
+
_sync_state = {"running": False, "last_error": None, "last_result": None}
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Embedding helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
_ort_session: ort.InferenceSession | None = None
|
|
56
|
+
_tokenizer: Tokenizer | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _download_model():
|
|
60
|
+
"""Download the embedding model on first use (mirrors setup.sh).
|
|
61
|
+
|
|
62
|
+
Only runs when the model files are missing — installations created by
|
|
63
|
+
setup.sh already have them, so this is a no-op there. The ONNX file sits
|
|
64
|
+
in the repo under onnx/ but the server expects it flat next to
|
|
65
|
+
tokenizer.json, hence the explicit mapping.
|
|
66
|
+
"""
|
|
67
|
+
import shutil
|
|
68
|
+
from huggingface_hub import hf_hub_download
|
|
69
|
+
|
|
70
|
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
mapping = {
|
|
72
|
+
"onnx/model_quantized.onnx": "model_quantized.onnx",
|
|
73
|
+
"tokenizer.json": "tokenizer.json",
|
|
74
|
+
"config.json": "config.json",
|
|
75
|
+
"tokenizer_config.json": "tokenizer_config.json",
|
|
76
|
+
"special_tokens_map.json": "special_tokens_map.json",
|
|
77
|
+
}
|
|
78
|
+
for repo_path, dest_name in mapping.items():
|
|
79
|
+
dest = MODEL_DIR / dest_name
|
|
80
|
+
if dest.exists():
|
|
81
|
+
continue
|
|
82
|
+
src = hf_hub_download("Xenova/multilingual-e5-small", repo_path)
|
|
83
|
+
shutil.copyfile(src, dest)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ensure_embedder():
|
|
87
|
+
global _ort_session, _tokenizer
|
|
88
|
+
if _ort_session is None:
|
|
89
|
+
if not MODEL_PATH.exists() or not TOKENIZER_PATH.exists():
|
|
90
|
+
_download_model()
|
|
91
|
+
_ort_session = ort.InferenceSession(str(MODEL_PATH))
|
|
92
|
+
if _tokenizer is None:
|
|
93
|
+
_tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
|
|
94
|
+
# E5 accepts at most 512 tokens; longer input crashes ONNX inference
|
|
95
|
+
_tokenizer.enable_truncation(max_length=512)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _embed(text: str, *, prefix: str = "passage") -> list[float]:
|
|
99
|
+
"""Compute a normalised 384-d embedding for *text*."""
|
|
100
|
+
_ensure_embedder()
|
|
101
|
+
input_text = f"{prefix}: {text}"
|
|
102
|
+
encoding = _tokenizer.encode(input_text)
|
|
103
|
+
input_ids = np.array([encoding.ids], dtype=np.int64)
|
|
104
|
+
attention_mask = np.array([encoding.attention_mask], dtype=np.int64)
|
|
105
|
+
token_type_ids = np.zeros_like(input_ids)
|
|
106
|
+
|
|
107
|
+
(last_hidden_state,) = _ort_session.run(
|
|
108
|
+
None,
|
|
109
|
+
{
|
|
110
|
+
"input_ids": input_ids,
|
|
111
|
+
"attention_mask": attention_mask,
|
|
112
|
+
"token_type_ids": token_type_ids,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Mean pooling
|
|
117
|
+
mask = attention_mask.astype(float).reshape(-1, 1)
|
|
118
|
+
pooled = np.sum(last_hidden_state[0] * mask, axis=0) / np.sum(mask, axis=0)
|
|
119
|
+
|
|
120
|
+
# L2 normalise
|
|
121
|
+
norm = np.sqrt(np.sum(pooled**2))
|
|
122
|
+
if norm > 0:
|
|
123
|
+
pooled = pooled / norm
|
|
124
|
+
return pooled.tolist()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Chunked embedding — E5 accepts at most 512 tokens, so notes longer than
|
|
129
|
+
# CHUNK_MAX_TOKENS are embedded as consecutive chunks; the vector search
|
|
130
|
+
# aggregates chunk hits per note (minimum distance).
|
|
131
|
+
# CHUNK_OVERLAP_TOKENS = 0 since 19.9.2026 (Phase 3): overlap removed — no
|
|
132
|
+
# recall benefit in the literature (SeCom) and it inflated the index; the
|
|
133
|
+
# pre-change state is recoverable by setting the constant back to 50.
|
|
134
|
+
CHUNK_MAX_TOKENS = 450
|
|
135
|
+
CHUNK_OVERLAP_TOKENS = 0
|
|
136
|
+
|
|
137
|
+
# Hybrid-search weights, env-tunable since 20.9.2026 (Phase 3 grid):
|
|
138
|
+
# - FTS_KEY_WEIGHT: bm25 weight of the key column (fact-key boost).
|
|
139
|
+
# - FTS_ARM_WEIGHT: relative weight of the keyword arm in the RRF fusion
|
|
140
|
+
# (vector arm = 1.0). Grid 20.9.2026 (3 baskets, 126 queries): equal
|
|
141
|
+
# arms let lexical double-hits outvote vector-only hits on
|
|
142
|
+
# vocabulary-gap queries; 0.85 recovers all four Runde-3 RRF losses
|
|
143
|
+
# (R@5 68.4->76.3 %) and lifts Runde 2 to its best values (85.7 %),
|
|
144
|
+
# at the cost of one lexical-only basket-1 hit (97.8->95.7 %, MRR
|
|
145
|
+
# -0.003). Plateau 0.85-0.55 (robust); K (60) has no effect.
|
|
146
|
+
FTS_KEY_WEIGHT = float(os.environ.get("MEMORY_FTS_KEY_WEIGHT", "3.0"))
|
|
147
|
+
RRF_K = int(os.environ.get("MEMORY_RRF_K", "60"))
|
|
148
|
+
FTS_ARM_WEIGHT = float(os.environ.get("MEMORY_FTS_ARM_WEIGHT", "0.85"))
|
|
149
|
+
|
|
150
|
+
# Query expansion (20.9.2026): everyday-word -> domain-term bridges loaded
|
|
151
|
+
# from synonyms.json. At search time, words matching a bridge append their
|
|
152
|
+
# expansion terms to BOTH the FTS terms and the embedding query text, so
|
|
153
|
+
# vocabulary gaps ("Tresor" vs KeepassXC) stop hiding results. Terms already
|
|
154
|
+
# present in the query are never duplicated. MEMORY_SYNONYMS=0 or a missing
|
|
155
|
+
# file disables expansion entirely.
|
|
156
|
+
SYNONYMS_PATH = os.environ.get(
|
|
157
|
+
"MEMORY_SYNONYMS_PATH", str(Path.home() / ".qwen-memory" / "synonyms.json"))
|
|
158
|
+
SYNONYMS_ENABLED = os.environ.get("MEMORY_SYNONYMS", "1") != "0"
|
|
159
|
+
|
|
160
|
+
_synonym_map: dict[str, list[str]] | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _synonym_expansion(query: str) -> list[str]:
|
|
164
|
+
global _synonym_map
|
|
165
|
+
if not SYNONYMS_ENABLED:
|
|
166
|
+
return []
|
|
167
|
+
if _synonym_map is None:
|
|
168
|
+
_synonym_map = {}
|
|
169
|
+
try:
|
|
170
|
+
with open(SYNONYMS_PATH, encoding="utf-8") as f:
|
|
171
|
+
for group in json.load(f).get("groups", []):
|
|
172
|
+
for m in group.get("match", []):
|
|
173
|
+
_synonym_map[m.lower()] = list(group.get("expand", []))
|
|
174
|
+
except (OSError, ValueError):
|
|
175
|
+
_synonym_map = {}
|
|
176
|
+
expand: list[str] = []
|
|
177
|
+
# Match-Wörter gegen Randzeichen bereinigen ("anzulegen?" am Satzende wäre sonst unsichtbar)
|
|
178
|
+
_norm = lambda w: w.strip(".,;:!?\"'„“()[]–—").lower()
|
|
179
|
+
seen: set[str] = {_norm(w) for w in query.split()}
|
|
180
|
+
for w in query.split():
|
|
181
|
+
for t in _synonym_map.get(_norm(w), []):
|
|
182
|
+
if t.lower() not in seen:
|
|
183
|
+
seen.add(t.lower())
|
|
184
|
+
expand.append(t)
|
|
185
|
+
return expand
|
|
186
|
+
|
|
187
|
+
_count_tokenizer: Tokenizer | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _count_tokenizer_() -> Tokenizer:
|
|
191
|
+
global _count_tokenizer
|
|
192
|
+
if _count_tokenizer is None:
|
|
193
|
+
_count_tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
|
|
194
|
+
return _count_tokenizer
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _count_tokens(text: str) -> int:
|
|
198
|
+
return len(_count_tokenizer_().encode(text).ids)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _split_segments(text: str) -> list[str]:
|
|
202
|
+
"""Split at blank lines; a markdown heading is glued to the paragraph
|
|
203
|
+
that follows it so a section never starts without its heading."""
|
|
204
|
+
segments: list[str] = []
|
|
205
|
+
pending_heading = ""
|
|
206
|
+
for raw in text.split("\n\n"):
|
|
207
|
+
seg = raw.strip()
|
|
208
|
+
if not seg:
|
|
209
|
+
continue
|
|
210
|
+
if seg.startswith("#"):
|
|
211
|
+
pending_heading = f"{pending_heading}\n\n{seg}" if pending_heading else seg
|
|
212
|
+
elif pending_heading:
|
|
213
|
+
segments.append(f"{pending_heading}\n\n{seg}")
|
|
214
|
+
pending_heading = ""
|
|
215
|
+
else:
|
|
216
|
+
segments.append(seg)
|
|
217
|
+
if pending_heading:
|
|
218
|
+
segments.append(pending_heading)
|
|
219
|
+
return segments
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _chunk_text(text: str) -> list[str]:
|
|
223
|
+
if _count_tokens(text) <= CHUNK_MAX_TOKENS:
|
|
224
|
+
return [text]
|
|
225
|
+
|
|
226
|
+
chunks: list[str] = []
|
|
227
|
+
current: list[str] = []
|
|
228
|
+
current_tokens = 0
|
|
229
|
+
for seg in _split_segments(text):
|
|
230
|
+
seg_tokens = _count_tokens(seg)
|
|
231
|
+
if seg_tokens > CHUNK_MAX_TOKENS:
|
|
232
|
+
if current:
|
|
233
|
+
chunks.append("\n\n".join(current))
|
|
234
|
+
current, current_tokens = [], 0
|
|
235
|
+
# oversized single segment: non-overlapping token windows
|
|
236
|
+
ids = _count_tokenizer_().encode(seg).ids
|
|
237
|
+
step = CHUNK_MAX_TOKENS - CHUNK_OVERLAP_TOKENS
|
|
238
|
+
start = 0
|
|
239
|
+
while start < len(ids):
|
|
240
|
+
window = ids[start : start + CHUNK_MAX_TOKENS]
|
|
241
|
+
chunks.append(_count_tokenizer_().decode(window))
|
|
242
|
+
if start + CHUNK_MAX_TOKENS >= len(ids):
|
|
243
|
+
break
|
|
244
|
+
start += step
|
|
245
|
+
continue
|
|
246
|
+
if current and current_tokens + seg_tokens > CHUNK_MAX_TOKENS:
|
|
247
|
+
chunks.append("\n\n".join(current))
|
|
248
|
+
current, current_tokens = [], 0
|
|
249
|
+
current.append(seg)
|
|
250
|
+
current_tokens += seg_tokens
|
|
251
|
+
if current:
|
|
252
|
+
chunks.append("\n\n".join(current))
|
|
253
|
+
return chunks
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _reembed_note_chunks(db: sqlite3.Connection, note_id: int, content: str) -> int:
|
|
257
|
+
"""Replace all vector chunks of a note and return the chunk count."""
|
|
258
|
+
db.execute(
|
|
259
|
+
"DELETE FROM note_chunks WHERE rowid IN "
|
|
260
|
+
"(SELECT chunk_rowid FROM note_chunks_map WHERE note_id = ?)",
|
|
261
|
+
(note_id,),
|
|
262
|
+
)
|
|
263
|
+
db.execute("DELETE FROM note_chunks_map WHERE note_id = ?", (note_id,))
|
|
264
|
+
next_rowid = db.execute("SELECT COALESCE(MAX(rowid), 0) + 1 FROM note_chunks").fetchone()[0]
|
|
265
|
+
count = 0
|
|
266
|
+
keyline = _keyline_of(content) if KEYLINE_PREFIX_EMBEDDING else ""
|
|
267
|
+
for idx, chunk in enumerate(_chunk_text(content)):
|
|
268
|
+
passage = chunk
|
|
269
|
+
if keyline and not chunk.strip().startswith(keyline):
|
|
270
|
+
passage = f"{keyline}\n\n{chunk}"
|
|
271
|
+
emb = np.array(_embed(passage, prefix="passage"), dtype=np.float32).tobytes()
|
|
272
|
+
db.execute(
|
|
273
|
+
"INSERT INTO note_chunks(rowid, embedding) VALUES (?, ?)",
|
|
274
|
+
(next_rowid, emb),
|
|
275
|
+
)
|
|
276
|
+
db.execute(
|
|
277
|
+
"INSERT INTO note_chunks_map(chunk_rowid, note_id, chunk_idx) VALUES (?, ?, ?)",
|
|
278
|
+
(next_rowid, note_id, idx),
|
|
279
|
+
)
|
|
280
|
+
next_rowid += 1
|
|
281
|
+
count += 1
|
|
282
|
+
return count
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _snippet_text(text: str, max_chars: int = 400) -> str:
|
|
286
|
+
"""Compressed retrieval output (RECOMP-style): the best chunk, cut at a
|
|
287
|
+
word boundary. Additive search output only — never used for ranking."""
|
|
288
|
+
text = text.strip()
|
|
289
|
+
if len(text) <= max_chars:
|
|
290
|
+
return text
|
|
291
|
+
cut = text[:max_chars]
|
|
292
|
+
if " " in cut:
|
|
293
|
+
cut = cut.rsplit(" ", 1)[0]
|
|
294
|
+
return cut + " …"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Server-side fact key (Phase 3 item 1a): every chunk embedding carries the
|
|
298
|
+
# note's first line as key context. Evidence: LongMemEval fact-key (+9 %),
|
|
299
|
+
# A-MEM "attributes into embedding" — the key always ADDS to the body,
|
|
300
|
+
# never replaces it (key-only collapses, summary bodies hurt).
|
|
301
|
+
# Revert switch: set to False and re-run phase3/rechunk-overlap0.py.
|
|
302
|
+
KEYLINE_PREFIX_EMBEDDING = True
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _keyline_of(content: str) -> str:
|
|
306
|
+
"""First non-blank line of the note (the Phase-0 keyline), trimmed to
|
|
307
|
+
200 chars — the server-side fact key, derived, not stored separately."""
|
|
308
|
+
for line in (content or "").strip().splitlines():
|
|
309
|
+
line = line.strip()
|
|
310
|
+
if line:
|
|
311
|
+
return line[:200]
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Database helpers
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
def _get_db() -> sqlite3.Connection:
|
|
319
|
+
"""Return a connection to the memory database (with extensions loaded)."""
|
|
320
|
+
_zsync_lazy_check()
|
|
321
|
+
db = sqlite3.connect(str(DB_PATH), timeout=30)
|
|
322
|
+
db.row_factory = sqlite3.Row
|
|
323
|
+
db.enable_load_extension(True)
|
|
324
|
+
sqlite_vec.load(db)
|
|
325
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
326
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
327
|
+
return db
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _init_db():
|
|
331
|
+
"""Create tables if they don't exist."""
|
|
332
|
+
with closing(_get_db()) as db:
|
|
333
|
+
db.executescript("""
|
|
334
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
335
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
336
|
+
content TEXT NOT NULL,
|
|
337
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
338
|
+
source TEXT NOT NULL DEFAULT '',
|
|
339
|
+
created_at TEXT NOT NULL,
|
|
340
|
+
key TEXT NOT NULL DEFAULT ''
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
344
|
+
content,
|
|
345
|
+
key,
|
|
346
|
+
content=notes,
|
|
347
|
+
content_rowid=id
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_vec USING vec0(
|
|
351
|
+
embedding float[384]
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS note_chunks USING vec0(
|
|
355
|
+
embedding float[384]
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
CREATE TABLE IF NOT EXISTS note_chunks_map (
|
|
359
|
+
chunk_rowid INTEGER PRIMARY KEY,
|
|
360
|
+
note_id INTEGER NOT NULL,
|
|
361
|
+
chunk_idx INTEGER NOT NULL
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_note ON note_chunks_map(note_id);
|
|
365
|
+
|
|
366
|
+
CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at);
|
|
367
|
+
""")
|
|
368
|
+
# Migrate databases created before the derived key column existed:
|
|
369
|
+
# notes and notes_fts both need it, else startup and memory_store
|
|
370
|
+
# crash with "no such column: key"
|
|
371
|
+
if "key" not in {r[1] for r in db.execute("PRAGMA table_info(notes)")}:
|
|
372
|
+
db.execute("ALTER TABLE notes ADD COLUMN key TEXT NOT NULL DEFAULT ''")
|
|
373
|
+
if "key" not in {r[1] for r in db.execute("PRAGMA table_info(notes_fts)")}:
|
|
374
|
+
db.execute("DROP TABLE notes_fts")
|
|
375
|
+
db.execute(
|
|
376
|
+
"CREATE VIRTUAL TABLE notes_fts USING fts5("
|
|
377
|
+
"content, key, content=notes, content_rowid=id)"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Heal notes missing the derived key column (server-side fact key)
|
|
381
|
+
for row in db.execute(
|
|
382
|
+
"SELECT id, content FROM notes WHERE key = '' AND TRIM(content) != ''"
|
|
383
|
+
).fetchall():
|
|
384
|
+
db.execute(
|
|
385
|
+
"UPDATE notes SET key = ? WHERE id = ?",
|
|
386
|
+
(_keyline_of(row["content"]), row["id"]),
|
|
387
|
+
)
|
|
388
|
+
db.commit()
|
|
389
|
+
|
|
390
|
+
# Rebuild FTS index from the content table — heals stale or missing
|
|
391
|
+
# entries at every start; the content table is the source of truth.
|
|
392
|
+
# Runs after the key heal so migrated FTS rows carry the keys too.
|
|
393
|
+
db.execute("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')")
|
|
394
|
+
db.commit()
|
|
395
|
+
|
|
396
|
+
# Re-embed notes missing from the chunk vector table
|
|
397
|
+
missing = db.execute(
|
|
398
|
+
"SELECT id, content FROM notes WHERE id NOT IN (SELECT note_id FROM note_chunks_map)"
|
|
399
|
+
).fetchall()
|
|
400
|
+
for row in missing:
|
|
401
|
+
_reembed_note_chunks(db, row["id"], row["content"])
|
|
402
|
+
db.commit()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# MCP Server
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
mcp = FastMCP("memory-server")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Zotero sync (memory_zotero_sync) — Web API only
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
def _zsync_cfg() -> dict:
|
|
415
|
+
"""Liest ~/.qwen/zotero-sync.json (device, api_key_path, collection)."""
|
|
416
|
+
if not ZOTERO_SYNC_CONFIG.exists():
|
|
417
|
+
return {}
|
|
418
|
+
try:
|
|
419
|
+
return json.loads(ZOTERO_SYNC_CONFIG.read_text(encoding="utf-8"))
|
|
420
|
+
except Exception:
|
|
421
|
+
return {}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _zsync_user_id(key: str) -> str | None:
|
|
425
|
+
"""userID direkt vom Key ableiten (GET /keys/<key>) — kein hartkodierter Nutzer."""
|
|
426
|
+
try:
|
|
427
|
+
req = urllib.request.Request(
|
|
428
|
+
f"https://api.zotero.org/keys/{key}",
|
|
429
|
+
headers={"Zotero-API-Key": key})
|
|
430
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
431
|
+
data = json.loads(r.read().decode())
|
|
432
|
+
return str(data.get("userID"))
|
|
433
|
+
except Exception:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _zsync_snapshot() -> Path | None:
|
|
438
|
+
"""Konsistenter Snapshot (VACUUM INTO) der Memory-DB."""
|
|
439
|
+
with closing(_get_db()) as db:
|
|
440
|
+
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
441
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
442
|
+
tmp.close()
|
|
443
|
+
db.execute(f"VACUUM INTO '{tmp.name}'")
|
|
444
|
+
return Path(tmp.name)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _zsync_call(method, url, body=None, key=None):
|
|
448
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
449
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
450
|
+
req.add_header("Content-Type", "application/json")
|
|
451
|
+
if key:
|
|
452
|
+
req.add_header("Zotero-API-Key", key)
|
|
453
|
+
try:
|
|
454
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
455
|
+
raw = r.read().decode()
|
|
456
|
+
return r.status, (json.loads(raw) if raw.strip() else None)
|
|
457
|
+
except urllib.error.HTTPError as e:
|
|
458
|
+
detail = e.read().decode()[:300]
|
|
459
|
+
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _zsync_parse_note(note_html):
|
|
463
|
+
"""content/tags/source/created_at/host aus einer Backup-Notiz (HTML)."""
|
|
464
|
+
pre = re.search(r'<pre[^>]*>(.*?)</pre>', note_html, re.S)
|
|
465
|
+
if not pre:
|
|
466
|
+
return None
|
|
467
|
+
tag_m = re.search(r"Tags:\s*(.*?)\s*\|\s*Quelle:", note_html)
|
|
468
|
+
src_m = re.search(r"Quelle:\s*(.*?)\s*\|\s*host:", note_html, re.S)
|
|
469
|
+
up_m = re.search(r"mem-updated:\s*([0-9T:.+\-Z]+)", note_html)
|
|
470
|
+
host_m = re.search(r"host:\s*([a-zA-Z0-9_]+)", note_html)
|
|
471
|
+
tags_raw = html.unescape(tag_m.group(1)) if tag_m else ""
|
|
472
|
+
try:
|
|
473
|
+
tags = json.loads(tags_raw)
|
|
474
|
+
except Exception:
|
|
475
|
+
tags = [t.strip() for t in tags_raw.strip("[]").split(",") if t.strip()]
|
|
476
|
+
tags = [str(t).strip().strip('"\'') for t in tags if str(t).strip()]
|
|
477
|
+
return {
|
|
478
|
+
"content": html.unescape(pre.group(1)),
|
|
479
|
+
"tags": tags,
|
|
480
|
+
"source": html.unescape(src_m.group(1)).strip() if src_m else "",
|
|
481
|
+
"created_at": up_m.group(1).strip() if up_m else "",
|
|
482
|
+
"host": host_m.group(1) if host_m else None,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _zsync_list_all_collections(base, key):
|
|
487
|
+
"""Alle Sammlungen — PAGINIERT (die Bibliothek hat >1000 Sammlungen;
|
|
488
|
+
ein einzelnes GET /collections liefert nur die erste Seite)."""
|
|
489
|
+
cols, start = [], 0
|
|
490
|
+
while True:
|
|
491
|
+
status, batch = _zsync_call(
|
|
492
|
+
"GET", base + f"/collections?limit=100&start={start}", key=key)
|
|
493
|
+
cols.extend(batch or [])
|
|
494
|
+
if not batch or len(batch) < 100:
|
|
495
|
+
return cols
|
|
496
|
+
start += 100
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _zsync_get_collection(base, key, name, coll_key=None):
|
|
500
|
+
"""Auflösung der Ziel-Sammlung. Mit coll_key (Config: collection_key)
|
|
501
|
+
gilt die feste ID — wird NIEMALS angelegt, 404 ist ein Fehler. Ohne
|
|
502
|
+
coll_key: paginierte Namenssuche, erst danach Anlegen."""
|
|
503
|
+
if coll_key:
|
|
504
|
+
status, c = _zsync_call("GET", base + f"/collections/{coll_key}", key=key)
|
|
505
|
+
if status != 200:
|
|
506
|
+
raise RuntimeError(
|
|
507
|
+
f"collection_key {coll_key} nicht gefunden (HTTP {status}) — "
|
|
508
|
+
"es wird bewusst KEINE neue Sammlung angelegt")
|
|
509
|
+
return c["key"]
|
|
510
|
+
for c in _zsync_list_all_collections(base, key):
|
|
511
|
+
if c["data"]["name"] == name:
|
|
512
|
+
return c["key"]
|
|
513
|
+
status, resp = _zsync_call("POST", base + "/collections",
|
|
514
|
+
[{"name": name}], key=key)
|
|
515
|
+
return resp["success"]["0"]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _zsync_note_text(content, tags, source, created, host, note_id):
|
|
519
|
+
sha = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
520
|
+
first = content.strip().splitlines()[0][:80] if content.strip() else "(leer)"
|
|
521
|
+
meta = (f"Tags: {tags} | Quelle: {source} | host: {host} | "
|
|
522
|
+
f"mem-updated: {created} | mem-hash: {sha}")
|
|
523
|
+
return (
|
|
524
|
+
f"<h1>#{host}-{note_id} – {html.escape(first)}</h1>\n"
|
|
525
|
+
f"<p><em>{html.escape(meta)}</em></p>\n"
|
|
526
|
+
f"<pre style=\"white-space:pre-wrap\">{html.escape(content)}</pre>"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _zsync_parse_existing(items):
|
|
531
|
+
out = {}
|
|
532
|
+
for it in items:
|
|
533
|
+
note = it["data"].get("note", "")
|
|
534
|
+
m = re.search(r"#([a-zA-Z0-9_]+)-(\d+)", note)
|
|
535
|
+
h = re.search(r"mem-hash:\s*([0-9a-f]+)", note)
|
|
536
|
+
t = re.search(r"<h1>(.*?)</h1>", note)
|
|
537
|
+
title = html.unescape(t.group(1)) if t else ""
|
|
538
|
+
if m:
|
|
539
|
+
out[(m.group(1), int(m.group(2)))] = {
|
|
540
|
+
"key": it["key"], "version": it["version"],
|
|
541
|
+
"hash": h.group(1) if h else "", "title": title}
|
|
542
|
+
continue
|
|
543
|
+
m2 = re.search(r"#(\d+)", note)
|
|
544
|
+
mh = re.search(r"host:\s*([a-zA-Z0-9_]+)", note)
|
|
545
|
+
if m2:
|
|
546
|
+
out[(mh.group(1) if mh else None, int(m2.group(1)))] = {
|
|
547
|
+
"key": it["key"], "version": it["version"],
|
|
548
|
+
"hash": h.group(1) if h else "", "title": title}
|
|
549
|
+
return out
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _zsync_do_sync(db: sqlite3.Connection, learn: bool = False) -> dict:
|
|
553
|
+
"""Ein Sync-Lauf. Ohne learn: nur Backup (eigene Notizen → Geräte-Sammlung)
|
|
554
|
+
mit Restore-Schutz. Mit learn=True: zusätzlich fehlende Notizen anderer
|
|
555
|
+
Geräte importieren — AUSSCHLIESSLICH auf explizite Anforderung
|
|
556
|
+
(memory_zotero_sync action:"learn"); nie als Automatik."""
|
|
557
|
+
cfg = _zsync_cfg()
|
|
558
|
+
device = cfg.get("device") or re.sub(r"[^a-zA-Z0-9_]", "", socket.gethostname().split(".")[0]) or "host"
|
|
559
|
+
key_path = Path(cfg.get("api_key_path", str(ZOTERO_SYNC_KEY_DEFAULT)))
|
|
560
|
+
key = key_path.read_text().strip() if key_path.exists() else None
|
|
561
|
+
if not key:
|
|
562
|
+
raise RuntimeError(f"Zotero-API-Key nicht gefunden: {key_path}")
|
|
563
|
+
user_id = cfg.get("user_id") or _zsync_user_id(key)
|
|
564
|
+
if not user_id:
|
|
565
|
+
raise RuntimeError("userID konnte nicht vom Key abgeleitet werden")
|
|
566
|
+
base = f"https://api.zotero.org/users/{user_id}"
|
|
567
|
+
collection = cfg.get("collection") or f"{ZOTERO_SYNC_PREFIX} — {device}"
|
|
568
|
+
|
|
569
|
+
# --- Eigene Notizen (read) ---
|
|
570
|
+
snap = _zsync_snapshot()
|
|
571
|
+
try:
|
|
572
|
+
con = sqlite3.connect(str(snap))
|
|
573
|
+
rows = con.execute(
|
|
574
|
+
"SELECT id, content, tags, source, created_at FROM notes ORDER BY id"
|
|
575
|
+
).fetchall()
|
|
576
|
+
con.close()
|
|
577
|
+
finally:
|
|
578
|
+
snap.unlink(missing_ok=True)
|
|
579
|
+
|
|
580
|
+
coll = _zsync_get_collection(base, key, collection,
|
|
581
|
+
coll_key=cfg.get("collection_key"))
|
|
582
|
+
status, own_items = _zsync_call(
|
|
583
|
+
"GET", base + f"/collections/{coll}/items?itemType=note&limit=100&format=json",
|
|
584
|
+
key=key)
|
|
585
|
+
items = list(own_items or [])
|
|
586
|
+
while len(items) >= 100:
|
|
587
|
+
status, page = _zsync_call(
|
|
588
|
+
"GET", base + f"/collections/{coll}/items?itemType=note&limit=100"
|
|
589
|
+
f"&start={len(items)}&format=json", key=key)
|
|
590
|
+
if not page:
|
|
591
|
+
break
|
|
592
|
+
items.extend(page)
|
|
593
|
+
existing = _zsync_parse_existing(items)
|
|
594
|
+
|
|
595
|
+
# --- Restore-Schutz ---
|
|
596
|
+
local_ids = {r[0] for r in rows}
|
|
597
|
+
own_missing = [
|
|
598
|
+
f"#{h}-{i}" for (h, i) in sorted(existing, key=lambda k: (k[0] or "", k[1]))
|
|
599
|
+
if (h == device or h is None) and i not in local_ids
|
|
600
|
+
]
|
|
601
|
+
protected = len(own_missing) >= ZOTERO_SYNC_PROTECT_THRESHOLD
|
|
602
|
+
|
|
603
|
+
# --- Backup: create / update ---
|
|
604
|
+
created, updated, skipped = 0, 0, 0
|
|
605
|
+
to_create, to_update = [], []
|
|
606
|
+
for nid, content, tags, source, c_at in rows:
|
|
607
|
+
sha = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
608
|
+
note_html = _zsync_note_text(content, tags, source, c_at, device, nid)
|
|
609
|
+
ex = existing.get((device, nid)) or existing.get((None, nid))
|
|
610
|
+
if ex is None:
|
|
611
|
+
to_create.append({"itemType": "note", "note": note_html,
|
|
612
|
+
"tags": [{"tag": "ki-gedaechtnis-backup"}],
|
|
613
|
+
"collections": [coll]})
|
|
614
|
+
elif ex["hash"] != sha:
|
|
615
|
+
if protected:
|
|
616
|
+
skipped += 1
|
|
617
|
+
else:
|
|
618
|
+
to_update.append((ex, note_html))
|
|
619
|
+
for i in range(0, len(to_create), ZOTERO_SYNC_CHUNK):
|
|
620
|
+
batch = to_create[i:i + ZOTERO_SYNC_CHUNK]
|
|
621
|
+
s, resp = _zsync_call("POST", base + "/items", batch, key=key)
|
|
622
|
+
if s not in (200, 201):
|
|
623
|
+
raise RuntimeError(f"Batch {i}: HTTP {s}")
|
|
624
|
+
created += len(resp.get("success", {}))
|
|
625
|
+
for ex, note_html_upd in to_update:
|
|
626
|
+
s, full = _zsync_call("GET", base + "/items/" + ex["key"], key=key)
|
|
627
|
+
if s != 200:
|
|
628
|
+
raise RuntimeError(f"GET {ex['key']}: HTTP {s}")
|
|
629
|
+
data = full["data"]
|
|
630
|
+
data["note"] = note_html_upd
|
|
631
|
+
data["tags"] = [{"tag": "ki-gedaechtnis-backup"}]
|
|
632
|
+
data["collections"] = [coll]
|
|
633
|
+
payload = {"key": ex["key"], "version": ex["version"], **data}
|
|
634
|
+
s, _ = _zsync_call("PUT", base + "/items/" + ex["key"], payload, key=key)
|
|
635
|
+
if s == 204:
|
|
636
|
+
updated += 1
|
|
637
|
+
else:
|
|
638
|
+
raise RuntimeError(f"PUT {ex['key']}: HTTP {s}")
|
|
639
|
+
|
|
640
|
+
# --- Fernlernen (NUR auf explizite Anforderung, learn=True) ---
|
|
641
|
+
learned, learned_from = 0, {}
|
|
642
|
+
if not learn:
|
|
643
|
+
ZOTERO_SYNC_LAST_FILE.write_text(str(time.time()))
|
|
644
|
+
return {
|
|
645
|
+
"device": device, "collection": collection,
|
|
646
|
+
"own_notes": len(rows), "created": created, "updated": updated,
|
|
647
|
+
"skipped_protected": skipped, "protected": protected,
|
|
648
|
+
"own_missing_in_zotero": len(own_missing),
|
|
649
|
+
"learned": 0, "learned_from": {},
|
|
650
|
+
"hint": "Backup abgeschlossen. Fernlernen ist eine eigene Aktion: action:\"learn\".",
|
|
651
|
+
}
|
|
652
|
+
local_hashes = {hashlib.sha256(r[1].encode()).hexdigest()[:12] for r in rows}
|
|
653
|
+
cols = _zsync_list_all_collections(base, key)
|
|
654
|
+
learned, learned_from = 0, {}
|
|
655
|
+
for c in (cols or []):
|
|
656
|
+
cname = c["data"]["name"]
|
|
657
|
+
if cname == collection or not (
|
|
658
|
+
cname.startswith(ZOTERO_SYNC_PREFIX)
|
|
659
|
+
or cname.startswith("AI-Memory-Backup")
|
|
660
|
+
):
|
|
661
|
+
continue
|
|
662
|
+
s2, foreign = _zsync_call(
|
|
663
|
+
"GET", base + f"/collections/{c['key']}/items?itemType=note&limit=100&format=json",
|
|
664
|
+
key=key)
|
|
665
|
+
fitems = list(foreign or [])
|
|
666
|
+
while len(fitems) >= 100:
|
|
667
|
+
s2, page = _zsync_call(
|
|
668
|
+
"GET", base + f"/collections/{c['key']}/items?itemType=note"
|
|
669
|
+
f"&limit=100&start={len(fitems)}&format=json", key=key)
|
|
670
|
+
if not page:
|
|
671
|
+
break
|
|
672
|
+
fitems.extend(page)
|
|
673
|
+
for it in fitems:
|
|
674
|
+
note = it["data"].get("note", "")
|
|
675
|
+
hm = re.search(r"mem-hash:\s*([0-9a-f]+)", note)
|
|
676
|
+
if not hm or hm.group(1) in local_hashes:
|
|
677
|
+
continue
|
|
678
|
+
parsed = _zsync_parse_note(note)
|
|
679
|
+
if not parsed or not parsed["content"].strip():
|
|
680
|
+
continue
|
|
681
|
+
tags = parsed["tags"] + ["quelle:cross-import"]
|
|
682
|
+
source = (parsed["source"] +
|
|
683
|
+
f" | cross-import aus „{cname}“, {time.strftime('%d.%m.%Y')}").strip(" |")
|
|
684
|
+
created_at = parsed["created_at"] or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
685
|
+
key_val = _keyline_of(parsed["content"])
|
|
686
|
+
cur = db.execute(
|
|
687
|
+
"INSERT INTO notes (content, tags, source, created_at, key) VALUES (?, ?, ?, ?, ?)",
|
|
688
|
+
(parsed["content"], json.dumps(tags, ensure_ascii=False), source, created_at, key_val))
|
|
689
|
+
nid = cur.lastrowid
|
|
690
|
+
_reembed_note_chunks(db, nid, parsed["content"])
|
|
691
|
+
db.execute("INSERT INTO notes_fts(rowid, content, key) VALUES (?, ?, ?)",
|
|
692
|
+
(nid, parsed["content"], key_val))
|
|
693
|
+
local_hashes.add(hashlib.sha256(parsed["content"].encode()).hexdigest()[:12])
|
|
694
|
+
learned += 1
|
|
695
|
+
learned_from[cname] = learned_from.get(cname, 0) + 1
|
|
696
|
+
db.commit()
|
|
697
|
+
|
|
698
|
+
ZOTERO_SYNC_LAST_FILE.write_text(str(time.time()))
|
|
699
|
+
return {
|
|
700
|
+
"device": device, "collection": collection,
|
|
701
|
+
"own_notes": len(rows), "created": created, "updated": updated,
|
|
702
|
+
"skipped_protected": skipped, "protected": protected,
|
|
703
|
+
"own_missing_in_zotero": len(own_missing),
|
|
704
|
+
"learned": learned, "learned_from": learned_from,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _zsync_status() -> dict:
|
|
709
|
+
cfg = _zsync_cfg()
|
|
710
|
+
key_path = Path(cfg.get("api_key_path", str(ZOTERO_SYNC_KEY_DEFAULT)))
|
|
711
|
+
last = None
|
|
712
|
+
if ZOTERO_SYNC_LAST_FILE.exists():
|
|
713
|
+
try:
|
|
714
|
+
last = float(ZOTERO_SYNC_LAST_FILE.read_text().strip())
|
|
715
|
+
except Exception:
|
|
716
|
+
last = None
|
|
717
|
+
due = last is None or (time.time() - last) > ZOTERO_SYNC_INTERVAL_H * 3600
|
|
718
|
+
return {
|
|
719
|
+
"configured": key_path.exists() and bool(cfg.get("device")),
|
|
720
|
+
"device": cfg.get("device"),
|
|
721
|
+
"collection": cfg.get("collection") or f"{ZOTERO_SYNC_PREFIX} — {cfg.get('device')}" if cfg.get("device") else None,
|
|
722
|
+
"api_key_path": str(key_path),
|
|
723
|
+
"key_present": key_path.exists(),
|
|
724
|
+
"interval_h": ZOTERO_SYNC_INTERVAL_H,
|
|
725
|
+
"last_sync_epoch": last,
|
|
726
|
+
"last_sync_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(last)) if last else None,
|
|
727
|
+
"due": due,
|
|
728
|
+
"running": _sync_state["running"],
|
|
729
|
+
"last_error": _sync_state["last_error"],
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _zsync_lazy_check():
|
|
734
|
+
"""6h-Trägheit: bei Memory-Tool-Aufruf prüfen, ob ein Backup fällig ist;
|
|
735
|
+
dann im Hintergrund-Thread anstoßen (nur wenn Key + Device konfiguriert).
|
|
736
|
+
Der Lazy-Lauf ist NUR Backup — Fernlernen passiert nie automatisch."""
|
|
737
|
+
try:
|
|
738
|
+
cfg = _zsync_cfg()
|
|
739
|
+
if not cfg.get("device"):
|
|
740
|
+
return
|
|
741
|
+
if not Path(cfg.get("api_key_path", str(ZOTERO_SYNC_KEY_DEFAULT))).exists():
|
|
742
|
+
return
|
|
743
|
+
st = _zsync_status()
|
|
744
|
+
if st["due"] and not _sync_state["running"]:
|
|
745
|
+
_sync_state["running"] = True
|
|
746
|
+
threading.Thread(target=_zsync_bg_run, daemon=True).start()
|
|
747
|
+
except Exception:
|
|
748
|
+
pass # Trigger darf niemals ein Memory-Tool ausbremsen
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _zsync_bg_run():
|
|
752
|
+
try:
|
|
753
|
+
with closing(_get_db()) as db:
|
|
754
|
+
result = _zsync_do_sync(db, learn=False)
|
|
755
|
+
_sync_state["last_result"] = result
|
|
756
|
+
_sync_state["last_error"] = None
|
|
757
|
+
except Exception as e:
|
|
758
|
+
_sync_state["last_error"] = str(e)
|
|
759
|
+
finally:
|
|
760
|
+
_sync_state["running"] = False
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@mcp.tool()
|
|
764
|
+
def memory_zotero_sync(action: str = "status") -> dict:
|
|
765
|
+
"""
|
|
766
|
+
Synchronisiert den lokalen Memory-Server mit zotero.org.
|
|
767
|
+
|
|
768
|
+
Parameters
|
|
769
|
+
----------
|
|
770
|
+
action : str
|
|
771
|
+
"status" = read-only Bericht (letzter Sync, offene Differenzen, Schutzmodus);
|
|
772
|
+
"sync" = Backup der eigenen Notizen in die Geräte-Sammlung
|
|
773
|
+
„KI-Gedächtnis-Backup — <Device>" (mit Restore-Schutz);
|
|
774
|
+
"learn" = Backup + gezielten Import fehlender Notizen ANDERER Geräte
|
|
775
|
+
(quellengetaggt quelle:cross-import) — nur auf ausdrücklichen
|
|
776
|
+
Wunsch, nie als Automatik.
|
|
777
|
+
"""
|
|
778
|
+
if action == "sync":
|
|
779
|
+
if _sync_state["running"]:
|
|
780
|
+
return {"status": "already_running", "hint": "Ein Lauf läuft bereits."}
|
|
781
|
+
_sync_state["running"] = True
|
|
782
|
+
try:
|
|
783
|
+
with closing(_get_db()) as db:
|
|
784
|
+
result = _zsync_do_sync(db, learn=False)
|
|
785
|
+
_sync_state["last_result"] = result
|
|
786
|
+
_sync_state["last_error"] = None
|
|
787
|
+
return {"action": "sync", **result}
|
|
788
|
+
except Exception as e:
|
|
789
|
+
_sync_state["last_error"] = str(e)
|
|
790
|
+
return {"action": "sync", "error": str(e)}
|
|
791
|
+
finally:
|
|
792
|
+
_sync_state["running"] = False
|
|
793
|
+
if action == "learn":
|
|
794
|
+
if _sync_state["running"]:
|
|
795
|
+
return {"status": "already_running", "hint": "Ein Lauf läuft bereits."}
|
|
796
|
+
_sync_state["running"] = True
|
|
797
|
+
try:
|
|
798
|
+
with closing(_get_db()) as db:
|
|
799
|
+
result = _zsync_do_sync(db, learn=True)
|
|
800
|
+
_sync_state["last_result"] = result
|
|
801
|
+
_sync_state["last_error"] = None
|
|
802
|
+
return {"action": "learn", **result}
|
|
803
|
+
except Exception as e:
|
|
804
|
+
_sync_state["last_error"] = str(e)
|
|
805
|
+
return {"action": "learn", "error": str(e)}
|
|
806
|
+
finally:
|
|
807
|
+
_sync_state["running"] = False
|
|
808
|
+
result = _zsync_status()
|
|
809
|
+
result["action"] = action
|
|
810
|
+
if action == "status":
|
|
811
|
+
return result
|
|
812
|
+
return {"error": f"unbekannte action: {action}", "hint": "status | sync | learn"}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@mcp.tool()
|
|
817
|
+
def memory_store(content: str, tags: str = "", source: str = "") -> dict:
|
|
818
|
+
"""
|
|
819
|
+
Store a memory note.
|
|
820
|
+
|
|
821
|
+
Parameters
|
|
822
|
+
----------
|
|
823
|
+
content : str
|
|
824
|
+
The note content / fact to remember.
|
|
825
|
+
tags : str
|
|
826
|
+
Optional comma-separated tags for categorisation.
|
|
827
|
+
source : str
|
|
828
|
+
Optional origin of this information.
|
|
829
|
+
"""
|
|
830
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
831
|
+
key_val = _keyline_of(content)
|
|
832
|
+
|
|
833
|
+
with closing(_get_db()) as db:
|
|
834
|
+
cur = db.execute(
|
|
835
|
+
"INSERT INTO notes (content, tags, source, created_at, key) VALUES (?, ?, ?, ?, ?)",
|
|
836
|
+
(content, tags, source, now, key_val),
|
|
837
|
+
)
|
|
838
|
+
note_id = cur.lastrowid
|
|
839
|
+
|
|
840
|
+
# Vector chunks
|
|
841
|
+
_reembed_note_chunks(db, note_id, content)
|
|
842
|
+
|
|
843
|
+
# FTS
|
|
844
|
+
db.execute(
|
|
845
|
+
"INSERT INTO notes_fts(rowid, content, key) VALUES (?, ?, ?)",
|
|
846
|
+
(note_id, content, key_val),
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
db.commit()
|
|
850
|
+
|
|
851
|
+
return {"id": note_id, "content": content, "tags": tags, "source": source, "created_at": now}
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@mcp.tool()
|
|
855
|
+
def memory_update(note_id: int, content: str | None = None, tags: str | None = None, source: str | None = None) -> dict | None:
|
|
856
|
+
"""
|
|
857
|
+
Update an existing memory note in place. Re-embeds when content changes.
|
|
858
|
+
|
|
859
|
+
Parameters
|
|
860
|
+
----------
|
|
861
|
+
note_id : int
|
|
862
|
+
The note ID to update.
|
|
863
|
+
content : str, optional
|
|
864
|
+
New note content (re-embeds vector and updates the FTS index).
|
|
865
|
+
tags : str, optional
|
|
866
|
+
New comma-separated tags.
|
|
867
|
+
source : str, optional
|
|
868
|
+
New origin of this information.
|
|
869
|
+
"""
|
|
870
|
+
with closing(_get_db()) as db:
|
|
871
|
+
row = db.execute(
|
|
872
|
+
"SELECT id, content, tags, source, created_at, key FROM notes WHERE id = ?",
|
|
873
|
+
(note_id,),
|
|
874
|
+
).fetchone()
|
|
875
|
+
if row is None:
|
|
876
|
+
return None
|
|
877
|
+
|
|
878
|
+
new_content = row["content"] if content is None else content
|
|
879
|
+
new_tags = row["tags"] if tags is None else tags
|
|
880
|
+
new_source = row["source"] if source is None else source
|
|
881
|
+
new_key = _keyline_of(new_content)
|
|
882
|
+
|
|
883
|
+
if new_content != row["content"]:
|
|
884
|
+
db.execute(
|
|
885
|
+
"INSERT INTO notes_fts(notes_fts, rowid, content, key) VALUES('delete', ?, ?, ?)",
|
|
886
|
+
(note_id, row["content"], row["key"]),
|
|
887
|
+
)
|
|
888
|
+
db.execute(
|
|
889
|
+
"INSERT INTO notes_fts(rowid, content, key) VALUES (?, ?, ?)",
|
|
890
|
+
(note_id, new_content, new_key),
|
|
891
|
+
)
|
|
892
|
+
_reembed_note_chunks(db, note_id, new_content)
|
|
893
|
+
|
|
894
|
+
db.execute(
|
|
895
|
+
"UPDATE notes SET content = ?, tags = ?, source = ?, key = ? WHERE id = ?",
|
|
896
|
+
(new_content, new_tags, new_source, new_key, note_id),
|
|
897
|
+
)
|
|
898
|
+
db.commit()
|
|
899
|
+
|
|
900
|
+
return {"id": note_id, "content": new_content, "tags": new_tags, "source": new_source, "created_at": row["created_at"]}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
@mcp.tool()
|
|
904
|
+
def memory_search(query: str, k: int = 10, budget_tokens: int = 0) -> list[dict]:
|
|
905
|
+
"""
|
|
906
|
+
Search memory using hybrid search (vector + full-text) with RRF fusion.
|
|
907
|
+
|
|
908
|
+
Parameters
|
|
909
|
+
----------
|
|
910
|
+
query : str
|
|
911
|
+
The search query.
|
|
912
|
+
k : int
|
|
913
|
+
Number of results to return (default 10).
|
|
914
|
+
budget_tokens : int
|
|
915
|
+
0 = disabled (default). Above 0: stop after the first notes whose
|
|
916
|
+
combined content reaches this token budget (minimum 3, maximum k) —
|
|
917
|
+
replaces result-count tuning with a context-size budget.
|
|
918
|
+
"""
|
|
919
|
+
if k < 1:
|
|
920
|
+
k = 10
|
|
921
|
+
k = min(k, 50)
|
|
922
|
+
|
|
923
|
+
expand_terms = _synonym_expansion(query)
|
|
924
|
+
search_text = query + " " + " ".join(expand_terms) if expand_terms else query
|
|
925
|
+
query_emb = _embed(search_text, prefix="query")
|
|
926
|
+
embedding_blob = np.array(query_emb, dtype=np.float32).tobytes()
|
|
927
|
+
|
|
928
|
+
with closing(_get_db()) as db:
|
|
929
|
+
# --- Vector search (chunked) — KNN over chunks, then aggregate
|
|
930
|
+
# to note level by minimum distance ---
|
|
931
|
+
chunk_rows = db.execute(
|
|
932
|
+
"""
|
|
933
|
+
SELECT rowid, distance
|
|
934
|
+
FROM note_chunks
|
|
935
|
+
WHERE embedding MATCH ?
|
|
936
|
+
ORDER BY distance
|
|
937
|
+
LIMIT ?
|
|
938
|
+
""",
|
|
939
|
+
(embedding_blob, k * 4),
|
|
940
|
+
).fetchall()
|
|
941
|
+
|
|
942
|
+
vec_rows: list[tuple[int, float]] = []
|
|
943
|
+
if chunk_rows:
|
|
944
|
+
placeholders = ",".join("?" for _ in chunk_rows)
|
|
945
|
+
map_rows = db.execute(
|
|
946
|
+
f"""
|
|
947
|
+
SELECT chunk_rowid, note_id
|
|
948
|
+
FROM note_chunks_map
|
|
949
|
+
WHERE chunk_rowid IN ({placeholders})
|
|
950
|
+
""",
|
|
951
|
+
[r["rowid"] for r in chunk_rows],
|
|
952
|
+
).fetchall()
|
|
953
|
+
chunk_to_note = {r["chunk_rowid"]: r["note_id"] for r in map_rows}
|
|
954
|
+
best: dict[int, tuple[float, int]] = {}
|
|
955
|
+
for r in chunk_rows:
|
|
956
|
+
nid = chunk_to_note.get(r["rowid"])
|
|
957
|
+
if nid is not None and (nid not in best or r["distance"] < best[nid][0]):
|
|
958
|
+
best[nid] = (r["distance"], r["rowid"])
|
|
959
|
+
vec_rows = sorted(best.items(), key=lambda x: x[1][0])[: k * 2]
|
|
960
|
+
|
|
961
|
+
# --- FTS5 search ---
|
|
962
|
+
# Quote each whitespace-separated term so FTS5 never interprets
|
|
963
|
+
# query syntax (column:term etc.); OR-join maximises lexical
|
|
964
|
+
# recall — precision comes from the semantic half of the hybrid.
|
|
965
|
+
# Match over the whole table so the key column is searched; the
|
|
966
|
+
# key weight is the fact-key boost (LongMemEval) — the keyline
|
|
967
|
+
# already sits in content too, so key terms count ~(1+w)x, body-only
|
|
968
|
+
# terms keep weight 1.0. Grid 20.9.2026: 3.0 overweights the keyline
|
|
969
|
+
# on vocabulary-gap queries (Runde 3: vector beat hybrid), lower
|
|
970
|
+
# weights recover them; MEMORY_FTS_KEY_WEIGHT tunes it.
|
|
971
|
+
terms = ['"' + t.replace('"', '""') + '"'
|
|
972
|
+
for t in query.split() + expand_terms]
|
|
973
|
+
fts_rows = []
|
|
974
|
+
if terms:
|
|
975
|
+
fts_rows = db.execute(
|
|
976
|
+
"""
|
|
977
|
+
SELECT rowid, bm25(notes_fts, 1.0, ?) AS rank
|
|
978
|
+
FROM notes_fts
|
|
979
|
+
WHERE notes_fts MATCH ?
|
|
980
|
+
ORDER BY rank
|
|
981
|
+
LIMIT ?
|
|
982
|
+
""",
|
|
983
|
+
(FTS_KEY_WEIGHT, " OR ".join(terms), k * 2),
|
|
984
|
+
).fetchall()
|
|
985
|
+
|
|
986
|
+
# --- RRF fusion ---
|
|
987
|
+
K = RRF_K # RRF constant (MEMORY_RRF_K, default 60)
|
|
988
|
+
scores: dict[int, float] = {}
|
|
989
|
+
|
|
990
|
+
for rank, row in enumerate(vec_rows):
|
|
991
|
+
note_id = row[0]
|
|
992
|
+
scores[note_id] = scores.get(note_id, 0.0) + 1.0 / (K + rank + 1)
|
|
993
|
+
|
|
994
|
+
for rank, row in enumerate(fts_rows):
|
|
995
|
+
note_id = row[0]
|
|
996
|
+
scores[note_id] = scores.get(note_id, 0.0) + FTS_ARM_WEIGHT / (K + rank + 1)
|
|
997
|
+
|
|
998
|
+
if not scores:
|
|
999
|
+
return []
|
|
1000
|
+
|
|
1001
|
+
# Sort by RRF score descending
|
|
1002
|
+
ranked = sorted(scores.items(), key=lambda x: -x[1])
|
|
1003
|
+
|
|
1004
|
+
# Fetch full note data (plus the best chunk index per note for snippets)
|
|
1005
|
+
with closing(_get_db()) as db:
|
|
1006
|
+
placeholders = ",".join("?" for _ in ranked)
|
|
1007
|
+
notes = db.execute(
|
|
1008
|
+
f"SELECT id, content, tags, source, created_at FROM notes WHERE id IN ({placeholders})",
|
|
1009
|
+
[rid for rid, _ in ranked],
|
|
1010
|
+
).fetchall()
|
|
1011
|
+
best_chunk_map: dict[int, int] = {}
|
|
1012
|
+
best_crids = [v[1] for v in best.values()]
|
|
1013
|
+
if best_crids:
|
|
1014
|
+
ph = ",".join("?" for _ in best_crids)
|
|
1015
|
+
for r in db.execute(
|
|
1016
|
+
f"SELECT chunk_rowid, note_id, chunk_idx FROM note_chunks_map WHERE chunk_rowid IN ({ph})",
|
|
1017
|
+
best_crids,
|
|
1018
|
+
):
|
|
1019
|
+
best_chunk_map[r["note_id"]] = r["chunk_idx"]
|
|
1020
|
+
|
|
1021
|
+
note_map = {n["id"]: dict(n) for n in notes}
|
|
1022
|
+
|
|
1023
|
+
result = []
|
|
1024
|
+
for note_id, rrf_score in ranked:
|
|
1025
|
+
note = note_map.get(note_id)
|
|
1026
|
+
if note:
|
|
1027
|
+
note["score"] = round(rrf_score, 4)
|
|
1028
|
+
chunk_idx = best_chunk_map.get(note_id)
|
|
1029
|
+
if chunk_idx is not None:
|
|
1030
|
+
chunks = _chunk_text(note["content"])
|
|
1031
|
+
if chunk_idx < len(chunks):
|
|
1032
|
+
note["snippet"] = _snippet_text(chunks[chunk_idx])
|
|
1033
|
+
result.append(note)
|
|
1034
|
+
|
|
1035
|
+
result = result[:k]
|
|
1036
|
+
|
|
1037
|
+
# Token-budget stopping (evidence: few notes, ~2k-token budget)
|
|
1038
|
+
if budget_tokens > 0:
|
|
1039
|
+
picked: list[dict] = []
|
|
1040
|
+
total = 0
|
|
1041
|
+
for note in result:
|
|
1042
|
+
picked.append(note)
|
|
1043
|
+
total += _count_tokens(note["content"])
|
|
1044
|
+
if len(picked) >= 3 and total >= budget_tokens:
|
|
1045
|
+
break
|
|
1046
|
+
if picked:
|
|
1047
|
+
picked[0]["budget_tokens_used"] = total
|
|
1048
|
+
result = picked
|
|
1049
|
+
|
|
1050
|
+
return result
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
@mcp.tool()
|
|
1054
|
+
def memory_get(note_id: int) -> dict | None:
|
|
1055
|
+
"""
|
|
1056
|
+
Retrieve a single memory note by ID.
|
|
1057
|
+
|
|
1058
|
+
Parameters
|
|
1059
|
+
----------
|
|
1060
|
+
note_id : int
|
|
1061
|
+
The note ID.
|
|
1062
|
+
"""
|
|
1063
|
+
with closing(_get_db()) as db:
|
|
1064
|
+
row = db.execute("SELECT id, content, tags, source, created_at FROM notes WHERE id = ?", (note_id,)).fetchone()
|
|
1065
|
+
return dict(row) if row else None
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
@mcp.tool()
|
|
1069
|
+
def memory_recent(limit: int = 20) -> list[dict]:
|
|
1070
|
+
"""
|
|
1071
|
+
Return the most recently stored memory notes.
|
|
1072
|
+
|
|
1073
|
+
Parameters
|
|
1074
|
+
----------
|
|
1075
|
+
limit : int
|
|
1076
|
+
Maximum number of notes to return (default 20).
|
|
1077
|
+
"""
|
|
1078
|
+
if limit < 1:
|
|
1079
|
+
limit = 20
|
|
1080
|
+
with closing(_get_db()) as db:
|
|
1081
|
+
rows = db.execute(
|
|
1082
|
+
"SELECT id, content, tags, source, created_at FROM notes ORDER BY id DESC LIMIT ?",
|
|
1083
|
+
(limit,),
|
|
1084
|
+
).fetchall()
|
|
1085
|
+
return [dict(r) for r in rows]
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
@mcp.tool()
|
|
1089
|
+
def memory_delete(note_id: int) -> bool:
|
|
1090
|
+
"""
|
|
1091
|
+
Delete a memory note and its associated vector/FTS entries.
|
|
1092
|
+
|
|
1093
|
+
Parameters
|
|
1094
|
+
----------
|
|
1095
|
+
note_id : int
|
|
1096
|
+
The note ID to delete.
|
|
1097
|
+
"""
|
|
1098
|
+
with closing(_get_db()) as db:
|
|
1099
|
+
row = db.execute("SELECT content, key FROM notes WHERE id = ?", (note_id,)).fetchone()
|
|
1100
|
+
if row is None:
|
|
1101
|
+
return False
|
|
1102
|
+
# External-content FTS: the index must be updated with the old
|
|
1103
|
+
# content BEFORE the content row disappears
|
|
1104
|
+
db.execute(
|
|
1105
|
+
"INSERT INTO notes_fts(notes_fts, rowid, content, key) VALUES('delete', ?, ?, ?)",
|
|
1106
|
+
(note_id, row["content"], row["key"]),
|
|
1107
|
+
)
|
|
1108
|
+
db.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
|
1109
|
+
db.execute("DELETE FROM notes_vec WHERE rowid = ?", (note_id,))
|
|
1110
|
+
db.execute(
|
|
1111
|
+
"DELETE FROM note_chunks WHERE rowid IN "
|
|
1112
|
+
"(SELECT chunk_rowid FROM note_chunks_map WHERE note_id = ?)",
|
|
1113
|
+
(note_id,),
|
|
1114
|
+
)
|
|
1115
|
+
db.execute("DELETE FROM note_chunks_map WHERE note_id = ?", (note_id,))
|
|
1116
|
+
db.commit()
|
|
1117
|
+
return True
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@mcp.tool()
|
|
1121
|
+
def memory_status() -> dict:
|
|
1122
|
+
"""Return database statistics — total notes, vector count, FTS count."""
|
|
1123
|
+
with closing(_get_db()) as db:
|
|
1124
|
+
total = db.execute("SELECT COUNT(*) AS c FROM notes").fetchone()["c"]
|
|
1125
|
+
vec_count = db.execute("SELECT COUNT(*) AS c FROM notes_vec").fetchone()["c"]
|
|
1126
|
+
chunk_count = db.execute("SELECT COUNT(*) AS c FROM note_chunks").fetchone()["c"]
|
|
1127
|
+
fts_count = db.execute("SELECT COUNT(*) AS c FROM notes_fts").fetchone()["c"]
|
|
1128
|
+
return {
|
|
1129
|
+
"total_notes": total,
|
|
1130
|
+
"vector_entries": vec_count,
|
|
1131
|
+
"chunk_entries": chunk_count,
|
|
1132
|
+
"fts_entries": fts_count,
|
|
1133
|
+
"db_path": str(DB_PATH),
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
# ---------------------------------------------------------------------------
|
|
1138
|
+
# Entrypoint
|
|
1139
|
+
# ---------------------------------------------------------------------------
|
|
1140
|
+
def main():
|
|
1141
|
+
_init_db()
|
|
1142
|
+
_ensure_embedder()
|
|
1143
|
+
mcp.run()
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
if __name__ == "__main__":
|
|
1147
|
+
main()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semantic-memory-mcp
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Single-file, self-hosted semantic memory MCP server for AI coding agents
|
|
5
|
+
Author: Michael Logies
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Michael-Logies/semantic-memory-mcp
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: fastmcp==3.4.7
|
|
12
|
+
Requires-Dist: onnxruntime==1.29.0
|
|
13
|
+
Requires-Dist: sqlite-vec==0.1.9
|
|
14
|
+
Requires-Dist: huggingface_hub==1.28.0
|
|
15
|
+
Requires-Dist: tokenizers==0.23.1
|
|
16
|
+
Requires-Dist: numpy==2.5.2
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Semantic Memory MCP
|
|
20
|
+
|
|
21
|
+
**Persistent, semantic memory for AI coding agents — a single Python file, entirely local.**
|
|
22
|
+
|
|
23
|
+
`semantic-memory-mcp` is a self-hosted [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI coding agents a durable, searchable memory across sessions. It is built as one Python file, stores everything in a single local SQLite database, and needs no cloud service and no API key.
|
|
24
|
+
|
|
25
|
+
## What it is
|
|
26
|
+
|
|
27
|
+
- **Cross-session memory:** notes survive between sessions; the agent searches its own memory at the start of every task instead of relying on the user to re-explain context.
|
|
28
|
+
- **Hybrid retrieval:** keyword full-text search (SQLite FTS5) and semantic vector search (sqlite-vec) are combined through Reciprocal Rank Fusion — a note can be found by exact wording *or* by meaning.
|
|
29
|
+
- **Multilingual embeddings:** quantized ONNX model (`Xenova/multilingual-e5-small`, ~113 MB) runs locally on CPU. English and German work equally well; no OpenAI key required.
|
|
30
|
+
- **Trivially auditable:** the whole server is a single readable file (`memory_server.py`, ~1,100 lines). There is no hidden state beyond the SQLite file.
|
|
31
|
+
|
|
32
|
+
### Tools
|
|
33
|
+
|
|
34
|
+
| Tool | Purpose |
|
|
35
|
+
|------|---------|
|
|
36
|
+
| `memory_store` | Save a note (with tags and source) |
|
|
37
|
+
| `memory_search` | Hybrid search (semantic + full-text), optional token budget |
|
|
38
|
+
| `memory_get` / `memory_recent` | Read one note by ID / list recent notes |
|
|
39
|
+
| `memory_update` / `memory_delete` | Edit or remove a note |
|
|
40
|
+
| `memory_status` | Database statistics |
|
|
41
|
+
| `memory_zotero_sync` | Optional: back up memory content into a Zotero library |
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
AI coding agents forget everything between sessions. File-based memory (markdown indexes that get reloaded) is keyword-based: it has no semantic recall and no ranking — a note saved as *"embedding model comparison"* will not be found when the agent later asks *"which embedding did we test?"*.
|
|
46
|
+
|
|
47
|
+
This server was built as a working reference implementation and **measured, not assumed** — see [Evaluation](#evaluation).
|
|
48
|
+
|
|
49
|
+
## Evaluation
|
|
50
|
+
|
|
51
|
+
Retrieval quality was tracked throughout development. Recall@5 — the share of queries where the correct note is among the top 5 hits — rose from **68.4 % to 89.2 %** across successive, individually measured optimization steps (chunking, embedding model choice, query rewriting, and more). The full measurement series with a 22-study comparison table is documented here:
|
|
52
|
+
|
|
53
|
+
**[The Memory Server in the Lab](https://www.logies.de/memory-server-research.html)**
|
|
54
|
+
|
|
55
|
+
## Working across parallel sessions
|
|
56
|
+
|
|
57
|
+
You can run several agent sessions at the same time — they all share one memory. Instead of a single "where did I stop?" slot that parallel sessions would overwrite, each session ends its work with a short **checkpoint note** in the memory (topic, what was done, what is open, next step), with the writing agent and model recorded in the `source` field. Asking any window *"Where did we stop on X?"* then finds the answer — even if the work happened in a different window. The convention is part of the agent rules (`memory-mcp-knowledge.md`, R17).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
**Prerequisites:** Linux, Python 3.11+ with `venv`, ~500 MB free disk space (virtual environment plus embedding model). The server is written for Linux, but not limited to it: since everything is one Python file, any AI assistant can read the script and adapt the installation for Windows or macOS.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/Michael-Logies/semantic-memory-mcp.git
|
|
65
|
+
cd semantic-memory-mcp
|
|
66
|
+
bash setup.sh # creates the venv, installs dependencies, downloads the model
|
|
67
|
+
bash verify.sh # checks the installation, writes a report
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The embedding model is downloaded automatically from Hugging Face (`huggingface_hub`, no account needed).
|
|
71
|
+
|
|
72
|
+
**Alternative: pip install from GitHub** — no clone needed; the package ships a `semantic-memory-mcp` command, and the embedding model (~113 MB) downloads automatically on first start:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python3 -m venv ~/.venv-memory
|
|
76
|
+
~/.venv-memory/bin/pip install git+https://github.com/Michael-Logies/semantic-memory-mcp.git
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then register the server in your AI agent's MCP configuration (example for Qwen Code, `~/.qwen/settings.json`):
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"memory": {
|
|
85
|
+
"command": "/home/<user>/.venv-memory/bin/python3",
|
|
86
|
+
"args": ["/path/to/semantic-memory-mcp/memory_server.py"],
|
|
87
|
+
"transport": "stdio"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If your client has an `mcp add` command, registration is a one-liner once the venv exists (with the pip installation, use the packaged `~/.venv-memory/bin/semantic-memory-mcp` as the single command instead of `python3 …/memory_server.py`):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Qwen Code (note: no `--` separator)
|
|
97
|
+
qwen mcp add memory ~/.venv-memory/bin/python3 /path/to/semantic-memory-mcp/memory_server.py
|
|
98
|
+
|
|
99
|
+
# Claude Code
|
|
100
|
+
claude mcp add memory -- ~/.venv-memory/bin/python3 /path/to/semantic-memory-mcp/memory_server.py
|
|
101
|
+
|
|
102
|
+
# Codex CLI
|
|
103
|
+
codex mcp add memory -- ~/.venv-memory/bin/python3 /path/to/semantic-memory-mcp/memory_server.py
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The Qwen Code and Claude Code commands were verified against the locally installed CLIs. The **Codex line was not tested by us** — its syntax was adopted from the documentation of other projects, since we only use Qwen Code ourselves.
|
|
107
|
+
|
|
108
|
+
See `mcp-config.json` for a template. `usage-notes.md` contains practical guidance for working with an agent that has persistent memory (context management, what to store, tagging, parallel sessions); the German version ships as `nutzungshinweise.md`.
|
|
109
|
+
|
|
110
|
+
## Documentation for your AI agent
|
|
111
|
+
|
|
112
|
+
Two documents ship with this repository, written for the agent rather than the human (both CC0-licensed):
|
|
113
|
+
|
|
114
|
+
- **`memory-mcp-knowledge.md`** — the rules and learning protocol for operating the memory server in daily agent work: when to read, when to write, what to store, and how to tag it.
|
|
115
|
+
- **`zotero-mcp-agent-knowledge.md`** — hard-won knowledge about connecting Zotero with AI agents via MCP servers: tool comparison (zotero-mcp-server, Zoteus, pyzotero, CLI access), benchmarks, and best practices. Since 31 August 2026, **Zoteus** is the sole Zotero MCP server in the reference setup; the older ChromaDB-based material is kept as background, not as a recommendation.
|
|
116
|
+
|
|
117
|
+
## The bigger picture
|
|
118
|
+
|
|
119
|
+
This server is the memory layer of a reproducible **AI workstation for students** — a complete setup guide (research, learning, literature work on a Linux machine) documented step by step here:
|
|
120
|
+
|
|
121
|
+
- **[AI Workstation for Students: Research, Learning, and More](https://www.logies.de/ai-workstation-linux-vm.html)** (English)
|
|
122
|
+
- **[KI-Arbeitsplatz fürs Studium: Recherche, Lernen und mehr](https://www.logies.de/studienarbeitsplatz-linux-vm.html)** (German)
|
|
123
|
+
|
|
124
|
+
Memory comes in two layers in that setup: this server stores the *working memory* of day-to-day collaboration with the agent, while scholarly literature lives in a Zotero research database made searchable by meaning through a second MCP server ([Zoteus](https://github.com/oscardvs/zoteus)). How the two fit together is described on the project pages:
|
|
125
|
+
|
|
126
|
+
- [Cross-Session Memory for AI Agents](https://www.logies.de/memory-mcp-for-ai-agents.html) (English)
|
|
127
|
+
- [Cross-Session-Memory für KI-Agenten](https://www.logies.de/memory-mcp-fuer-ki-agenten.html) (German)
|
|
128
|
+
|
|
129
|
+
## Status and maintenance
|
|
130
|
+
|
|
131
|
+
The server has been in daily production use since August 2026 as the memory of a working AI workstation. It is developed and maintained by [Michael Logies](https://github.com/Michael-Logies) in close collaboration with AI coding agents — see the project pages above for how it was built and evaluated. The server is provided **as-is**: bug reports are welcome and handled on a best-effort basis, but there is no formal support.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
memory_server.py,sha256=r8t7uuXCzfVe5XpQMo14wLcINKMs32MHdVkJl3rCshI,44377
|
|
2
|
+
semantic_memory_mcp-1.0.2.dist-info/licenses/LICENSE,sha256=InorYtyRr_lQTuiEw5Ue-UxRIdEmD3cdufNu7tmOgNA,1071
|
|
3
|
+
semantic_memory_mcp-1.0.2.dist-info/METADATA,sha256=ruKkKa7QXJ2_4FpuVTKtmP69nb2B4IQzsNm47aof7ko,8360
|
|
4
|
+
semantic_memory_mcp-1.0.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
semantic_memory_mcp-1.0.2.dist-info/entry_points.txt,sha256=rOCHGYaZrFT6rEIMz0F805KLw2mn0RrFhzHkCk3tLQg,59
|
|
6
|
+
semantic_memory_mcp-1.0.2.dist-info/top_level.txt,sha256=c5Z-HLp2zy58jjlRrleX6OTn53TNtTjrjRFA8TA7rVI,14
|
|
7
|
+
semantic_memory_mcp-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Logies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
memory_server
|