aether-cache 0.1.3__tar.gz → 0.1.5__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 (25) hide show
  1. aether_cache-0.1.5/LICENSE +21 -0
  2. {aether_cache-0.1.3 → aether_cache-0.1.5}/PKG-INFO +14 -5
  3. {aether_cache-0.1.3 → aether_cache-0.1.5}/README.md +11 -4
  4. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/__init__.py +1 -1
  5. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/cli.py +44 -6
  6. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/engine.py +36 -14
  7. aether_cache-0.1.5/aether_cache/javart.py +174 -0
  8. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/portability.py +70 -13
  9. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/index.html +4 -1
  10. {aether_cache-0.1.3 → aether_cache-0.1.5}/pyproject.toml +5 -3
  11. {aether_cache-0.1.3 → aether_cache-0.1.5}/.gitignore +0 -0
  12. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/__main__.py +0 -0
  13. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/config.py +0 -0
  14. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/connect.py +0 -0
  15. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/engine/aether-engine.jar +0 -0
  16. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/repos.py +0 -0
  17. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/server.py +0 -0
  18. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/OFL.txt +0 -0
  19. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  20. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  21. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  22. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  23. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  24. {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui.py +0 -0
  25. {aether_cache-0.1.3 → aether_cache-0.1.5}/docs/README.md +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anish Chugh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aether-cache
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Shared memory for AI coding tools that stays true to your code.
5
5
  Project-URL: Homepage, https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/
6
6
  Project-URL: Repository, https://github.com/anishchugh05/Aether-Cache-Redis-Compatible-Vector-Store
7
7
  Author: Anish Chugh
8
+ License-Expression: MIT
9
+ License-File: LICENSE
8
10
  Requires-Python: >=3.10
9
11
  Requires-Dist: fastembed>=0.3
10
12
  Requires-Dist: mcp>=1.2
@@ -104,9 +106,8 @@ m1 retired; m3 replaces it.
104
106
 
105
107
  ## Quick start
106
108
 
107
- You need **Java 11+** (`brew install openjdk@21` on macOS, or [adoptium.net](https://adoptium.net)) and
108
- [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes care of
109
- Python for you. Then:
109
+ You need [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes
110
+ care of Python for you. Then:
110
111
 
111
112
  ```sh
112
113
  uv tool install aether-cache
@@ -121,6 +122,10 @@ repo and try:
121
122
 
122
123
  Then start a new session and ask how to run the tests.
123
124
 
125
+ The engine runs on Java 11+. If you have it, it's used. If you don't, `setup` downloads a private copy (about
126
+ 50 MB) into `~/.aether-cache/java`, checks it against its published checksum, and uses it only for Aether Cache;
127
+ nothing is installed system-wide, and `aether-cache uninstall` removes it.
128
+
124
129
  Already keep notes for your AI in `CLAUDE.md`, `AGENTS.md`, or Cursor rules? Run `aether-cache import` in the repo
125
130
  (or use **Import notes** in the dashboard) to turn them into memories, linked to the files they mention.
126
131
 
@@ -354,6 +359,7 @@ src/main/java/Main.java the engine (single file, from the CodeCrafters setu
354
359
  aether_cache/ the Python package behind the `aether-cache` command
355
360
  server.py MCP server: embeddings, git staleness, AGENTS.md/CLAUDE.md sync
356
361
  engine.py finds Java, starts and stops the engine in the background
362
+ javart.py downloads a private copy of Java when the computer has none
357
363
  connect.py registers Aether Cache with Claude Code, Cursor, and Codex
358
364
  cli.py the `aether-cache` command
359
365
  ui.py, ui/index.html the dashboard (`aether-cache ui`): a local server, its API, and the page
@@ -362,4 +368,7 @@ aether_cache/ the Python package behind the `aether-cache` comman
362
368
  memory_mcp/calibrate.py similarity calibration for duplicate/contradiction thresholds
363
369
  vector_bench.py HNSW benchmark (writes results.json)
364
370
  crash_test.py kill -9 crash-safety test
365
- ```
371
+ ```
372
+ ## License
373
+
374
+ MIT. See [LICENSE](LICENSE).
@@ -91,9 +91,8 @@ m1 retired; m3 replaces it.
91
91
 
92
92
  ## Quick start
93
93
 
94
- You need **Java 11+** (`brew install openjdk@21` on macOS, or [adoptium.net](https://adoptium.net)) and
95
- [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes care of
96
- Python for you. Then:
94
+ You need [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes
95
+ care of Python for you. Then:
97
96
 
98
97
  ```sh
99
98
  uv tool install aether-cache
@@ -108,6 +107,10 @@ repo and try:
108
107
 
109
108
  Then start a new session and ask how to run the tests.
110
109
 
110
+ The engine runs on Java 11+. If you have it, it's used. If you don't, `setup` downloads a private copy (about
111
+ 50 MB) into `~/.aether-cache/java`, checks it against its published checksum, and uses it only for Aether Cache;
112
+ nothing is installed system-wide, and `aether-cache uninstall` removes it.
113
+
111
114
  Already keep notes for your AI in `CLAUDE.md`, `AGENTS.md`, or Cursor rules? Run `aether-cache import` in the repo
112
115
  (or use **Import notes** in the dashboard) to turn them into memories, linked to the files they mention.
113
116
 
@@ -341,6 +344,7 @@ src/main/java/Main.java the engine (single file, from the CodeCrafters setu
341
344
  aether_cache/ the Python package behind the `aether-cache` command
342
345
  server.py MCP server: embeddings, git staleness, AGENTS.md/CLAUDE.md sync
343
346
  engine.py finds Java, starts and stops the engine in the background
347
+ javart.py downloads a private copy of Java when the computer has none
344
348
  connect.py registers Aether Cache with Claude Code, Cursor, and Codex
345
349
  cli.py the `aether-cache` command
346
350
  ui.py, ui/index.html the dashboard (`aether-cache ui`): a local server, its API, and the page
@@ -349,4 +353,7 @@ aether_cache/ the Python package behind the `aether-cache` comman
349
353
  memory_mcp/calibrate.py similarity calibration for duplicate/contradiction thresholds
350
354
  vector_bench.py HNSW benchmark (writes results.json)
351
355
  crash_test.py kill -9 crash-safety test
352
- ```
356
+ ```
357
+ ## License
358
+
359
+ MIT. See [LICENSE](LICENSE).
@@ -1,3 +1,3 @@
1
1
  """Aether Cache: shared memory for AI coding tools that stays true to your code."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.5"
@@ -17,11 +17,38 @@ def _say(mark: str, text: str) -> None:
17
17
 
18
18
  # ---------- commands ----------
19
19
 
20
+ def _java_label(java: str) -> str:
21
+ return f"private copy in {java_dir()}" if engine.is_private(java) else java
22
+
23
+
24
+ def java_dir():
25
+ from .javart import JAVA_DIR
26
+ return JAVA_DIR
27
+
28
+
29
+ def _java_for_setup():
30
+ """The computer's own Java if it has a recent enough one; otherwise downloads a private copy."""
31
+ try:
32
+ return engine.find_java()
33
+ except engine.EngineError as missing:
34
+ from . import javart
35
+ reason = str(missing).splitlines()[0] # "Java isn't installed." or "Found Java 8, which is too old."
36
+ print(f"[ .... ] {reason} Downloading a private copy of Java {javart.FEATURE_VERSION} for Aether Cache "
37
+ f"(about 50 MB, only the first time).\n"
38
+ f" It goes in {javart.JAVA_DIR}, only Aether Cache uses it, and nothing else on your "
39
+ f"computer changes.", flush=True)
40
+ try:
41
+ javart.download()
42
+ except javart.JavaDownloadError as e:
43
+ raise engine.EngineError(str(e))
44
+ return engine.find_java()
45
+
46
+
20
47
  def cmd_setup(args) -> int:
21
48
  print(f"Setting up Aether Cache {__version__}\n")
22
49
  try:
23
- java, major = engine.find_java()
24
- _say(OK, f"Java {major} ({java})")
50
+ java, major = _java_for_setup()
51
+ _say(OK, f"Java {major} ({_java_label(java)})")
25
52
  _say(OK, engine.start())
26
53
  except engine.EngineError as e:
27
54
  _say(FAIL, str(e))
@@ -31,7 +58,10 @@ def cmd_setup(args) -> int:
31
58
  if config.FAKE_EMBEDDER:
32
59
  _say(OK, "Embedding model: test mode, nothing to download")
33
60
  else:
34
- print("[ .... ] Downloading the embedding model (about 90 MB, only the first time)...", flush=True)
61
+ if any(config.MODELS_DIR.glob("*")):
62
+ print("[ .... ] Loading the embedding model...", flush=True)
63
+ else:
64
+ print("[ .... ] Downloading the embedding model (about 90 MB, only the first time)...", flush=True)
35
65
  try:
36
66
  from .server import embed
37
67
  embed("warm up")
@@ -121,7 +151,7 @@ def cmd_doctor(args) -> int:
121
151
  _say(OK, f"Aether Cache {__version__}, Python {sys.version.split()[0]} ({sys.executable})")
122
152
  try:
123
153
  java, major = engine.find_java()
124
- _say(OK, f"Java {major} ({java})")
154
+ _say(OK, f"Java {major} ({_java_label(java)})")
125
155
  except engine.EngineError as e:
126
156
  _say(FAIL, str(e))
127
157
  problems += 1
@@ -233,8 +263,13 @@ def cmd_import(args) -> int:
233
263
  if left_out:
234
264
  print(f"\nLeft out ({len(left_out)}):")
235
265
  for n in left_out:
236
- why = (f"mentions {', '.join(n['missing'])}, which doesn't exist (probably out of date)"
237
- if n["missing"] else "doesn't look like a fact about the project")
266
+ if n["similar"]:
267
+ what = n["similar"]["id"] or "another note here"
268
+ why = f"very similar to {what}: \"{n['similar']['text']}\""
269
+ elif n["missing"]:
270
+ why = f"mentions {', '.join(n['missing'])}, which doesn't exist (probably out of date)"
271
+ else:
272
+ why = "doesn't look like a fact about the project"
238
273
  print(f" - {n['text']}\n {why}")
239
274
  if known:
240
275
  print(f"\nAlready remembered: {len(known)}")
@@ -329,6 +364,9 @@ def cmd_uninstall(args) -> int:
329
364
  shutil.rmtree(config.HOME, ignore_errors=True)
330
365
  _say(OK, f"Deleted {config.HOME}")
331
366
  elif has_data:
367
+ from . import javart
368
+ if javart.remove():
369
+ _say(OK, f"Removed Aether Cache's private copy of Java ({javart.JAVA_DIR})")
332
370
  _say(OK, f"Kept your memories in {config.HOME}. Reinstall any time and they're back.")
333
371
  print(f"\nLast step, remove the program itself:\n {_uninstall_command()}")
334
372
  return 1 if failed else 0
@@ -34,30 +34,44 @@ def java_install_hint() -> str:
34
34
  how = "winget install EclipseAdoptium.Temurin.21.JRE (or download it from https://adoptium.net)"
35
35
  else:
36
36
  how = "sudo apt install openjdk-21-jre-headless (or your distro's equivalent, or https://adoptium.net)"
37
- return f"Aether Cache needs Java {MIN_JAVA} or newer. Install it with:\n {how}"
37
+ return (f"Run `aether-cache setup` and it downloads a private copy of Java for you, "
38
+ f"or install Java {MIN_JAVA}+ yourself:\n {how}")
39
+
40
+
41
+ def _java_version(java: str) -> Optional[int]:
42
+ try:
43
+ out = subprocess.run([java, "-version"], capture_output=True, text=True, timeout=15)
44
+ except (OSError, subprocess.SubprocessError):
45
+ return None
46
+ match = re.search(r'version "(\d+)(?:\.(\d+))?', out.stderr + out.stdout)
47
+ if out.returncode != 0 or not match:
48
+ return None
49
+ major = int(match.group(1))
50
+ if major == 1 and match.group(2): # old-style "1.8.0" means Java 8
51
+ major = int(match.group(2))
52
+ return major
38
53
 
39
54
 
40
55
  def find_java() -> Tuple[str, int]:
41
56
  """(path to a working java, its major version). Raises EngineError if there's no usable Java.
57
+ Prefers the computer's own Java; falls back to the private copy `setup` downloads when there isn't one.
42
58
  Runs `java -version` rather than trusting the PATH: macOS ships a /usr/bin/java stub that only
43
59
  says "Unable to locate a Java Runtime"."""
60
+ from .javart import private_java
44
61
  candidates = []
45
- if os.environ.get("JAVA_HOME"):
46
- candidates.append(str(Path(os.environ["JAVA_HOME"]) / "bin" / ("java.exe" if os.name == "nt" else "java")))
47
- if shutil.which("java"):
48
- candidates.append(shutil.which("java"))
62
+ if not os.environ.get("AETHER_IGNORE_SYSTEM_JAVA"): # testing only: act as if this computer had no Java
63
+ if os.environ.get("JAVA_HOME"):
64
+ candidates.append(str(Path(os.environ["JAVA_HOME"]) / "bin" / ("java.exe" if os.name == "nt" else "java")))
65
+ if shutil.which("java"):
66
+ candidates.append(shutil.which("java"))
67
+ private = private_java()
68
+ if private:
69
+ candidates.append(str(private))
49
70
  too_old = None
50
71
  for java in candidates:
51
- try:
52
- out = subprocess.run([java, "-version"], capture_output=True, text=True, timeout=15)
53
- except (OSError, subprocess.SubprocessError):
72
+ major = _java_version(java)
73
+ if major is None:
54
74
  continue
55
- match = re.search(r'version "(\d+)(?:\.(\d+))?', out.stderr + out.stdout)
56
- if out.returncode != 0 or not match:
57
- continue
58
- major = int(match.group(1))
59
- if major == 1 and match.group(2): # old-style "1.8.0" means Java 8
60
- major = int(match.group(2))
61
75
  if major >= MIN_JAVA:
62
76
  return java, major
63
77
  too_old = major
@@ -66,6 +80,14 @@ def find_java() -> Tuple[str, int]:
66
80
  raise EngineError("Java isn't installed.\n" + java_install_hint())
67
81
 
68
82
 
83
+ def is_private(java: str) -> bool:
84
+ from .javart import JAVA_DIR
85
+ try:
86
+ return Path(java).resolve().is_relative_to(JAVA_DIR.resolve())
87
+ except (OSError, ValueError):
88
+ return False
89
+
90
+
69
91
  # ---------- talking to the engine ----------
70
92
 
71
93
  def client(timeout: Optional[float] = None) -> redis.Redis:
@@ -0,0 +1,174 @@
1
+ """
2
+ A private copy of Java, for people who don't have it.
3
+
4
+ The engine needs Java 11 or newer. If this computer has one, it's used and nothing is downloaded. If not,
5
+ `aether-cache setup` downloads Eclipse Temurin (the standard free build of Java) into ~/.aether-cache/java,
6
+ checks the download against the checksum its publisher lists, and uses it only for Aether Cache: nothing is
7
+ installed system-wide, and `aether-cache uninstall` removes it.
8
+ """
9
+ import hashlib
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import sys
14
+ import tarfile
15
+ import tempfile
16
+ import time
17
+ import zipfile
18
+ from pathlib import Path
19
+ from typing import Callable, Optional
20
+
21
+ from . import config
22
+
23
+ FEATURE_VERSION = 21 # a long-term-support release
24
+ API = os.environ.get("AETHER_JAVA_API", "https://api.adoptium.net/v3")
25
+ JAVA_DIR = config.HOME / "java"
26
+
27
+
28
+ class JavaDownloadError(Exception):
29
+ pass
30
+
31
+
32
+ def private_java() -> Optional[Path]:
33
+ """The java program inside our private copy, if it has been downloaded."""
34
+ name = "java.exe" if os.name == "nt" else "java"
35
+ for pattern in (f"*/Contents/Home/bin/{name}", f"*/bin/{name}"): # macOS keeps it inside a bundle
36
+ for p in sorted(JAVA_DIR.glob(pattern)):
37
+ if p.is_file():
38
+ return p
39
+ return None
40
+
41
+
42
+ def _target() -> tuple:
43
+ """(os, architecture) in the names the download service uses."""
44
+ system = platform.system()
45
+ machine = platform.machine().lower()
46
+ os_name = {"Darwin": "mac", "Windows": "windows", "Linux": "linux"}.get(system)
47
+ if os_name == "linux" and Path("/etc/alpine-release").exists():
48
+ os_name = "alpine-linux"
49
+ arch = {"x86_64": "x64", "amd64": "x64", "arm64": "aarch64", "aarch64": "aarch64"}.get(machine)
50
+ if not os_name or not arch:
51
+ raise JavaDownloadError(f"There's no ready-made Java download for this computer ({system} {machine}).")
52
+ return os_name, arch
53
+
54
+
55
+ def _client():
56
+ import httpx # installed with the MCP library; respects HTTPS_PROXY like the model download does
57
+ return httpx.Client(follow_redirects=True, timeout=httpx.Timeout(30.0, read=60.0))
58
+
59
+
60
+ def _find_package(client) -> dict:
61
+ os_name, arch = _target()
62
+ url = f"{API}/assets/latest/{FEATURE_VERSION}/hotspot"
63
+ r = client.get(url, params={"architecture": arch, "image_type": "jre", "os": os_name, "vendor": "eclipse"})
64
+ r.raise_for_status()
65
+ try:
66
+ package = r.json()[0]["binary"]["package"]
67
+ return {"link": package["link"], "sha256": package["checksum"].lower(),
68
+ "size": int(package.get("size") or 0), "name": package["name"]}
69
+ except (ValueError, LookupError, TypeError, AttributeError):
70
+ raise JavaDownloadError(f"The Java download service sent an unexpected answer for {os_name} {arch}.")
71
+
72
+
73
+ def _download(client, pkg: dict, dest: Path, progress: Callable[[int, int], None]) -> None:
74
+ digest = hashlib.sha256()
75
+ done = 0
76
+ with client.stream("GET", pkg["link"]) as r:
77
+ r.raise_for_status()
78
+ total = pkg["size"] or int(r.headers.get("content-length") or 0)
79
+ with open(dest, "wb") as f:
80
+ for chunk in r.iter_bytes(1 << 16):
81
+ f.write(chunk)
82
+ digest.update(chunk)
83
+ done += len(chunk)
84
+ progress(done, total)
85
+ if digest.hexdigest() != pkg["sha256"]:
86
+ raise JavaDownloadError("The Java download didn't match its published checksum, so it wasn't used. "
87
+ "Try again; if it keeps happening, install Java yourself from https://adoptium.net.")
88
+
89
+
90
+ def _extract(archive: Path, into: Path) -> None:
91
+ if archive.name.endswith(".zip"):
92
+ with zipfile.ZipFile(archive) as z:
93
+ for member in z.namelist(): # refuse paths that would land outside the folder
94
+ if member.startswith(("/", "\\")) or ".." in Path(member).parts:
95
+ raise JavaDownloadError("The Java download contained an unsafe file path.")
96
+ z.extractall(into)
97
+ else:
98
+ with tarfile.open(archive) as t:
99
+ if hasattr(tarfile, "data_filter"):
100
+ t.extractall(into, filter="data") # rejects unsafe paths and links
101
+ else:
102
+ for member in t.getmembers():
103
+ if member.name.startswith("/") or ".." in Path(member.name).parts:
104
+ raise JavaDownloadError("The Java download contained an unsafe file path.")
105
+ t.extractall(into)
106
+ if os.name != "nt": # make sure the programs are runnable (zip files drop permissions)
107
+ for bin_dir in into.glob("*/**/bin"):
108
+ for f in bin_dir.iterdir():
109
+ if f.is_file():
110
+ f.chmod(f.stat().st_mode | 0o755)
111
+
112
+
113
+ _last_shown = [-1]
114
+
115
+
116
+ def _bar(done: int, total: int) -> None:
117
+ if not sys.stdout.isatty():
118
+ return
119
+ step = int(done * 100 / total) if total else done >> 20 # redraw once per percent (or per MB)
120
+ if step == _last_shown[0]:
121
+ return
122
+ _last_shown[0] = step
123
+ mb = done / 1_048_576
124
+ if total:
125
+ width = 24
126
+ filled = int(width * done / total)
127
+ line = f" [{'#' * filled}{'.' * (width - filled)}] {mb:5.1f} of {total / 1_048_576:.0f} MB"
128
+ else:
129
+ line = f" {mb:5.1f} MB"
130
+ print("\r" + line, end="", flush=True)
131
+
132
+
133
+ def download(progress: Callable[[int, int], None] = _bar, attempts: int = 3) -> Path:
134
+ """Downloads and unpacks Java into ~/.aether-cache/java. Returns the path to its java program."""
135
+ config.HOME.mkdir(parents=True, exist_ok=True)
136
+ last_error = None
137
+ for attempt in range(1, attempts + 1):
138
+ work = Path(tempfile.mkdtemp(prefix="java-download-", dir=config.HOME))
139
+ try:
140
+ with _client() as client:
141
+ pkg = _find_package(client)
142
+ archive = work / pkg["name"]
143
+ _download(client, pkg, archive, progress)
144
+ if progress is _bar and sys.stdout.isatty():
145
+ print()
146
+ unpacked = work / "unpacked"
147
+ unpacked.mkdir()
148
+ _extract(archive, unpacked)
149
+ if JAVA_DIR.exists():
150
+ shutil.rmtree(JAVA_DIR)
151
+ os.replace(unpacked, JAVA_DIR) # appears all at once, so a half-unpacked Java is never used
152
+ java = private_java()
153
+ if java is None:
154
+ raise JavaDownloadError("The Java download didn't contain a java program.")
155
+ return java
156
+ except JavaDownloadError:
157
+ raise # a bad checksum or a bad archive won't fix itself by retrying
158
+ except Exception as e: # network trouble: try again
159
+ last_error = e
160
+ if progress is _bar and sys.stdout.isatty():
161
+ print()
162
+ if attempt < attempts:
163
+ time.sleep(2 * attempt)
164
+ finally:
165
+ shutil.rmtree(work, ignore_errors=True)
166
+ raise JavaDownloadError(f"Couldn't download Java ({last_error}). Check your internet connection and run "
167
+ f"`aether-cache setup` again, or install Java yourself from https://adoptium.net.")
168
+
169
+
170
+ def remove() -> bool:
171
+ if JAVA_DIR.exists():
172
+ shutil.rmtree(JAVA_DIR, ignore_errors=True)
173
+ return True
174
+ return False
@@ -253,10 +253,60 @@ def _key(file: str, text: str) -> str:
253
253
  return hashlib.sha1(f"{file}\n{text}".encode()).hexdigest()[:12]
254
254
 
255
255
 
256
- def scan(project: str, root: Path) -> dict:
257
- """Everything import would bring in from this repo's notes files, tagged for review: known (already
258
- remembered), missing (mentions a file that doesn't exist, so probably already stale), weak (doesn't read
259
- like a fact about the project). Only notes with none of these are selected by default."""
256
+ # Same thresholds as the engine (Main.java): at or above DUPLICATE it treats a new memory as the same one,
257
+ # at or above CONFLICT as a possible contradiction. A note scoring that close to a memory you already have
258
+ # would only be skipped or flagged after importing, so it's flagged before instead.
259
+ DUPLICATE_SIMILARITY = 0.97
260
+ CONFLICT_SIMILARITY = 0.80
261
+
262
+
263
+ def _cosine(a: List[float], b: List[float]) -> float:
264
+ dot = sum(x * y for x, y in zip(a, b))
265
+ na = sum(x * x for x in a) ** 0.5
266
+ nb = sum(y * y for y in b) ** 0.5
267
+ return dot / (na * nb) if na and nb else 0.0
268
+
269
+
270
+ def _flag_similar(project: str, notes: List[dict]) -> Dict[str, List[str]]:
271
+ """Compares each new note by meaning with the project's current memories, and with the notes before it
272
+ in this import. Marks near-copies as known and close matches as similar (unselected). Returns the
273
+ notes' embeddings by key, so importing them doesn't compute them twice."""
274
+ fresh = [n for n in notes if not n["known"]]
275
+ if not fresh:
276
+ return {}
277
+ vectors = server.embed_many([n["text"] for n in fresh])
278
+ pipe = server.db().pipeline(transaction=False)
279
+ for v in vectors:
280
+ pipe.execute_command("MEM.SEARCH", project, 1, "STATUS", "active", "VECTOR", *v)
281
+ try:
282
+ hits = pipe.execute()
283
+ except Exception:
284
+ hits = [server.run("MEM.SEARCH", project, 1, "STATUS", "active", "VECTOR", *v) for v in vectors]
285
+ floats = [[float(x) for x in v] for v in vectors]
286
+ for i, (note, hit) in enumerate(zip(fresh, hits)):
287
+ if hit:
288
+ mid, score, text = hit[0][0], float(hit[0][1]), hit[0][2]
289
+ if score >= DUPLICATE_SIMILARITY:
290
+ note["known"] = True
291
+ note["selected"] = False
292
+ continue
293
+ if score >= CONFLICT_SIMILARITY:
294
+ note["similar"] = {"id": mid, "text": text, "score": round(score, 2)}
295
+ note["selected"] = False
296
+ continue
297
+ for j in range(i): # two notes in this import that say the same thing, e.g. in CLAUDE.md and a rule
298
+ other = fresh[j]
299
+ if other["known"] or other.get("similar"):
300
+ continue
301
+ score = _cosine(floats[i], floats[j])
302
+ if score >= CONFLICT_SIMILARITY:
303
+ note["similar"] = {"id": None, "text": other["text"], "score": round(score, 2)}
304
+ note["selected"] = False
305
+ break
306
+ return {n["key"]: v for n, v in zip(fresh, vectors)}
307
+
308
+
309
+ def _scan(project: str, root: Path) -> tuple:
260
310
  if not root.is_dir():
261
311
  raise NotesError(f"{root} isn't a folder.")
262
312
  existing = set()
@@ -280,27 +330,34 @@ def scan(project: str, root: Path) -> dict:
280
330
  linked, missing = mentioned_paths(note, root, skip=rels)
281
331
  known = n in existing or body in existing
282
332
  notes.append({"key": _key(rel, note), "text": note, "file": rel, "files": linked,
283
- "missing": missing, "known": known, "weak": found["weak"],
333
+ "missing": missing, "known": known, "weak": found["weak"], "similar": None,
284
334
  "selected": not known and not missing and not found["weak"]})
285
335
  count += 1
286
336
  per_file.append({"path": rel, "notes": count})
287
- return {"project": project, "root": str(root), "files": per_file, "notes": notes}
337
+ vectors = _flag_similar(project, notes)
338
+ return {"project": project, "root": str(root), "files": per_file, "notes": notes}, vectors
339
+
340
+
341
+ def scan(project: str, root: Path) -> dict:
342
+ """Everything import would bring in from this repo's notes files, tagged for review: known (already
343
+ remembered, word for word or nearly), similar (close in meaning to a memory you have, or to another note
344
+ here), missing (mentions a file that doesn't exist, so probably already stale), weak (doesn't read like
345
+ a fact about the project). Only notes with none of these are selected by default."""
346
+ return _scan(project, root)[0]
288
347
 
289
348
 
290
349
  def apply(project: str, root: Path, keys: Optional[List[str]] = None) -> dict:
291
350
  """Saves the chosen notes as memories (by key from scan; None means the ones selected by default).
292
351
  Notes that turn out to be already known are skipped, never duplicated."""
293
- found = scan(project, root)["notes"]
294
- chosen = [n for n in found if not n["known"] and (n["selected"] if keys is None else n["key"] in set(keys))]
352
+ found, vectors = _scan(project, root)
353
+ wanted = set(keys or ())
354
+ chosen = [n for n in found["notes"] if not n["known"] and (n["selected"] if keys is None else n["key"] in wanted)]
295
355
  result = {"saved": [], "duplicates": 0, "conflicts": 0}
296
- if not chosen:
297
- return result
298
- vectors = server.embed_many([n["text"] for n in chosen])
299
- for note, vector in zip(chosen, vectors):
356
+ for note in chosen:
300
357
  args = ["MEM.ADD", project, note["text"], "SOURCE", f"{note['file']} (imported)", "SESSION", "import"]
301
358
  if note["files"]:
302
359
  args += ["FILES", server.link_files(note["files"], root)]
303
- mem_id, outcome, _related = server.run(*args, "VECTOR", *vector)
360
+ mem_id, outcome, _related = server.run(*args, "VECTOR", *vectors[note["key"]])
304
361
  if outcome == "duplicate":
305
362
  result["duplicates"] += 1
306
363
  continue
@@ -903,7 +903,10 @@ function renderImport() {
903
903
  n.files.length ? h("span", { class: "linked" }, "Linked to ", n.files.map((p, i) => [i ? ", " : "", h("span", { class: "mono" }, pathNodes(p))])) : null,
904
904
  n.missing.length ? h("span", { class: "warn" }, "Mentions ", n.missing.map((p, i) => [i ? ", " : "", h("span", { class: "mono" }, pathNodes(p))]),
905
905
  n.missing.length === 1 ? ", which doesn’t exist. Probably out of date." : ", which don’t exist. Probably out of date.") : null,
906
- n.weak && !n.missing.length ? h("span", {}, "Doesn’t look like a fact about the project, so it’s unchecked.") : null,
906
+ n.similar ? h("span", { class: "warn" }, n.similar.id
907
+ ? ["Very similar to ", h("span", { class: "mono" }, n.similar.id), ", which you already have: “", richText(n.similar.text), "”"]
908
+ : ["Very similar to another note here: “", richText(n.similar.text), "”"]) : null,
909
+ n.weak && !n.missing.length && !n.similar ? h("span", {}, "Doesn’t look like a fact about the project, so it’s unchecked.") : null,
907
910
  ].filter(Boolean);
908
911
  return h("label", { class: "pick" }, box,
909
912
  h("span", {}, h("span", { class: "text", id: `t-${n.key}` }, richText(n.text)), tags.length ? h("span", { class: "tags" }, tags) : null));
@@ -1,14 +1,16 @@
1
1
  [build-system]
2
- requires = ["hatchling>=1.24"]
2
+ requires = ["hatchling>=1.27"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "aether-cache"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "Shared memory for AI coding tools that stays true to your code."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  authors = [{ name = "Anish Chugh" }]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
12
14
  dependencies = [
13
15
  "mcp>=1.2",
14
16
  "fastembed>=0.3",
@@ -27,7 +29,7 @@ packages = ["aether_cache"]
27
29
  artifacts = ["aether_cache/engine/aether-engine.jar"] # *.jar is gitignored; ship this one anyway
28
30
 
29
31
  [tool.hatch.build.targets.sdist]
30
- include = ["aether_cache", "README.md", "pyproject.toml"]
32
+ include = ["aether_cache", "README.md", "LICENSE", "pyproject.toml"]
31
33
  artifacts = ["aether_cache/engine/aether-engine.jar"]
32
34
 
33
35
  [tool.uv]
File without changes