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.
- aether_cache-0.1.5/LICENSE +21 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/PKG-INFO +14 -5
- {aether_cache-0.1.3 → aether_cache-0.1.5}/README.md +11 -4
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/__init__.py +1 -1
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/cli.py +44 -6
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/engine.py +36 -14
- aether_cache-0.1.5/aether_cache/javart.py +174 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/portability.py +70 -13
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/index.html +4 -1
- {aether_cache-0.1.3 → aether_cache-0.1.5}/pyproject.toml +5 -3
- {aether_cache-0.1.3 → aether_cache-0.1.5}/.gitignore +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/__main__.py +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/config.py +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/connect.py +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/engine/aether-engine.jar +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/repos.py +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/server.py +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/OFL.txt +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- {aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui.py +0 -0
- {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
|
+
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 **
|
|
108
|
-
|
|
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 **
|
|
95
|
-
|
|
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).
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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"
|
|
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("
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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 =
|
|
294
|
-
|
|
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
|
-
|
|
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", *
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.3 → aether_cache-0.1.5}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|