elesync 1.3.0__tar.gz → 1.3.2__tar.gz
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-1.3.0 → elesync-1.3.2}/PKG-INFO +3 -3
- {elesync-1.3.0 → elesync-1.3.2}/README.md +2 -2
- {elesync-1.3.0 → elesync-1.3.2}/elesync/__init__.py +1 -1
- {elesync-1.3.0 → elesync-1.3.2}/elesync/cli.py +13 -8
- {elesync-1.3.0 → elesync-1.3.2}/elesync/embeddings.py +13 -2
- {elesync-1.3.0 → elesync-1.3.2}/elesync/summary.py +2 -1
- {elesync-1.3.0 → elesync-1.3.2}/elesync/usb.py +30 -15
- {elesync-1.3.0 → elesync-1.3.2}/elesync/web.py +45 -23
- {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/PKG-INFO +3 -3
- {elesync-1.3.0 → elesync-1.3.2}/pyproject.toml +1 -1
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_usb.py +3 -5
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_web.py +65 -36
- {elesync-1.3.0 → elesync-1.3.2}/LICENSE +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/conflicts.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/crypto.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/mcp_server.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/models.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/normalize.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/onboarding.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/scopes.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/service.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/store.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync/watch.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/SOURCES.txt +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/dependency_links.txt +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/entry_points.txt +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/requires.txt +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/top_level.txt +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/setup.cfg +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_conflicts.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_encryption.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_scopes.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_semantic.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_service.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_summary.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_vault.py +0 -0
- {elesync-1.3.0 → elesync-1.3.2}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elesync
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: Local-first, MCP-native unified memory vault — your AI memory as files you own, shared across every model.
|
|
5
5
|
Author: Alphanymous
|
|
6
6
|
License: MIT
|
|
@@ -173,7 +173,7 @@ pip install "elesync[mcp]"
|
|
|
173
173
|
> No Python yet? Download from **[python.org/downloads](https://www.python.org/downloads/)** — on Windows tick **"Add Python to PATH"** during install. If `pip` isn't found: use `py -m pip …` (Windows) or `python3 -m pip …` (Mac/Linux).
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
ele --version # → EleSync 1.
|
|
176
|
+
ele --version # → EleSync 1.3.0
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
**2 · Import your memories from ChatGPT / Claude / Gemini**
|
|
@@ -560,7 +560,7 @@ Still stuck? Open an issue at **[github.com/darknodebros/EleSync/issues](https:/
|
|
|
560
560
|
## Tests
|
|
561
561
|
|
|
562
562
|
```bash
|
|
563
|
-
python -m unittest discover -s tests -v #
|
|
563
|
+
python -m unittest discover -s tests -v # 179 tests, stdlib only — no MCP SDK required
|
|
564
564
|
```
|
|
565
565
|
|
|
566
566
|
## Where the export files come from (2026 reality)
|
|
@@ -133,7 +133,7 @@ pip install "elesync[mcp]"
|
|
|
133
133
|
> No Python yet? Download from **[python.org/downloads](https://www.python.org/downloads/)** — on Windows tick **"Add Python to PATH"** during install. If `pip` isn't found: use `py -m pip …` (Windows) or `python3 -m pip …` (Mac/Linux).
|
|
134
134
|
|
|
135
135
|
```bash
|
|
136
|
-
ele --version # → EleSync 1.
|
|
136
|
+
ele --version # → EleSync 1.3.0
|
|
137
137
|
```
|
|
138
138
|
|
|
139
139
|
**2 · Import your memories from ChatGPT / Claude / Gemini**
|
|
@@ -520,7 +520,7 @@ Still stuck? Open an issue at **[github.com/darknodebros/EleSync/issues](https:/
|
|
|
520
520
|
## Tests
|
|
521
521
|
|
|
522
522
|
```bash
|
|
523
|
-
python -m unittest discover -s tests -v #
|
|
523
|
+
python -m unittest discover -s tests -v # 179 tests, stdlib only — no MCP SDK required
|
|
524
524
|
```
|
|
525
525
|
|
|
526
526
|
## Where the export files come from (2026 reality)
|
|
@@ -48,8 +48,9 @@ def _embedder(args):
|
|
|
48
48
|
emb = embeddings.load_embedder()
|
|
49
49
|
if emb is None:
|
|
50
50
|
print(
|
|
51
|
-
"Semantic mode
|
|
52
|
-
'
|
|
51
|
+
"Semantic mode unavailable (the extra isn't installed, or the model "
|
|
52
|
+
"couldn't be loaded — check your connection). Falling back to keyword "
|
|
53
|
+
'search. To enable it: pip install "elesync[semantic]".',
|
|
53
54
|
file=sys.stderr,
|
|
54
55
|
)
|
|
55
56
|
return emb
|
|
@@ -243,7 +244,9 @@ def cmd_embed(args) -> int:
|
|
|
243
244
|
emb = embeddings.load_embedder()
|
|
244
245
|
if emb is None:
|
|
245
246
|
print(
|
|
246
|
-
|
|
247
|
+
"Could not load the embedding model. Make sure the extra is installed "
|
|
248
|
+
'(pip install "elesync[semantic]") and that you are online for the '
|
|
249
|
+
"first run, which downloads the model.",
|
|
247
250
|
file=sys.stderr,
|
|
248
251
|
)
|
|
249
252
|
return 1
|
|
@@ -495,14 +498,14 @@ def cmd_usb_install(args) -> int:
|
|
|
495
498
|
print(f"❌ {e}", file=sys.stderr)
|
|
496
499
|
return 1
|
|
497
500
|
print(f"✅ Vault created: {result['vault_dir']}")
|
|
498
|
-
print(
|
|
501
|
+
print(" Encrypted: yes (XSalsa20-Poly1305 + argon2id)")
|
|
499
502
|
print(f" Launcher: {result['launcher']}")
|
|
500
503
|
print(f" README: {result['readme']}")
|
|
501
504
|
print()
|
|
502
505
|
print("Next steps:")
|
|
503
506
|
print(f" Import memories: ele --vault {result['vault_dir']} import <file> --source chatgpt")
|
|
504
507
|
print(f" Connect Claude: ele usb attach {drive}")
|
|
505
|
-
print(
|
|
508
|
+
print(" On another machine: plug in the drive and run python RUN_ME.py")
|
|
506
509
|
return 0
|
|
507
510
|
|
|
508
511
|
|
|
@@ -531,7 +534,7 @@ def cmd_usb_detach(args) -> int:
|
|
|
531
534
|
drive = args.drive
|
|
532
535
|
result = usb.detach(drive, name=args.name or "elesync-usb")
|
|
533
536
|
if result["removed"]:
|
|
534
|
-
print(
|
|
537
|
+
print("✅ USB vault disconnected from Claude Desktop.")
|
|
535
538
|
print(f" Config: {result['config_path']}")
|
|
536
539
|
print()
|
|
537
540
|
print("You can now safely eject the drive.")
|
|
@@ -552,7 +555,9 @@ def cmd_usb_status(args) -> int:
|
|
|
552
555
|
if s["vault_present"]:
|
|
553
556
|
print(f"Encrypted: {'yes' if s['encrypted'] else 'NO — run ele usb install to fix'}")
|
|
554
557
|
print(f"Memory files: {s['note_count']}")
|
|
555
|
-
print(
|
|
558
|
+
print(
|
|
559
|
+
f"Launcher: {'present' if s['launcher_present'] else 'missing — run ele usb install'}"
|
|
560
|
+
)
|
|
556
561
|
print(f"Free space: {s['size_gb']} GB")
|
|
557
562
|
return 0
|
|
558
563
|
|
|
@@ -583,7 +588,7 @@ def cmd_watch(args) -> int:
|
|
|
583
588
|
def cmd_summary(args) -> int:
|
|
584
589
|
print(
|
|
585
590
|
build_summary(
|
|
586
|
-
vault_dir=args
|
|
591
|
+
vault_dir=_vault_path(args),
|
|
587
592
|
source=args.source,
|
|
588
593
|
short=args.short,
|
|
589
594
|
)
|
|
@@ -66,6 +66,11 @@ class Embedder:
|
|
|
66
66
|
|
|
67
67
|
self.model_name = model_name
|
|
68
68
|
self._model = TextEmbedding(model_name)
|
|
69
|
+
# fastembed downloads/loads the model lazily on first embed. Force that
|
|
70
|
+
# now with a tiny warm-up so any failure (offline, blocked download,
|
|
71
|
+
# corrupt cache) raises here — where load_embedder() turns it into a
|
|
72
|
+
# graceful keyword-search fallback instead of crashing mid-search.
|
|
73
|
+
self.embed_one("warmup")
|
|
69
74
|
|
|
70
75
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
71
76
|
return [list(map(float, v)) for v in self._model.embed(list(texts))]
|
|
@@ -75,8 +80,14 @@ class Embedder:
|
|
|
75
80
|
|
|
76
81
|
|
|
77
82
|
def load_embedder(model_name: str = DEFAULT_MODEL):
|
|
78
|
-
"""Return
|
|
83
|
+
"""Return a ready-to-use `Embedder`, or None if semantic recall is unavailable.
|
|
84
|
+
|
|
85
|
+
Returns None on any failure — the optional extra not being installed
|
|
86
|
+
(ImportError) or the model failing to load/download (offline, blocked,
|
|
87
|
+
corrupt cache). Callers fall back to keyword search, so semantic mode
|
|
88
|
+
never crashes the command; it just quietly degrades.
|
|
89
|
+
"""
|
|
79
90
|
try:
|
|
80
91
|
return Embedder(model_name)
|
|
81
|
-
except
|
|
92
|
+
except Exception:
|
|
82
93
|
return None
|
|
@@ -9,6 +9,7 @@ Usage:
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import os
|
|
12
13
|
from collections import defaultdict
|
|
13
14
|
from datetime import datetime, timezone
|
|
14
15
|
|
|
@@ -73,7 +74,7 @@ def build_summary(
|
|
|
73
74
|
short: bool = False,
|
|
74
75
|
) -> str:
|
|
75
76
|
"""Return the formatted summary as a string."""
|
|
76
|
-
store = MemoryStore(vault_dir=vault_dir or "~/EleSyncVault")
|
|
77
|
+
store = MemoryStore(vault_dir=vault_dir or os.environ.get("ELESYNC_DIR", "~/EleSyncVault"))
|
|
77
78
|
try:
|
|
78
79
|
all_items = store.all()
|
|
79
80
|
finally:
|
|
@@ -46,7 +46,6 @@ import os
|
|
|
46
46
|
import platform
|
|
47
47
|
import shutil
|
|
48
48
|
import subprocess
|
|
49
|
-
import sys
|
|
50
49
|
from pathlib import Path
|
|
51
50
|
|
|
52
51
|
# Module-level references so tests can patch elesync.usb.crypto / MemoryStore / onboarding
|
|
@@ -58,7 +57,7 @@ except Exception: # pragma: no cover
|
|
|
58
57
|
try:
|
|
59
58
|
from .store import MemoryStore # type: ignore[attr-defined]
|
|
60
59
|
except Exception: # pragma: no cover
|
|
61
|
-
MemoryStore = None # type: ignore[assignment]
|
|
60
|
+
MemoryStore = None # type: ignore[misc,assignment]
|
|
62
61
|
|
|
63
62
|
try:
|
|
64
63
|
from . import onboarding # type: ignore[attr-defined]
|
|
@@ -89,7 +88,6 @@ def list_removable_drives() -> list[dict]:
|
|
|
89
88
|
|
|
90
89
|
|
|
91
90
|
def _list_drives_macos() -> list[dict]:
|
|
92
|
-
import json as _json
|
|
93
91
|
|
|
94
92
|
out = subprocess.run(
|
|
95
93
|
["diskutil", "list", "-plist"],
|
|
@@ -138,8 +136,15 @@ def _list_drives_macos() -> list[dict]:
|
|
|
138
136
|
|
|
139
137
|
def _list_drives_windows() -> list[dict]:
|
|
140
138
|
out = subprocess.run(
|
|
141
|
-
[
|
|
142
|
-
|
|
139
|
+
[
|
|
140
|
+
"wmic",
|
|
141
|
+
"logicaldisk",
|
|
142
|
+
"where",
|
|
143
|
+
"DriveType=2",
|
|
144
|
+
"get",
|
|
145
|
+
"DeviceID,VolumeName,Size,FileSystem",
|
|
146
|
+
"/format:csv",
|
|
147
|
+
],
|
|
143
148
|
capture_output=True,
|
|
144
149
|
text=True,
|
|
145
150
|
check=False,
|
|
@@ -159,7 +164,7 @@ def _list_drives_windows() -> list[dict]:
|
|
|
159
164
|
|
|
160
165
|
|
|
161
166
|
def _list_drives_linux() -> list[dict]:
|
|
162
|
-
drives = []
|
|
167
|
+
drives: list[dict] = []
|
|
163
168
|
mounts = Path("/proc/mounts")
|
|
164
169
|
if not mounts.exists():
|
|
165
170
|
return drives
|
|
@@ -167,7 +172,7 @@ def _list_drives_linux() -> list[dict]:
|
|
|
167
172
|
parts = line.split()
|
|
168
173
|
if len(parts) < 3:
|
|
169
174
|
continue
|
|
170
|
-
|
|
175
|
+
_, mp, fs = parts[0], parts[1], parts[2]
|
|
171
176
|
# Heuristic: removable drives are under /media or /mnt, or start with /dev/sd
|
|
172
177
|
if not (mp.startswith("/media/") or mp.startswith("/mnt/")):
|
|
173
178
|
continue
|
|
@@ -205,6 +210,7 @@ def is_usb_vault(drive: str | Path) -> bool:
|
|
|
205
210
|
# Install
|
|
206
211
|
# ---------------------------------------------------------------------------
|
|
207
212
|
|
|
213
|
+
|
|
208
214
|
def install(drive: str | Path, passphrase: str, label: str = "USB") -> dict:
|
|
209
215
|
"""Bootstrap an encrypted EleSync vault on `drive`.
|
|
210
216
|
|
|
@@ -217,8 +223,7 @@ def install(drive: str | Path, passphrase: str, label: str = "USB") -> dict:
|
|
|
217
223
|
"""
|
|
218
224
|
if crypto is None or not crypto.available():
|
|
219
225
|
raise RuntimeError(
|
|
220
|
-
'USB vaults are always encrypted. '
|
|
221
|
-
'Run: pip install "elesync[encryption]" first.'
|
|
226
|
+
'USB vaults are always encrypted. Run: pip install "elesync[encryption]" first.'
|
|
222
227
|
)
|
|
223
228
|
|
|
224
229
|
drive = Path(drive)
|
|
@@ -237,7 +242,6 @@ def install(drive: str | Path, passphrase: str, label: str = "USB") -> dict:
|
|
|
237
242
|
|
|
238
243
|
# Open the store once so the SQLite schema is initialised (even though the
|
|
239
244
|
# in-memory index is empty — it primes the notes/ layout).
|
|
240
|
-
import base64 as _b64
|
|
241
245
|
store = MemoryStore(vp, key=_key)
|
|
242
246
|
store.close()
|
|
243
247
|
|
|
@@ -257,6 +261,7 @@ def install(drive: str | Path, passphrase: str, label: str = "USB") -> dict:
|
|
|
257
261
|
# Attach / detach
|
|
258
262
|
# ---------------------------------------------------------------------------
|
|
259
263
|
|
|
264
|
+
|
|
260
265
|
def attach(drive: str | Path, passphrase: str, name: str = "elesync-usb") -> dict:
|
|
261
266
|
"""Unlock the USB vault and wire it into Claude Desktop (or any MCP client).
|
|
262
267
|
|
|
@@ -333,6 +338,7 @@ def detach(drive: str | Path, name: str = "elesync-usb") -> dict:
|
|
|
333
338
|
cfg["mcpServers"] = servers
|
|
334
339
|
|
|
335
340
|
from datetime import datetime, timezone
|
|
341
|
+
|
|
336
342
|
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
337
343
|
bak = cfg_path.with_suffix(f".{ts}.bak")
|
|
338
344
|
bak.write_text(cfg_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
@@ -345,6 +351,7 @@ def detach(drive: str | Path, name: str = "elesync-usb") -> dict:
|
|
|
345
351
|
# Status
|
|
346
352
|
# ---------------------------------------------------------------------------
|
|
347
353
|
|
|
354
|
+
|
|
348
355
|
def status(drive: str | Path) -> dict:
|
|
349
356
|
"""Return a status snapshot for a drive (encrypted vault present, count, etc.)."""
|
|
350
357
|
from . import crypto
|
|
@@ -365,7 +372,7 @@ def status(drive: str | Path) -> dict:
|
|
|
365
372
|
if not result["vault_present"]:
|
|
366
373
|
return result
|
|
367
374
|
|
|
368
|
-
result["encrypted"] =
|
|
375
|
+
result["encrypted"] = crypto is not None and crypto.is_encrypted(vp)
|
|
369
376
|
result["note_count"] = len(list((vp / "notes").glob("*.md*")))
|
|
370
377
|
return result
|
|
371
378
|
|
|
@@ -374,6 +381,7 @@ def status(drive: str | Path) -> dict:
|
|
|
374
381
|
# Safe eject
|
|
375
382
|
# ---------------------------------------------------------------------------
|
|
376
383
|
|
|
384
|
+
|
|
377
385
|
def eject(drive: str | Path) -> str:
|
|
378
386
|
"""Attempt a safe-eject of the drive. Returns a status string."""
|
|
379
387
|
drive = str(Path(drive))
|
|
@@ -381,13 +389,15 @@ def eject(drive: str | Path) -> str:
|
|
|
381
389
|
if SYSTEM == "Darwin":
|
|
382
390
|
r = subprocess.run(
|
|
383
391
|
["diskutil", "eject", drive],
|
|
384
|
-
capture_output=True,
|
|
392
|
+
capture_output=True,
|
|
393
|
+
text=True,
|
|
394
|
+
check=False,
|
|
385
395
|
)
|
|
386
396
|
return r.stdout.strip() or r.stderr.strip() or "Ejected."
|
|
387
397
|
if SYSTEM == "Windows":
|
|
388
398
|
# PowerShell safe-remove via WMI
|
|
389
399
|
ps = (
|
|
390
|
-
f
|
|
400
|
+
f"$d=(New-Object -comObject Shell.Application).Namespace(17)"
|
|
391
401
|
f'.ParseName("{drive}"); $d.InvokeVerb("Eject")'
|
|
392
402
|
)
|
|
393
403
|
subprocess.run(["powershell", "-Command", ps], check=False, capture_output=True)
|
|
@@ -395,11 +405,15 @@ def eject(drive: str | Path) -> str:
|
|
|
395
405
|
# Linux — udisksctl
|
|
396
406
|
r = subprocess.run(
|
|
397
407
|
["udisksctl", "unmount", "-b", _linux_block_dev(drive)],
|
|
398
|
-
capture_output=True,
|
|
408
|
+
capture_output=True,
|
|
409
|
+
text=True,
|
|
410
|
+
check=False,
|
|
399
411
|
)
|
|
400
412
|
r2 = subprocess.run(
|
|
401
413
|
["udisksctl", "power-off", "-b", _linux_block_dev(drive)],
|
|
402
|
-
capture_output=True,
|
|
414
|
+
capture_output=True,
|
|
415
|
+
text=True,
|
|
416
|
+
check=False,
|
|
403
417
|
)
|
|
404
418
|
return (r.stdout + r2.stdout).strip() or "Eject request sent."
|
|
405
419
|
except FileNotFoundError:
|
|
@@ -410,6 +424,7 @@ def eject(drive: str | Path) -> str:
|
|
|
410
424
|
# Helpers
|
|
411
425
|
# ---------------------------------------------------------------------------
|
|
412
426
|
|
|
427
|
+
|
|
413
428
|
def _validate_key(vault_dir: Path, key: bytes) -> None:
|
|
414
429
|
"""Raise ValueError if the key cannot decrypt an existing note.
|
|
415
430
|
If there are no notes yet (empty vault), the key is accepted as valid."""
|
|
@@ -13,25 +13,35 @@ Zero extra dependencies — pure Python stdlib HTTP server.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
-
import mimetypes
|
|
17
16
|
import os
|
|
17
|
+
import sys
|
|
18
18
|
import threading
|
|
19
19
|
import webbrowser
|
|
20
20
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
21
21
|
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
22
23
|
from urllib.parse import parse_qs, urlparse
|
|
23
24
|
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .store import MemoryStore
|
|
27
|
+
|
|
24
28
|
# Module-level store reference — set by serve() before the server starts
|
|
25
|
-
_store = None
|
|
29
|
+
_store: MemoryStore | None = None
|
|
26
30
|
_vault_dir: str = ""
|
|
27
31
|
|
|
28
32
|
|
|
33
|
+
def _get_store() -> MemoryStore:
|
|
34
|
+
assert _store is not None, "store not initialised — call serve() first"
|
|
35
|
+
return _store
|
|
36
|
+
|
|
37
|
+
|
|
29
38
|
# ---------------------------------------------------------------------------
|
|
30
39
|
# REST API handlers
|
|
31
40
|
# ---------------------------------------------------------------------------
|
|
32
41
|
|
|
42
|
+
|
|
33
43
|
def _api_stats() -> dict:
|
|
34
|
-
return
|
|
44
|
+
return _get_store().stats()
|
|
35
45
|
|
|
36
46
|
|
|
37
47
|
def _api_memories(params: dict) -> dict:
|
|
@@ -42,10 +52,10 @@ def _api_memories(params: dict) -> dict:
|
|
|
42
52
|
offset = int(params.get("offset", ["0"])[0])
|
|
43
53
|
|
|
44
54
|
if q:
|
|
45
|
-
items =
|
|
55
|
+
items = _get_store().search(q, limit=limit + offset, source=source, mtype=mtype)
|
|
46
56
|
items = items[offset:]
|
|
47
57
|
else:
|
|
48
|
-
all_items =
|
|
58
|
+
all_items = _get_store().all()
|
|
49
59
|
if source:
|
|
50
60
|
all_items = [i for i in all_items if i.source == source]
|
|
51
61
|
if mtype:
|
|
@@ -68,16 +78,15 @@ def _api_memories(params: dict) -> dict:
|
|
|
68
78
|
|
|
69
79
|
|
|
70
80
|
def _api_memory_get(mem_id: str) -> dict | None:
|
|
71
|
-
item =
|
|
81
|
+
item = _get_store().get(mem_id)
|
|
72
82
|
if item is None:
|
|
73
83
|
return None
|
|
74
84
|
return _item_to_dict(item)
|
|
75
85
|
|
|
76
86
|
|
|
77
87
|
def _api_memory_update(mem_id: str, body: dict) -> dict | None:
|
|
78
|
-
from .models import MemoryItem
|
|
79
88
|
|
|
80
|
-
item =
|
|
89
|
+
item = _get_store().get(mem_id)
|
|
81
90
|
if item is None:
|
|
82
91
|
return None
|
|
83
92
|
# Update allowed fields
|
|
@@ -90,15 +99,16 @@ def _api_memory_update(mem_id: str, body: dict) -> dict | None:
|
|
|
90
99
|
item.type = body["type"]
|
|
91
100
|
if "scopes" in body:
|
|
92
101
|
from . import scopes as scopes_mod
|
|
102
|
+
|
|
93
103
|
item.scopes = scopes_mod.parse(body["scopes"])
|
|
94
104
|
# Delete old entry and re-insert
|
|
95
|
-
|
|
96
|
-
|
|
105
|
+
_get_store().delete(mem_id)
|
|
106
|
+
_get_store().upsert(item)
|
|
97
107
|
return _item_to_dict(item)
|
|
98
108
|
|
|
99
109
|
|
|
100
110
|
def _api_memory_delete(mem_id: str) -> bool:
|
|
101
|
-
return
|
|
111
|
+
return _get_store().delete(mem_id)
|
|
102
112
|
|
|
103
113
|
|
|
104
114
|
def _api_memory_add(body: dict) -> dict:
|
|
@@ -113,7 +123,7 @@ def _api_memory_add(body: dict) -> dict:
|
|
|
113
123
|
type=body.get("type", "note"),
|
|
114
124
|
tags=body.get("tags", []),
|
|
115
125
|
)
|
|
116
|
-
|
|
126
|
+
_get_store().upsert(item)
|
|
117
127
|
return _item_to_dict(item)
|
|
118
128
|
|
|
119
129
|
|
|
@@ -126,10 +136,10 @@ def _api_import(body: dict) -> dict:
|
|
|
126
136
|
if raw is None:
|
|
127
137
|
raise ValueError("data is required")
|
|
128
138
|
|
|
129
|
-
import
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
) as f:
|
|
139
|
+
import json as _json
|
|
140
|
+
import tempfile
|
|
141
|
+
|
|
142
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f:
|
|
133
143
|
_json.dump(raw, f)
|
|
134
144
|
tmp_path = f.name
|
|
135
145
|
try:
|
|
@@ -137,7 +147,7 @@ def _api_import(body: dict) -> dict:
|
|
|
137
147
|
finally:
|
|
138
148
|
Path(tmp_path).unlink(missing_ok=True)
|
|
139
149
|
|
|
140
|
-
result =
|
|
150
|
+
result = _get_store().import_many(items)
|
|
141
151
|
return result
|
|
142
152
|
|
|
143
153
|
|
|
@@ -160,6 +170,7 @@ def _item_to_dict(item) -> dict:
|
|
|
160
170
|
# HTTP request handler
|
|
161
171
|
# ---------------------------------------------------------------------------
|
|
162
172
|
|
|
173
|
+
|
|
163
174
|
class _Handler(BaseHTTPRequestHandler):
|
|
164
175
|
def log_message(self, fmt, *args):
|
|
165
176
|
pass # silence access logs
|
|
@@ -212,7 +223,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
212
223
|
elif path == "/api/memories":
|
|
213
224
|
self._send_json(_api_memories(params))
|
|
214
225
|
elif path.startswith("/api/memories/"):
|
|
215
|
-
mem_id = path[len("/api/memories/"):]
|
|
226
|
+
mem_id = path[len("/api/memories/") :]
|
|
216
227
|
item = _api_memory_get(mem_id)
|
|
217
228
|
if item is None:
|
|
218
229
|
self._send_error(404, "not found")
|
|
@@ -249,7 +260,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
249
260
|
path = parsed.path.rstrip("/")
|
|
250
261
|
|
|
251
262
|
if path.startswith("/api/memories/"):
|
|
252
|
-
mem_id = path[len("/api/memories/"):]
|
|
263
|
+
mem_id = path[len("/api/memories/") :]
|
|
253
264
|
try:
|
|
254
265
|
body = self._read_body()
|
|
255
266
|
item = _api_memory_update(mem_id, body)
|
|
@@ -267,7 +278,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
267
278
|
path = parsed.path.rstrip("/")
|
|
268
279
|
|
|
269
280
|
if path.startswith("/api/memories/"):
|
|
270
|
-
mem_id = path[len("/api/memories/"):]
|
|
281
|
+
mem_id = path[len("/api/memories/") :]
|
|
271
282
|
ok = _api_memory_delete(mem_id)
|
|
272
283
|
if ok:
|
|
273
284
|
self._send_json({"deleted": True})
|
|
@@ -281,11 +292,19 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
281
292
|
# Entry point
|
|
282
293
|
# ---------------------------------------------------------------------------
|
|
283
294
|
|
|
295
|
+
|
|
284
296
|
def serve(vault_dir: str | None = None, port: int = 7477, open_browser: bool = True) -> None:
|
|
285
297
|
global _store, _vault_dir
|
|
286
298
|
|
|
287
|
-
|
|
299
|
+
# Same UTF-8 stdout defence as cli.main — legacy Windows cp1252 can't encode
|
|
300
|
+
# the emoji we print below, and would crash with UnicodeEncodeError.
|
|
301
|
+
import contextlib
|
|
302
|
+
for stream in (sys.stdout, sys.stderr):
|
|
303
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
304
|
+
stream.reconfigure(encoding="utf-8")
|
|
305
|
+
|
|
288
306
|
from . import crypto as _crypto
|
|
307
|
+
from .store import MemoryStore
|
|
289
308
|
|
|
290
309
|
vault_path = Path(vault_dir or os.environ.get("ELESYNC_DIR", "~/EleSyncVault")).expanduser()
|
|
291
310
|
_vault_dir = str(vault_path)
|
|
@@ -294,11 +313,14 @@ def serve(vault_dir: str | None = None, port: int = 7477, open_browser: bool = T
|
|
|
294
313
|
key = None
|
|
295
314
|
if _crypto.available() and _crypto.is_encrypted(vault_path):
|
|
296
315
|
import getpass
|
|
316
|
+
|
|
297
317
|
passphrase = getpass.getpass("🔑 Vault passphrase: ")
|
|
298
318
|
meta = _crypto.read_meta(vault_path)
|
|
319
|
+
assert meta is not None
|
|
299
320
|
key = _crypto.derive_key(
|
|
300
321
|
passphrase,
|
|
301
|
-
bytes.fromhex(meta["salt"])
|
|
322
|
+
bytes.fromhex(meta["salt"])
|
|
323
|
+
if isinstance(meta["salt"], str) and len(meta["salt"]) == 64
|
|
302
324
|
else __import__("base64").b64decode(meta["salt"]),
|
|
303
325
|
meta["opslimit"],
|
|
304
326
|
meta["memlimit"],
|
|
@@ -320,7 +342,7 @@ def serve(vault_dir: str | None = None, port: int = 7477, open_browser: bool = T
|
|
|
320
342
|
except KeyboardInterrupt:
|
|
321
343
|
pass
|
|
322
344
|
finally:
|
|
323
|
-
|
|
345
|
+
_get_store().close()
|
|
324
346
|
print("\n👋 EleSync web server stopped.")
|
|
325
347
|
|
|
326
348
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elesync
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: Local-first, MCP-native unified memory vault — your AI memory as files you own, shared across every model.
|
|
5
5
|
Author: Alphanymous
|
|
6
6
|
License: MIT
|
|
@@ -173,7 +173,7 @@ pip install "elesync[mcp]"
|
|
|
173
173
|
> No Python yet? Download from **[python.org/downloads](https://www.python.org/downloads/)** — on Windows tick **"Add Python to PATH"** during install. If `pip` isn't found: use `py -m pip …` (Windows) or `python3 -m pip …` (Mac/Linux).
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
ele --version # → EleSync 1.
|
|
176
|
+
ele --version # → EleSync 1.3.0
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
**2 · Import your memories from ChatGPT / Claude / Gemini**
|
|
@@ -560,7 +560,7 @@ Still stuck? Open an issue at **[github.com/darknodebros/EleSync/issues](https:/
|
|
|
560
560
|
## Tests
|
|
561
561
|
|
|
562
562
|
```bash
|
|
563
|
-
python -m unittest discover -s tests -v #
|
|
563
|
+
python -m unittest discover -s tests -v # 179 tests, stdlib only — no MCP SDK required
|
|
564
564
|
```
|
|
565
565
|
|
|
566
566
|
## Where the export files come from (2026 reality)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "elesync"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.2"
|
|
8
8
|
description = "Local-first, MCP-native unified memory vault — your AI memory as files you own, shared across every model."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -11,7 +11,7 @@ import json
|
|
|
11
11
|
import tempfile
|
|
12
12
|
import unittest
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from unittest.mock import
|
|
14
|
+
from unittest.mock import patch
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class FakeCrypto:
|
|
@@ -241,9 +241,7 @@ class TestUsbDetach(unittest.TestCase):
|
|
|
241
241
|
self.tmp.cleanup()
|
|
242
242
|
|
|
243
243
|
def _write_cfg(self, servers: dict):
|
|
244
|
-
self.cfg.write_text(
|
|
245
|
-
json.dumps({"mcpServers": servers}, indent=2), encoding="utf-8"
|
|
246
|
-
)
|
|
244
|
+
self.cfg.write_text(json.dumps({"mcpServers": servers}, indent=2), encoding="utf-8")
|
|
247
245
|
|
|
248
246
|
def test_detach_removes_server_entry(self):
|
|
249
247
|
import elesync.usb as usb_mod
|
|
@@ -377,7 +375,7 @@ class TestUsbCLI(unittest.TestCase):
|
|
|
377
375
|
def test_usb_eject_no_crash(self):
|
|
378
376
|
import elesync.usb as usb_mod
|
|
379
377
|
|
|
380
|
-
with patch.object(usb_mod, "eject", return_value="Eject sent.")
|
|
378
|
+
with patch.object(usb_mod, "eject", return_value="Eject sent."):
|
|
381
379
|
rc = self._run(["usb", "eject", str(self.drive)])
|
|
382
380
|
self.assertEqual(rc, 0)
|
|
383
381
|
|
|
@@ -13,9 +13,8 @@ import threading
|
|
|
13
13
|
import unittest
|
|
14
14
|
from http.server import HTTPServer
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from urllib.request import urlopen
|
|
17
16
|
from urllib.error import HTTPError
|
|
18
|
-
from urllib.request import Request
|
|
17
|
+
from urllib.request import Request, urlopen
|
|
19
18
|
|
|
20
19
|
|
|
21
20
|
def _url(port: int, path: str) -> str:
|
|
@@ -32,7 +31,9 @@ def _get(port: int, path: str) -> tuple[int, dict]:
|
|
|
32
31
|
|
|
33
32
|
def _post(port: int, path: str, body: dict) -> tuple[int, dict]:
|
|
34
33
|
data = json.dumps(body).encode()
|
|
35
|
-
req = Request(
|
|
34
|
+
req = Request(
|
|
35
|
+
_url(port, path), data=data, headers={"Content-Type": "application/json"}, method="POST"
|
|
36
|
+
)
|
|
36
37
|
try:
|
|
37
38
|
with urlopen(req, timeout=5) as r:
|
|
38
39
|
return r.status, json.loads(r.read())
|
|
@@ -42,7 +43,9 @@ def _post(port: int, path: str, body: dict) -> tuple[int, dict]:
|
|
|
42
43
|
|
|
43
44
|
def _put(port: int, path: str, body: dict) -> tuple[int, dict]:
|
|
44
45
|
data = json.dumps(body).encode()
|
|
45
|
-
req = Request(
|
|
46
|
+
req = Request(
|
|
47
|
+
_url(port, path), data=data, headers={"Content-Type": "application/json"}, method="PUT"
|
|
48
|
+
)
|
|
46
49
|
try:
|
|
47
50
|
with urlopen(req, timeout=5) as r:
|
|
48
51
|
return r.status, json.loads(r.read())
|
|
@@ -86,6 +89,7 @@ class WebServerTestCase(unittest.TestCase):
|
|
|
86
89
|
def tearDownClass(cls):
|
|
87
90
|
cls.server.shutdown()
|
|
88
91
|
import elesync.web as web_mod
|
|
92
|
+
|
|
89
93
|
if web_mod._store:
|
|
90
94
|
web_mod._store.close()
|
|
91
95
|
cls.vault_tmp.cleanup()
|
|
@@ -95,6 +99,7 @@ class WebServerTestCase(unittest.TestCase):
|
|
|
95
99
|
# Static routes
|
|
96
100
|
# ---------------------------------------------------------------------------
|
|
97
101
|
|
|
102
|
+
|
|
98
103
|
class TestStaticRoutes(WebServerTestCase):
|
|
99
104
|
def test_root_returns_html(self):
|
|
100
105
|
with urlopen(_url(self.port, "/"), timeout=5) as r:
|
|
@@ -118,6 +123,7 @@ class TestStaticRoutes(WebServerTestCase):
|
|
|
118
123
|
# /api/vault
|
|
119
124
|
# ---------------------------------------------------------------------------
|
|
120
125
|
|
|
126
|
+
|
|
121
127
|
class TestVaultEndpoint(WebServerTestCase):
|
|
122
128
|
def test_vault_returns_path(self):
|
|
123
129
|
code, data = _get(self.port, "/api/vault")
|
|
@@ -130,6 +136,7 @@ class TestVaultEndpoint(WebServerTestCase):
|
|
|
130
136
|
# /api/stats
|
|
131
137
|
# ---------------------------------------------------------------------------
|
|
132
138
|
|
|
139
|
+
|
|
133
140
|
class TestStatsEndpoint(WebServerTestCase):
|
|
134
141
|
def test_stats_shape(self):
|
|
135
142
|
code, data = _get(self.port, "/api/stats")
|
|
@@ -147,14 +154,19 @@ class TestStatsEndpoint(WebServerTestCase):
|
|
|
147
154
|
# POST /api/memories (add)
|
|
148
155
|
# ---------------------------------------------------------------------------
|
|
149
156
|
|
|
157
|
+
|
|
150
158
|
class TestAddMemory(WebServerTestCase):
|
|
151
159
|
def test_add_returns_201(self):
|
|
152
|
-
code, data = _post(
|
|
153
|
-
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
code, data = _post(
|
|
161
|
+
self.port,
|
|
162
|
+
"/api/memories",
|
|
163
|
+
{
|
|
164
|
+
"content": "Test memory from web test",
|
|
165
|
+
"source": "manual",
|
|
166
|
+
"type": "note",
|
|
167
|
+
"tags": ["test"],
|
|
168
|
+
},
|
|
169
|
+
)
|
|
158
170
|
self.assertEqual(code, 201)
|
|
159
171
|
self.assertIn("id", data)
|
|
160
172
|
self.assertEqual(data["content"], "Test memory from web test")
|
|
@@ -173,17 +185,22 @@ class TestAddMemory(WebServerTestCase):
|
|
|
173
185
|
# GET /api/memories
|
|
174
186
|
# ---------------------------------------------------------------------------
|
|
175
187
|
|
|
188
|
+
|
|
176
189
|
class TestListMemories(WebServerTestCase):
|
|
177
190
|
@classmethod
|
|
178
191
|
def setUpClass(cls):
|
|
179
192
|
super().setUpClass()
|
|
180
193
|
# Seed a few memories
|
|
181
194
|
for i in range(3):
|
|
182
|
-
_post(
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
_post(
|
|
196
|
+
cls.port,
|
|
197
|
+
"/api/memories",
|
|
198
|
+
{
|
|
199
|
+
"content": f"Seeded memory number {i}",
|
|
200
|
+
"source": "chatgpt" if i % 2 == 0 else "claude",
|
|
201
|
+
"type": "fact",
|
|
202
|
+
},
|
|
203
|
+
)
|
|
187
204
|
|
|
188
205
|
def test_list_returns_items(self):
|
|
189
206
|
code, data = _get(self.port, "/api/memories")
|
|
@@ -210,15 +227,20 @@ class TestListMemories(WebServerTestCase):
|
|
|
210
227
|
# GET /api/memories/<id>
|
|
211
228
|
# ---------------------------------------------------------------------------
|
|
212
229
|
|
|
230
|
+
|
|
213
231
|
class TestGetMemory(WebServerTestCase):
|
|
214
232
|
@classmethod
|
|
215
233
|
def setUpClass(cls):
|
|
216
234
|
super().setUpClass()
|
|
217
|
-
_, added = _post(
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
|
|
221
|
-
|
|
235
|
+
_, added = _post(
|
|
236
|
+
cls.port,
|
|
237
|
+
"/api/memories",
|
|
238
|
+
{
|
|
239
|
+
"content": "Memory for get test",
|
|
240
|
+
"source": "manual",
|
|
241
|
+
"type": "note",
|
|
242
|
+
},
|
|
243
|
+
)
|
|
222
244
|
cls.mem_id = added["id"]
|
|
223
245
|
|
|
224
246
|
def test_get_existing(self):
|
|
@@ -236,28 +258,29 @@ class TestGetMemory(WebServerTestCase):
|
|
|
236
258
|
# PUT /api/memories/<id> (update)
|
|
237
259
|
# ---------------------------------------------------------------------------
|
|
238
260
|
|
|
261
|
+
|
|
239
262
|
class TestUpdateMemory(WebServerTestCase):
|
|
240
263
|
@classmethod
|
|
241
264
|
def setUpClass(cls):
|
|
242
265
|
super().setUpClass()
|
|
243
|
-
_, added = _post(
|
|
244
|
-
|
|
245
|
-
"
|
|
246
|
-
|
|
247
|
-
|
|
266
|
+
_, added = _post(
|
|
267
|
+
cls.port,
|
|
268
|
+
"/api/memories",
|
|
269
|
+
{
|
|
270
|
+
"content": "Original content",
|
|
271
|
+
"source": "manual",
|
|
272
|
+
"type": "note",
|
|
273
|
+
},
|
|
274
|
+
)
|
|
248
275
|
cls.mem_id = added["id"]
|
|
249
276
|
|
|
250
277
|
def test_update_content(self):
|
|
251
|
-
code, data = _put(self.port, f"/api/memories/{self.mem_id}", {
|
|
252
|
-
"content": "Updated content"
|
|
253
|
-
})
|
|
278
|
+
code, data = _put(self.port, f"/api/memories/{self.mem_id}", {"content": "Updated content"})
|
|
254
279
|
self.assertEqual(code, 200)
|
|
255
280
|
self.assertEqual(data["content"], "Updated content")
|
|
256
281
|
|
|
257
282
|
def test_update_tags(self):
|
|
258
|
-
_, data = _put(self.port, f"/api/memories/{self.mem_id}", {
|
|
259
|
-
"tags": ["newtag", "another"]
|
|
260
|
-
})
|
|
283
|
+
_, data = _put(self.port, f"/api/memories/{self.mem_id}", {"tags": ["newtag", "another"]})
|
|
261
284
|
self.assertIn("newtag", data["tags"])
|
|
262
285
|
|
|
263
286
|
def test_update_nonexistent_404(self):
|
|
@@ -269,13 +292,18 @@ class TestUpdateMemory(WebServerTestCase):
|
|
|
269
292
|
# DELETE /api/memories/<id>
|
|
270
293
|
# ---------------------------------------------------------------------------
|
|
271
294
|
|
|
295
|
+
|
|
272
296
|
class TestDeleteMemory(WebServerTestCase):
|
|
273
297
|
def test_delete_existing(self):
|
|
274
|
-
_, added = _post(
|
|
275
|
-
|
|
276
|
-
"
|
|
277
|
-
|
|
278
|
-
|
|
298
|
+
_, added = _post(
|
|
299
|
+
self.port,
|
|
300
|
+
"/api/memories",
|
|
301
|
+
{
|
|
302
|
+
"content": "Memory to be deleted",
|
|
303
|
+
"source": "manual",
|
|
304
|
+
"type": "note",
|
|
305
|
+
},
|
|
306
|
+
)
|
|
279
307
|
mem_id = added["id"]
|
|
280
308
|
code, data = _delete(self.port, f"/api/memories/{mem_id}")
|
|
281
309
|
self.assertEqual(code, 200)
|
|
@@ -293,6 +321,7 @@ class TestDeleteMemory(WebServerTestCase):
|
|
|
293
321
|
# POST /api/import
|
|
294
322
|
# ---------------------------------------------------------------------------
|
|
295
323
|
|
|
324
|
+
|
|
296
325
|
class TestImport(WebServerTestCase):
|
|
297
326
|
def test_import_chatgpt_format(self):
|
|
298
327
|
payload = {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|