elesync 0.1.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.
- elesync/__init__.py +12 -0
- elesync/cli.py +268 -0
- elesync/embeddings.py +71 -0
- elesync/mcp_server.py +83 -0
- elesync/models.py +115 -0
- elesync/normalize.py +192 -0
- elesync/onboarding.py +129 -0
- elesync/service.py +63 -0
- elesync/store.py +292 -0
- elesync-0.1.0.dist-info/METADATA +312 -0
- elesync-0.1.0.dist-info/RECORD +15 -0
- elesync-0.1.0.dist-info/WHEEL +5 -0
- elesync-0.1.0.dist-info/entry_points.txt +2 -0
- elesync-0.1.0.dist-info/licenses/LICENSE +21 -0
- elesync-0.1.0.dist-info/top_level.txt +1 -0
elesync/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""elesync — a local-first, MCP-native unified elesync.
|
|
2
|
+
|
|
3
|
+
One vault on your disk. Every AI reads from and writes to it over MCP.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .models import MemoryItem, MEMORY_TYPES
|
|
7
|
+
from .store import MemoryStore
|
|
8
|
+
from .service import MemoryService
|
|
9
|
+
from . import normalize
|
|
10
|
+
|
|
11
|
+
__all__ = ["MemoryItem", "MEMORY_TYPES", "MemoryStore", "MemoryService", "normalize"]
|
|
12
|
+
__version__ = "0.1.0"
|
elesync/cli.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Command-line interface for the vault.
|
|
2
|
+
|
|
3
|
+
python -m elesync.cli import chatgpt_export.json --source chatgpt
|
|
4
|
+
python -m elesync.cli search "Helios project"
|
|
5
|
+
python -m elesync.cli add "Prefers direct, no-fluff answers" --type preference
|
|
6
|
+
python -m elesync.cli stats
|
|
7
|
+
python -m elesync.cli reindex # rebuild the index from notes/*.md
|
|
8
|
+
python -m elesync.cli export backup.json
|
|
9
|
+
python -m elesync.cli serve # start the MCP server
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__, normalize
|
|
22
|
+
from .models import MemoryItem
|
|
23
|
+
from .store import MemoryStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _semantic_on(args) -> bool:
|
|
27
|
+
return bool(getattr(args, "semantic", False) or os.environ.get("ELESYNC_SEMANTIC"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _embedder(args):
|
|
31
|
+
"""Load the embedding model only when semantic mode is requested. Returns None
|
|
32
|
+
(and a friendly note) if requested but the optional extra isn't installed."""
|
|
33
|
+
if not _semantic_on(args):
|
|
34
|
+
return None
|
|
35
|
+
from . import embeddings
|
|
36
|
+
emb = embeddings.load_embedder()
|
|
37
|
+
if emb is None:
|
|
38
|
+
print('Semantic mode requested but the extra is not installed — '
|
|
39
|
+
'run: pip install "elesync[semantic]". Falling back to keyword search.',
|
|
40
|
+
file=sys.stderr)
|
|
41
|
+
return emb
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _store(args) -> MemoryStore:
|
|
45
|
+
return MemoryStore(args.vault or os.environ.get("ELESYNC_DIR", "~/EleSyncVault"),
|
|
46
|
+
embedder=_embedder(args))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_import(args) -> int:
|
|
50
|
+
store = _store(args)
|
|
51
|
+
try:
|
|
52
|
+
items = normalize.normalize_file(args.path, source=args.source)
|
|
53
|
+
result = store.import_many(items)
|
|
54
|
+
print(f"Imported {result['added']} new memories "
|
|
55
|
+
f"({result['skipped_duplicates']} duplicates skipped). "
|
|
56
|
+
f"Vault now holds {result['total']}.")
|
|
57
|
+
return 0
|
|
58
|
+
finally:
|
|
59
|
+
store.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_search(args) -> int:
|
|
63
|
+
store = _store(args)
|
|
64
|
+
try:
|
|
65
|
+
hits = store.search(args.query, limit=args.limit, source=args.source, mtype=args.type)
|
|
66
|
+
if not hits:
|
|
67
|
+
print("No matches.")
|
|
68
|
+
return 0
|
|
69
|
+
for h in hits:
|
|
70
|
+
print(f"\n[{h.type}/{h.source}] id={h.id[:8]}")
|
|
71
|
+
print(h.content[:500] + ("…" if len(h.content) > 500 else ""))
|
|
72
|
+
return 0
|
|
73
|
+
finally:
|
|
74
|
+
store.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_add(args) -> int:
|
|
78
|
+
store = _store(args)
|
|
79
|
+
try:
|
|
80
|
+
item = MemoryItem(content=args.content, type=args.type,
|
|
81
|
+
tags=[t.strip() for t in (args.tags or "").split(",") if t.strip()],
|
|
82
|
+
source=args.source or "manual")
|
|
83
|
+
mem_id, inserted = store.upsert_status(item)
|
|
84
|
+
print(f"Saved id={mem_id[:8]}." if inserted
|
|
85
|
+
else f"Already in your vault (id={mem_id[:8]}) — nothing added.")
|
|
86
|
+
return 0
|
|
87
|
+
finally:
|
|
88
|
+
store.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_reindex(args) -> int:
|
|
92
|
+
store = _store(args)
|
|
93
|
+
try:
|
|
94
|
+
res = store.reindex()
|
|
95
|
+
print(f"Reindexed {res['reindexed']} memories from notes/ in {res['vault_dir']}.")
|
|
96
|
+
return 0
|
|
97
|
+
finally:
|
|
98
|
+
store.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_embed(args) -> int:
|
|
102
|
+
from . import embeddings
|
|
103
|
+
emb = embeddings.load_embedder()
|
|
104
|
+
if emb is None:
|
|
105
|
+
print('The semantic extra is not installed. Run: pip install "elesync[semantic]"',
|
|
106
|
+
file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
store = MemoryStore(args.vault or os.environ.get("ELESYNC_DIR", "~/EleSyncVault"),
|
|
109
|
+
embedder=emb)
|
|
110
|
+
try:
|
|
111
|
+
print(f"Embedding with {emb.model_name} … (first run downloads the model)")
|
|
112
|
+
n = store.embed_missing()
|
|
113
|
+
print(f"Embedded {n} memories. Semantic recall is active — "
|
|
114
|
+
f"use it with --semantic or ELESYNC_SEMANTIC=1.")
|
|
115
|
+
return 0
|
|
116
|
+
finally:
|
|
117
|
+
store.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_export(args) -> int:
|
|
121
|
+
store = _store(args)
|
|
122
|
+
try:
|
|
123
|
+
items = store.all()
|
|
124
|
+
payload = {
|
|
125
|
+
"elesync_export": 1,
|
|
126
|
+
"exported_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
127
|
+
"count": len(items),
|
|
128
|
+
"memories": [i.to_dict() for i in items],
|
|
129
|
+
}
|
|
130
|
+
Path(args.path).expanduser().write_text(
|
|
131
|
+
json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
132
|
+
print(f"Exported {len(items)} memories to {args.path}. "
|
|
133
|
+
f"Re-import with: ele import {args.path}")
|
|
134
|
+
return 0
|
|
135
|
+
finally:
|
|
136
|
+
store.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_stats(args) -> int:
|
|
140
|
+
store = _store(args)
|
|
141
|
+
try:
|
|
142
|
+
s = store.stats()
|
|
143
|
+
print(f"Vault: {s['vault_dir']}")
|
|
144
|
+
print(f"Total memories: {s['total']} (FTS search: {'on' if s['fts_enabled'] else 'off'})")
|
|
145
|
+
print(f"Embedded: {s['embedded']}/{s['total']} "
|
|
146
|
+
f"(semantic recall: {'available' if s['embedded'] else 'off — run: ele embed'})")
|
|
147
|
+
print("By source:", s["by_source"] or "—")
|
|
148
|
+
print("By type: ", s["by_type"] or "—")
|
|
149
|
+
return 0
|
|
150
|
+
finally:
|
|
151
|
+
store.close()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_serve(args) -> int:
|
|
155
|
+
from .mcp_server import build_server
|
|
156
|
+
build_server(args.vault).run()
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _vault_path(args) -> str:
|
|
161
|
+
return args.vault or os.environ.get("ELESYNC_DIR", "~/EleSyncVault")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_onboard(args) -> int:
|
|
165
|
+
from . import onboarding
|
|
166
|
+
vault = _vault_path(args)
|
|
167
|
+
cfg = Path(args.config_path) if args.config_path else onboarding.default_client_config_path()
|
|
168
|
+
|
|
169
|
+
# Make sure the vault exists.
|
|
170
|
+
MemoryStore(vault).close()
|
|
171
|
+
|
|
172
|
+
if args.print_only:
|
|
173
|
+
print("Add this to your MCP client config (e.g. Claude Desktop):\n")
|
|
174
|
+
print(json.dumps({"mcpServers": {onboarding.SERVER_KEY:
|
|
175
|
+
onboarding.server_entry(vault)}}, indent=2))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
res = onboarding.wire_client_config(cfg, vault)
|
|
179
|
+
print("✅ Onboarded.")
|
|
180
|
+
print(f" Vault: {Path(vault).expanduser()}")
|
|
181
|
+
print(f" Config: {res['config_path']}"
|
|
182
|
+
+ (" (existing backed up to *.bak)" if res["backed_up"] else ""))
|
|
183
|
+
print(f" Server '{onboarding.SERVER_KEY}' "
|
|
184
|
+
+ ("updated." if res["updated_existing"] else "added."))
|
|
185
|
+
print('\nNext: install the MCP SDK if you haven\'t — pip install "mcp[cli]"')
|
|
186
|
+
print("Then fully quit and reopen Claude Desktop, and ask it: "
|
|
187
|
+
'"What do you remember about me?"')
|
|
188
|
+
print("Verify anytime with: ele doctor")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_doctor(args) -> int:
|
|
193
|
+
from . import onboarding
|
|
194
|
+
cfg = Path(args.config_path) if args.config_path else None
|
|
195
|
+
rows = onboarding.run_doctor(_vault_path(args), config_path=cfg)
|
|
196
|
+
mark = {"PASS": "✅", "WARN": "⚠️ ", "FAIL": "❌"}
|
|
197
|
+
print("EleSync — health check\n")
|
|
198
|
+
for status, check, detail in rows:
|
|
199
|
+
print(f" {mark[status]} {check:<32} {detail}")
|
|
200
|
+
failed = [r for r in rows if r[0] == "FAIL"]
|
|
201
|
+
print("\n" + ("❌ Fix the FAIL items above." if failed
|
|
202
|
+
else "✅ Core looks good. WARNs are optional/until you serve."))
|
|
203
|
+
return 1 if failed else 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
207
|
+
p = argparse.ArgumentParser(
|
|
208
|
+
prog="ele", description="EleSync — local-first, MCP-native unified AI memory vault.")
|
|
209
|
+
p.add_argument("--version", action="version", version=f"EleSync {__version__}")
|
|
210
|
+
p.add_argument("--vault", help="Vault directory (default: $ELESYNC_DIR or ~/EleSyncVault)")
|
|
211
|
+
p.add_argument("--semantic", action="store_true",
|
|
212
|
+
help="Use semantic (embedding) search; needs the [semantic] extra")
|
|
213
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
214
|
+
|
|
215
|
+
pi = sub.add_parser("import", help="Import an AI export file")
|
|
216
|
+
pi.add_argument("path")
|
|
217
|
+
pi.add_argument("--source", choices=["chatgpt", "claude", "gemini", "grok", "deepseek"],
|
|
218
|
+
help="Force a source adapter (otherwise auto-detected)")
|
|
219
|
+
pi.set_defaults(func=cmd_import)
|
|
220
|
+
|
|
221
|
+
ps = sub.add_parser("search", help="Search the vault")
|
|
222
|
+
ps.add_argument("query")
|
|
223
|
+
ps.add_argument("--limit", type=int, default=10)
|
|
224
|
+
ps.add_argument("--source")
|
|
225
|
+
ps.add_argument("--type")
|
|
226
|
+
ps.set_defaults(func=cmd_search)
|
|
227
|
+
|
|
228
|
+
pa = sub.add_parser("add", help="Add a memory manually")
|
|
229
|
+
pa.add_argument("content")
|
|
230
|
+
pa.add_argument("--type", default="note")
|
|
231
|
+
pa.add_argument("--tags")
|
|
232
|
+
pa.add_argument("--source")
|
|
233
|
+
pa.set_defaults(func=cmd_add)
|
|
234
|
+
|
|
235
|
+
pt = sub.add_parser("stats", help="Show vault statistics")
|
|
236
|
+
pt.set_defaults(func=cmd_stats)
|
|
237
|
+
|
|
238
|
+
pr = sub.add_parser("reindex", help="Rebuild the search index from notes/*.md")
|
|
239
|
+
pr.set_defaults(func=cmd_reindex)
|
|
240
|
+
|
|
241
|
+
pm = sub.add_parser("embed", help="Embed memories for semantic recall ([semantic] extra)")
|
|
242
|
+
pm.set_defaults(func=cmd_embed)
|
|
243
|
+
|
|
244
|
+
pe = sub.add_parser("export", help="Export the whole vault to a portable JSON file")
|
|
245
|
+
pe.add_argument("path", help="Output file, e.g. vault-backup.json")
|
|
246
|
+
pe.set_defaults(func=cmd_export)
|
|
247
|
+
|
|
248
|
+
pv = sub.add_parser("serve", help="Run the MCP server")
|
|
249
|
+
pv.set_defaults(func=cmd_serve)
|
|
250
|
+
|
|
251
|
+
po = sub.add_parser("onboard", help="One-command setup: create vault + wire Claude Desktop")
|
|
252
|
+
po.add_argument("--config-path", help="Path to the MCP client config (default: auto-detect Claude Desktop)")
|
|
253
|
+
po.add_argument("--print-only", action="store_true", help="Print the config block instead of writing it")
|
|
254
|
+
po.set_defaults(func=cmd_onboard)
|
|
255
|
+
|
|
256
|
+
pd = sub.add_parser("doctor", help="Verify the install end to end")
|
|
257
|
+
pd.add_argument("--config-path", help="Path to the MCP client config to check")
|
|
258
|
+
pd.set_defaults(func=cmd_doctor)
|
|
259
|
+
return p
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv=None) -> int:
|
|
263
|
+
args = build_parser().parse_args(argv)
|
|
264
|
+
return args.func(args)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
sys.exit(main())
|
elesync/embeddings.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Optional semantic-embedding support.
|
|
2
|
+
|
|
3
|
+
The core stays dependency-free: everything here degrades gracefully when no
|
|
4
|
+
embedding model is installed. Enable semantic recall with the optional extra:
|
|
5
|
+
|
|
6
|
+
pip install "elesync[semantic]"
|
|
7
|
+
|
|
8
|
+
Design choices that keep the "no infrastructure" promise:
|
|
9
|
+
* Vectors live as float32 blobs in the same SQLite index — no new datastore.
|
|
10
|
+
* Similarity is brute-force cosine in pure Python — fast enough for a personal
|
|
11
|
+
vault of thousands of memories, and nothing to run or tune.
|
|
12
|
+
* The model is a small local ONNX model (no PyTorch), loaded only on demand.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from array import array
|
|
19
|
+
|
|
20
|
+
# Small, fast, local (fastembed uses ONNX runtime — no PyTorch). 384 dims.
|
|
21
|
+
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pack(vec) -> bytes:
|
|
25
|
+
"""Serialise a float vector to a compact float32 blob."""
|
|
26
|
+
return array("f", vec).tobytes()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unpack(blob: bytes) -> list[float]:
|
|
30
|
+
"""Inverse of `pack`."""
|
|
31
|
+
a = array("f")
|
|
32
|
+
a.frombytes(blob)
|
|
33
|
+
return list(a)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cosine(a, b) -> float:
|
|
37
|
+
"""Cosine similarity of two equal-length vectors (pure stdlib)."""
|
|
38
|
+
dot = sa = sb = 0.0
|
|
39
|
+
for x, y in zip(a, b):
|
|
40
|
+
dot += x * y
|
|
41
|
+
sa += x * x
|
|
42
|
+
sb += y * y
|
|
43
|
+
if sa == 0.0 or sb == 0.0:
|
|
44
|
+
return 0.0
|
|
45
|
+
return dot / (math.sqrt(sa) * math.sqrt(sb))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Embedder:
|
|
49
|
+
"""Wraps a fastembed model behind a tiny `str -> list[float]` interface.
|
|
50
|
+
|
|
51
|
+
Any object exposing `embed_one(str) -> list[float]` works as an embedder, so
|
|
52
|
+
the store can be driven by a stub in tests without pulling in the model."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, model_name: str = DEFAULT_MODEL):
|
|
55
|
+
from fastembed import TextEmbedding # optional dep, imported lazily
|
|
56
|
+
self.model_name = model_name
|
|
57
|
+
self._model = TextEmbedding(model_name)
|
|
58
|
+
|
|
59
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
60
|
+
return [list(map(float, v)) for v in self._model.embed(list(texts))]
|
|
61
|
+
|
|
62
|
+
def embed_one(self, text: str) -> list[float]:
|
|
63
|
+
return self.embed([text])[0]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_embedder(model_name: str = DEFAULT_MODEL):
|
|
67
|
+
"""Return an `Embedder` if the `semantic` extra is installed, else None."""
|
|
68
|
+
try:
|
|
69
|
+
return Embedder(model_name)
|
|
70
|
+
except ImportError:
|
|
71
|
+
return None
|
elesync/mcp_server.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""MCP server — the connector.
|
|
2
|
+
|
|
3
|
+
This is the heart of the whole idea. It exposes your local vault over the Model
|
|
4
|
+
Context Protocol, which OpenAI, Google and Anthropic all support natively. Point
|
|
5
|
+
Claude Desktop / ChatGPT / any MCP client at this server and they can all
|
|
6
|
+
`recall` from and `remember` into the *same* vault, live. No pasting, no static
|
|
7
|
+
"memory chips", no switching apps.
|
|
8
|
+
|
|
9
|
+
The actual logic lives in service.py (and is unit-tested); this file is the thin
|
|
10
|
+
protocol wrapper around it.
|
|
11
|
+
|
|
12
|
+
Run:
|
|
13
|
+
pip install "mcp[cli]" # or: pip install -e ".[mcp]"
|
|
14
|
+
ELESYNC_DIR=~/EleSyncVault python -m elesync.mcp_server
|
|
15
|
+
|
|
16
|
+
Easiest path: `ele onboard` wires this into Claude Desktop for you.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from .service import MemoryService
|
|
24
|
+
from .store import MemoryStore
|
|
25
|
+
|
|
26
|
+
# `mcp` is an external dependency. Imported lazily so the rest of the package —
|
|
27
|
+
# and the test suite — work without it installed.
|
|
28
|
+
try:
|
|
29
|
+
from mcp.server.fastmcp import FastMCP
|
|
30
|
+
except ImportError: # pragma: no cover
|
|
31
|
+
FastMCP = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_server(vault_dir: str | None = None):
|
|
35
|
+
if FastMCP is None: # pragma: no cover
|
|
36
|
+
raise ImportError('MCP SDK not installed. Run: pip install "mcp[cli]"')
|
|
37
|
+
|
|
38
|
+
vault_dir = vault_dir or os.environ.get("ELESYNC_DIR", "~/EleSyncVault")
|
|
39
|
+
# Opt into semantic recall when ELESYNC_SEMANTIC is set and the extra is present.
|
|
40
|
+
embedder = None
|
|
41
|
+
if os.environ.get("ELESYNC_SEMANTIC"):
|
|
42
|
+
from . import embeddings
|
|
43
|
+
embedder = embeddings.load_embedder()
|
|
44
|
+
svc = MemoryService(MemoryStore(vault_dir, embedder=embedder))
|
|
45
|
+
mcp = FastMCP("elesync")
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
def recall(query: str, limit: int = 8, source: str = "", type: str = "") -> str:
|
|
49
|
+
"""Search the user's unified memory across all AIs. Use this at the start of
|
|
50
|
+
a task to recover context. `source` filters to one AI (chatgpt/claude/gemini);
|
|
51
|
+
`type` filters by fact/preference/project/relationship/conversation/note."""
|
|
52
|
+
return svc.recall(query, limit=limit, source=source, type=type)
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def remember(content: str, type: str = "note", tags: str = "", source: str = "claude") -> str:
|
|
56
|
+
"""Save a new memory to the user's vault so every other AI can see it too.
|
|
57
|
+
Use for durable facts, preferences, project state — not throwaway chatter."""
|
|
58
|
+
return svc.remember(content, type=type, tags=tags, source=source)
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def forget(memory_id: str) -> str:
|
|
62
|
+
"""Delete a memory by id (full or 8-char prefix shown by recall)."""
|
|
63
|
+
return svc.forget(memory_id)
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def memory_status() -> str:
|
|
67
|
+
"""Summary of what's in the vault: totals by source and type."""
|
|
68
|
+
return svc.status()
|
|
69
|
+
|
|
70
|
+
@mcp.resource("memory://recent")
|
|
71
|
+
def recent() -> str:
|
|
72
|
+
"""The most recent memories, as a readable digest."""
|
|
73
|
+
return svc.recent()
|
|
74
|
+
|
|
75
|
+
return mcp
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None: # pragma: no cover
|
|
79
|
+
build_server().run()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__": # pragma: no cover
|
|
83
|
+
main()
|
elesync/models.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Normalized memory schema.
|
|
2
|
+
|
|
3
|
+
Every source (ChatGPT, Claude, Gemini, manual notes, ...) is mapped into a single
|
|
4
|
+
`MemoryItem` shape. This is the contract the whole system depends on: adapters
|
|
5
|
+
produce MemoryItems, the store persists MemoryItems, and the MCP server serves
|
|
6
|
+
MemoryItems back to any AI client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field, asdict
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
# Coarse taxonomy. Kept deliberately small so it stays useful across providers.
|
|
18
|
+
MEMORY_TYPES = {
|
|
19
|
+
"fact", # "User's name is Alex", "Lives in Westbrook"
|
|
20
|
+
"preference", # "Prefers direct, no-fluff answers"
|
|
21
|
+
"project", # "Helios side project", "open-source CLI tool"
|
|
22
|
+
"relationship", # "Has a younger sister"
|
|
23
|
+
"conversation", # a captured exchange, summarised or raw
|
|
24
|
+
"note", # free-form, manually added
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
VALID_SOURCES_HINT = ("chatgpt", "claude", "gemini", "grok", "deepseek", "manual")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_for_hash(text: str) -> str:
|
|
35
|
+
"""Lower-case + collapse whitespace so trivially-different copies of the same
|
|
36
|
+
memory (e.g. re-exported across sessions) hash identically and dedupe."""
|
|
37
|
+
return re.sub(r"\s+", " ", text.strip().lower())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MemoryItem:
|
|
42
|
+
content: str
|
|
43
|
+
source: str = "manual"
|
|
44
|
+
type: str = "note"
|
|
45
|
+
tags: list[str] = field(default_factory=list)
|
|
46
|
+
confidence: float = 1.0
|
|
47
|
+
created_at: str = field(default_factory=_now)
|
|
48
|
+
source_ref: str = "" # provenance: original conversation id / export filename
|
|
49
|
+
id: str = "" # stable uuid (assigned on creation if blank)
|
|
50
|
+
content_hash: str = "" # for dedup / near-dup detection
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
if not isinstance(self.tags, list):
|
|
54
|
+
self.tags = [t for t in re.split(r"[,;]", str(self.tags)) if t.strip()]
|
|
55
|
+
if self.type not in MEMORY_TYPES:
|
|
56
|
+
self.type = "note"
|
|
57
|
+
self.confidence = max(0.0, min(1.0, float(self.confidence)))
|
|
58
|
+
if not self.content_hash:
|
|
59
|
+
self.content_hash = self.compute_hash()
|
|
60
|
+
if not self.id:
|
|
61
|
+
self.id = uuid.uuid4().hex
|
|
62
|
+
|
|
63
|
+
def compute_hash(self) -> str:
|
|
64
|
+
basis = f"{self.source}|{self.type}|{_normalize_for_hash(self.content)}"
|
|
65
|
+
return hashlib.sha1(basis.encode("utf-8")).hexdigest()
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
return asdict(self)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, d: dict) -> "MemoryItem":
|
|
72
|
+
known = {k: d.get(k) for k in cls.__dataclass_fields__ if k in d}
|
|
73
|
+
return cls(**known)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_markdown(cls, text: str) -> "MemoryItem":
|
|
77
|
+
"""Inverse of `to_markdown`: parse a note's YAML front-matter + body back
|
|
78
|
+
into a MemoryItem. Tolerant of hand-edits (extra blank lines, missing
|
|
79
|
+
optional keys); preserves the stored id and content_hash so a reindex is a
|
|
80
|
+
faithful rebuild rather than a reimport. Raises ValueError if there's no
|
|
81
|
+
front-matter block to parse."""
|
|
82
|
+
if not text.startswith("---"):
|
|
83
|
+
raise ValueError("note has no front-matter block")
|
|
84
|
+
_, fm, body = text.split("---", 2)
|
|
85
|
+
meta: dict = {}
|
|
86
|
+
for line in fm.splitlines():
|
|
87
|
+
if ":" not in line:
|
|
88
|
+
continue
|
|
89
|
+
key, _, val = line.partition(":")
|
|
90
|
+
key, val = key.strip(), val.strip()
|
|
91
|
+
if not key:
|
|
92
|
+
continue
|
|
93
|
+
if key == "tags":
|
|
94
|
+
val = [t.strip() for t in val.strip("[]").split(",") if t.strip()]
|
|
95
|
+
meta[key] = val
|
|
96
|
+
meta["content"] = body.lstrip("\n").rstrip("\n")
|
|
97
|
+
return cls.from_dict(meta)
|
|
98
|
+
|
|
99
|
+
def to_markdown(self) -> str:
|
|
100
|
+
"""Human-readable, Obsidian-compatible: YAML front-matter + body.
|
|
101
|
+
This is what makes the vault *yours* — readable in any text editor."""
|
|
102
|
+
tags = ", ".join(self.tags)
|
|
103
|
+
return (
|
|
104
|
+
"---\n"
|
|
105
|
+
f"id: {self.id}\n"
|
|
106
|
+
f"source: {self.source}\n"
|
|
107
|
+
f"type: {self.type}\n"
|
|
108
|
+
f"tags: [{tags}]\n"
|
|
109
|
+
f"confidence: {self.confidence}\n"
|
|
110
|
+
f"created_at: {self.created_at}\n"
|
|
111
|
+
f"source_ref: {self.source_ref}\n"
|
|
112
|
+
f"content_hash: {self.content_hash}\n"
|
|
113
|
+
"---\n\n"
|
|
114
|
+
f"{self.content}\n"
|
|
115
|
+
)
|