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.
Files changed (37) hide show
  1. {elesync-1.3.0 → elesync-1.3.2}/PKG-INFO +3 -3
  2. {elesync-1.3.0 → elesync-1.3.2}/README.md +2 -2
  3. {elesync-1.3.0 → elesync-1.3.2}/elesync/__init__.py +1 -1
  4. {elesync-1.3.0 → elesync-1.3.2}/elesync/cli.py +13 -8
  5. {elesync-1.3.0 → elesync-1.3.2}/elesync/embeddings.py +13 -2
  6. {elesync-1.3.0 → elesync-1.3.2}/elesync/summary.py +2 -1
  7. {elesync-1.3.0 → elesync-1.3.2}/elesync/usb.py +30 -15
  8. {elesync-1.3.0 → elesync-1.3.2}/elesync/web.py +45 -23
  9. {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/PKG-INFO +3 -3
  10. {elesync-1.3.0 → elesync-1.3.2}/pyproject.toml +1 -1
  11. {elesync-1.3.0 → elesync-1.3.2}/tests/test_usb.py +3 -5
  12. {elesync-1.3.0 → elesync-1.3.2}/tests/test_web.py +65 -36
  13. {elesync-1.3.0 → elesync-1.3.2}/LICENSE +0 -0
  14. {elesync-1.3.0 → elesync-1.3.2}/elesync/conflicts.py +0 -0
  15. {elesync-1.3.0 → elesync-1.3.2}/elesync/crypto.py +0 -0
  16. {elesync-1.3.0 → elesync-1.3.2}/elesync/mcp_server.py +0 -0
  17. {elesync-1.3.0 → elesync-1.3.2}/elesync/models.py +0 -0
  18. {elesync-1.3.0 → elesync-1.3.2}/elesync/normalize.py +0 -0
  19. {elesync-1.3.0 → elesync-1.3.2}/elesync/onboarding.py +0 -0
  20. {elesync-1.3.0 → elesync-1.3.2}/elesync/scopes.py +0 -0
  21. {elesync-1.3.0 → elesync-1.3.2}/elesync/service.py +0 -0
  22. {elesync-1.3.0 → elesync-1.3.2}/elesync/store.py +0 -0
  23. {elesync-1.3.0 → elesync-1.3.2}/elesync/watch.py +0 -0
  24. {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/SOURCES.txt +0 -0
  25. {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/dependency_links.txt +0 -0
  26. {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/entry_points.txt +0 -0
  27. {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/requires.txt +0 -0
  28. {elesync-1.3.0 → elesync-1.3.2}/elesync.egg-info/top_level.txt +0 -0
  29. {elesync-1.3.0 → elesync-1.3.2}/setup.cfg +0 -0
  30. {elesync-1.3.0 → elesync-1.3.2}/tests/test_conflicts.py +0 -0
  31. {elesync-1.3.0 → elesync-1.3.2}/tests/test_encryption.py +0 -0
  32. {elesync-1.3.0 → elesync-1.3.2}/tests/test_scopes.py +0 -0
  33. {elesync-1.3.0 → elesync-1.3.2}/tests/test_semantic.py +0 -0
  34. {elesync-1.3.0 → elesync-1.3.2}/tests/test_service.py +0 -0
  35. {elesync-1.3.0 → elesync-1.3.2}/tests/test_summary.py +0 -0
  36. {elesync-1.3.0 → elesync-1.3.2}/tests/test_vault.py +0 -0
  37. {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.0
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.1.2
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 # 134 tests, stdlib only — no MCP SDK required
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.1.2
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 # 134 tests, stdlib only — no MCP SDK required
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)
@@ -9,4 +9,4 @@ from .service import MemoryService
9
9
  from .store import MemoryStore
10
10
 
11
11
  __all__ = ["MemoryItem", "MEMORY_TYPES", "MemoryStore", "MemoryService", "normalize"]
12
- __version__ = "1.3.0"
12
+ __version__ = "1.3.2"
@@ -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 requested but the extra is not installed "
52
- 'run: pip install "elesync[semantic]". Falling back to keyword search.',
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
- 'The semantic extra is not installed. Run: pip install "elesync[semantic]"',
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(f" Encrypted: yes (XSalsa20-Poly1305 + argon2id)")
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(f" On another machine: plug in the drive and run python RUN_ME.py")
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(f"✅ USB vault disconnected from Claude Desktop.")
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(f"Launcher: {'present' if s['launcher_present'] else 'missing — run ele usb install'}")
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.vault,
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 an `Embedder` if the `semantic` extra is installed, else None."""
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 ImportError:
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
- ["wmic", "logicaldisk", "where", "DriveType=2", "get",
142
- "DeviceID,VolumeName,Size,FileSystem", "/format:csv"],
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
- dev, mp, fs = parts[0], parts[1], parts[2]
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"] = (crypto is not None and crypto.is_encrypted(vp))
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, text=True, check=False,
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'$d=(New-Object -comObject Shell.Application).Namespace(17)'
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, text=True, check=False,
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, text=True, check=False,
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 _store.stats()
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 = _store.search(q, limit=limit + offset, source=source, mtype=mtype)
55
+ items = _get_store().search(q, limit=limit + offset, source=source, mtype=mtype)
46
56
  items = items[offset:]
47
57
  else:
48
- all_items = _store.all()
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 = _store.get(mem_id)
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 = _store.get(mem_id)
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
- _store.delete(mem_id)
96
- _store.upsert(item)
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 _store.delete(mem_id)
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
- _store.upsert(item)
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 tempfile, json as _json
130
- with tempfile.NamedTemporaryFile(
131
- mode="w", suffix=".json", delete=False, encoding="utf-8"
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 = _store.import_many(items)
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
- from .store import MemoryStore
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"]) if isinstance(meta["salt"], str) and len(meta["salt"]) == 64
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
- _store.close()
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.0
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.1.2
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 # 134 tests, stdlib only — no MCP SDK required
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.0"
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 MagicMock, patch
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.") as mock_eject:
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(_url(port, path), data=data, headers={"Content-Type": "application/json"}, method="POST")
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(_url(port, path), data=data, headers={"Content-Type": "application/json"}, method="PUT")
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(self.port, "/api/memories", {
153
- "content": "Test memory from web test",
154
- "source": "manual",
155
- "type": "note",
156
- "tags": ["test"],
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(cls.port, "/api/memories", {
183
- "content": f"Seeded memory number {i}",
184
- "source": "chatgpt" if i % 2 == 0 else "claude",
185
- "type": "fact",
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(cls.port, "/api/memories", {
218
- "content": "Memory for get test",
219
- "source": "manual",
220
- "type": "note",
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(cls.port, "/api/memories", {
244
- "content": "Original content",
245
- "source": "manual",
246
- "type": "note",
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(self.port, "/api/memories", {
275
- "content": "Memory to be deleted",
276
- "source": "manual",
277
- "type": "note",
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