aether-cache 0.1.2__tar.gz → 0.1.4__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.2 → aether_cache-0.1.4}/.gitignore +1 -0
- aether_cache-0.1.2/README.md → aether_cache-0.1.4/PKG-INFO +30 -4
- aether_cache-0.1.2/PKG-INFO → aether_cache-0.1.4/README.md +17 -16
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/__init__.py +1 -1
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/cli.py +187 -4
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/connect.py +87 -8
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/engine.py +36 -14
- aether_cache-0.1.4/aether_cache/javart.py +174 -0
- aether_cache-0.1.4/aether_cache/portability.py +354 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/server.py +13 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/index.html +248 -11
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui.py +73 -1
- aether_cache-0.1.4/docs/README.md +41 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/pyproject.toml +3 -2
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/__main__.py +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/config.py +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/engine/aether-engine.jar +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/repos.py +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/OFL.txt +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
|
@@ -1,9 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aether-cache
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Shared memory for AI coding tools that stays true to your code.
|
|
5
|
+
Project-URL: Homepage, https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/
|
|
6
|
+
Project-URL: Repository, https://github.com/anishchugh05/Aether-Cache-Redis-Compatible-Vector-Store
|
|
7
|
+
Author: Anish Chugh
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: fastembed>=0.3
|
|
10
|
+
Requires-Dist: mcp>=1.2
|
|
11
|
+
Requires-Dist: redis>=5.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
[](https://app.codecrafters.io/users/anishchugh05?r=2qF)
|
|
2
15
|
|
|
3
16
|
# Aether Cache
|
|
4
17
|
|
|
5
18
|
**Shared memory for AI coding tools that stays true to your code.**
|
|
6
19
|
|
|
20
|
+
**[Website](https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/)** · **[PyPI](https://pypi.org/project/aether-cache/)**
|
|
21
|
+
|
|
7
22
|
Claude Code, Cursor, and other AI tools each forget your project between sessions, and when they do remember things,
|
|
8
23
|
nothing checks whether those memories are still correct. Aether Cache gives every tool one shared, local memory, and keeps
|
|
9
24
|
it honest:
|
|
@@ -89,9 +104,8 @@ m1 retired; m3 replaces it.
|
|
|
89
104
|
|
|
90
105
|
## Quick start
|
|
91
106
|
|
|
92
|
-
You need **
|
|
93
|
-
|
|
94
|
-
Python for you. Then:
|
|
107
|
+
You need [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes
|
|
108
|
+
care of Python for you. Then:
|
|
95
109
|
|
|
96
110
|
```sh
|
|
97
111
|
uv tool install aether-cache
|
|
@@ -106,6 +120,13 @@ repo and try:
|
|
|
106
120
|
|
|
107
121
|
Then start a new session and ask how to run the tests.
|
|
108
122
|
|
|
123
|
+
The engine runs on Java 11+. If you have it, it's used. If you don't, `setup` downloads a private copy (about
|
|
124
|
+
50 MB) into `~/.aether-cache/java`, checks it against its published checksum, and uses it only for Aether Cache;
|
|
125
|
+
nothing is installed system-wide, and `aether-cache uninstall` removes it.
|
|
126
|
+
|
|
127
|
+
Already keep notes for your AI in `CLAUDE.md`, `AGENTS.md`, or Cursor rules? Run `aether-cache import` in the repo
|
|
128
|
+
(or use **Import notes** in the dashboard) to turn them into memories, linked to the files they mention.
|
|
129
|
+
|
|
109
130
|
Memories are kept per project (the git repo's folder name), so projects never mix. Everything stays on your machine:
|
|
110
131
|
the engine only accepts connections from it, and your memories live in `~/.aether-cache`.
|
|
111
132
|
|
|
@@ -114,15 +135,18 @@ the engine only accepts connections from it, and your memories live in `~/.aethe
|
|
|
114
135
|
| `aether-cache setup` | One-time setup: engine, model, and AI tools |
|
|
115
136
|
| `aether-cache add` | Adds the repo you're in (or a path) so the dashboard can check its files; the dashboard can also find and add repos for you |
|
|
116
137
|
| `aether-cache ui` | Opens the dashboard: see what your AI remembers, and confirm or retire memories whose files changed |
|
|
138
|
+
| `aether-cache import` | Turns the notes in `CLAUDE.md`, `AGENTS.md`, and Cursor rules into memories; shows them first and asks |
|
|
139
|
+
| `aether-cache export` | Saves memories, with their linked files and history, to a JSON file (`-p` for one project) |
|
|
117
140
|
| `aether-cache status` | Is it running, and what's in this project's memory |
|
|
118
141
|
| `aether-cache doctor` | Checks everything and tells you how to fix what's wrong |
|
|
119
142
|
| `aether-cache list` / `recall "query"` / `explain m1` | Look at memories from the terminal |
|
|
120
143
|
| `aether-cache sync` | Writes current memories into `AGENTS.md` / `CLAUDE.md` |
|
|
121
144
|
| `aether-cache connect cursor` | Connects one tool (`--print` shows config for any other MCP tool) |
|
|
122
145
|
| `aether-cache start` / `stop` | Runs or stops the engine (it also starts on its own when a tool needs it) |
|
|
146
|
+
| `aether-cache uninstall` | Disconnects your AI tools, stops the engine, and asks before deleting memories |
|
|
123
147
|
|
|
124
148
|
If `aether-cache` isn't found after installing, run `uv tool update-shell` and open a new terminal. Port 7380 taken?
|
|
125
|
-
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache
|
|
149
|
+
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache uninstall`, then `uv tool uninstall aether-cache`.
|
|
126
150
|
|
|
127
151
|
## Tools the AI can call
|
|
128
152
|
|
|
@@ -333,9 +357,11 @@ src/main/java/Main.java the engine (single file, from the CodeCrafters setu
|
|
|
333
357
|
aether_cache/ the Python package behind the `aether-cache` command
|
|
334
358
|
server.py MCP server: embeddings, git staleness, AGENTS.md/CLAUDE.md sync
|
|
335
359
|
engine.py finds Java, starts and stops the engine in the background
|
|
360
|
+
javart.py downloads a private copy of Java when the computer has none
|
|
336
361
|
connect.py registers Aether Cache with Claude Code, Cursor, and Codex
|
|
337
362
|
cli.py the `aether-cache` command
|
|
338
363
|
ui.py, ui/index.html the dashboard (`aether-cache ui`): a local server, its API, and the page
|
|
364
|
+
portability.py import from notes files, export to JSON
|
|
339
365
|
engine/aether-engine.jar the prebuilt engine (rebuild with `python scripts/build_engine.py`)
|
|
340
366
|
memory_mcp/calibrate.py similarity calibration for duplicate/contradiction thresholds
|
|
341
367
|
vector_bench.py HNSW benchmark (writes results.json)
|
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: aether-cache
|
|
3
|
-
Version: 0.1.2
|
|
4
|
-
Summary: Shared memory for AI coding tools that stays true to your code.
|
|
5
|
-
Project-URL: Homepage, https://github.com/anishchugh05/Aether-Cache-Redis-Compatible-Vector-Store
|
|
6
|
-
Author: Anish Chugh
|
|
7
|
-
Requires-Python: >=3.10
|
|
8
|
-
Requires-Dist: fastembed>=0.3
|
|
9
|
-
Requires-Dist: mcp>=1.2
|
|
10
|
-
Requires-Dist: redis>=5.0
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
1
|
[](https://app.codecrafters.io/users/anishchugh05?r=2qF)
|
|
14
2
|
|
|
15
3
|
# Aether Cache
|
|
16
4
|
|
|
17
5
|
**Shared memory for AI coding tools that stays true to your code.**
|
|
18
6
|
|
|
7
|
+
**[Website](https://anishchugh05.github.io/Aether-Cache-Redis-Compatible-Vector-Store/)** · **[PyPI](https://pypi.org/project/aether-cache/)**
|
|
8
|
+
|
|
19
9
|
Claude Code, Cursor, and other AI tools each forget your project between sessions, and when they do remember things,
|
|
20
10
|
nothing checks whether those memories are still correct. Aether Cache gives every tool one shared, local memory, and keeps
|
|
21
11
|
it honest:
|
|
@@ -101,9 +91,8 @@ m1 retired; m3 replaces it.
|
|
|
101
91
|
|
|
102
92
|
## Quick start
|
|
103
93
|
|
|
104
|
-
You need **
|
|
105
|
-
|
|
106
|
-
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:
|
|
107
96
|
|
|
108
97
|
```sh
|
|
109
98
|
uv tool install aether-cache
|
|
@@ -118,6 +107,13 @@ repo and try:
|
|
|
118
107
|
|
|
119
108
|
Then start a new session and ask how to run the tests.
|
|
120
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
|
+
|
|
114
|
+
Already keep notes for your AI in `CLAUDE.md`, `AGENTS.md`, or Cursor rules? Run `aether-cache import` in the repo
|
|
115
|
+
(or use **Import notes** in the dashboard) to turn them into memories, linked to the files they mention.
|
|
116
|
+
|
|
121
117
|
Memories are kept per project (the git repo's folder name), so projects never mix. Everything stays on your machine:
|
|
122
118
|
the engine only accepts connections from it, and your memories live in `~/.aether-cache`.
|
|
123
119
|
|
|
@@ -126,15 +122,18 @@ the engine only accepts connections from it, and your memories live in `~/.aethe
|
|
|
126
122
|
| `aether-cache setup` | One-time setup: engine, model, and AI tools |
|
|
127
123
|
| `aether-cache add` | Adds the repo you're in (or a path) so the dashboard can check its files; the dashboard can also find and add repos for you |
|
|
128
124
|
| `aether-cache ui` | Opens the dashboard: see what your AI remembers, and confirm or retire memories whose files changed |
|
|
125
|
+
| `aether-cache import` | Turns the notes in `CLAUDE.md`, `AGENTS.md`, and Cursor rules into memories; shows them first and asks |
|
|
126
|
+
| `aether-cache export` | Saves memories, with their linked files and history, to a JSON file (`-p` for one project) |
|
|
129
127
|
| `aether-cache status` | Is it running, and what's in this project's memory |
|
|
130
128
|
| `aether-cache doctor` | Checks everything and tells you how to fix what's wrong |
|
|
131
129
|
| `aether-cache list` / `recall "query"` / `explain m1` | Look at memories from the terminal |
|
|
132
130
|
| `aether-cache sync` | Writes current memories into `AGENTS.md` / `CLAUDE.md` |
|
|
133
131
|
| `aether-cache connect cursor` | Connects one tool (`--print` shows config for any other MCP tool) |
|
|
134
132
|
| `aether-cache start` / `stop` | Runs or stops the engine (it also starts on its own when a tool needs it) |
|
|
133
|
+
| `aether-cache uninstall` | Disconnects your AI tools, stops the engine, and asks before deleting memories |
|
|
135
134
|
|
|
136
135
|
If `aether-cache` isn't found after installing, run `uv tool update-shell` and open a new terminal. Port 7380 taken?
|
|
137
|
-
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache
|
|
136
|
+
Set `AETHER_PORT` and run `setup` again. To uninstall: `aether-cache uninstall`, then `uv tool uninstall aether-cache`.
|
|
138
137
|
|
|
139
138
|
## Tools the AI can call
|
|
140
139
|
|
|
@@ -345,9 +344,11 @@ src/main/java/Main.java the engine (single file, from the CodeCrafters setu
|
|
|
345
344
|
aether_cache/ the Python package behind the `aether-cache` command
|
|
346
345
|
server.py MCP server: embeddings, git staleness, AGENTS.md/CLAUDE.md sync
|
|
347
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
|
|
348
348
|
connect.py registers Aether Cache with Claude Code, Cursor, and Codex
|
|
349
349
|
cli.py the `aether-cache` command
|
|
350
350
|
ui.py, ui/index.html the dashboard (`aether-cache ui`): a local server, its API, and the page
|
|
351
|
+
portability.py import from notes files, export to JSON
|
|
351
352
|
engine/aether-engine.jar the prebuilt engine (rebuild with `python scripts/build_engine.py`)
|
|
352
353
|
memory_mcp/calibrate.py similarity calibration for duplicate/contradiction thresholds
|
|
353
354
|
vector_bench.py HNSW benchmark (writes results.json)
|
|
@@ -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
|
|
@@ -195,6 +225,148 @@ def cmd_add(args) -> int:
|
|
|
195
225
|
return 0
|
|
196
226
|
|
|
197
227
|
|
|
228
|
+
def _ask(question: str) -> bool:
|
|
229
|
+
if not sys.stdin.isatty():
|
|
230
|
+
return False
|
|
231
|
+
try:
|
|
232
|
+
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
|
|
233
|
+
except EOFError:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_import(args) -> int:
|
|
238
|
+
from pathlib import Path
|
|
239
|
+
from . import portability, repos, server
|
|
240
|
+
folder = Path(args.path).expanduser()
|
|
241
|
+
if not folder.is_dir():
|
|
242
|
+
print(f"{args.path} isn't a folder.")
|
|
243
|
+
return 1
|
|
244
|
+
root = repos.git_top(folder) or folder.resolve()
|
|
245
|
+
project = root.name
|
|
246
|
+
try:
|
|
247
|
+
engine.ensure_running()
|
|
248
|
+
found = portability.scan(project, root)
|
|
249
|
+
except (engine.EngineError, server.EngineDown) as e:
|
|
250
|
+
print(server.store_down(e))
|
|
251
|
+
return 1
|
|
252
|
+
if not found["files"]:
|
|
253
|
+
print(f"No notes files in {root}.\nAether Cache looks for: " + ", ".join(portability.NOTE_FILES)
|
|
254
|
+
+ ", and .cursor/rules/.")
|
|
255
|
+
return 0
|
|
256
|
+
notes = found["notes"]
|
|
257
|
+
new = [n for n in notes if n["selected"]]
|
|
258
|
+
left_out = [n for n in notes if not n["known"] and not n["selected"]]
|
|
259
|
+
known = [n for n in notes if n["known"]]
|
|
260
|
+
print(f"Found {len(notes)} notes in " + ", ".join(f["path"] for f in found["files"]) + f" ({project})\n")
|
|
261
|
+
for i, n in enumerate(new, 1):
|
|
262
|
+
print(f" {i:>3}. {n['text']}" + (f"\n linked to {', '.join(n['files'])}" if n["files"] else ""))
|
|
263
|
+
if left_out:
|
|
264
|
+
print(f"\nLeft out ({len(left_out)}):")
|
|
265
|
+
for n in left_out:
|
|
266
|
+
why = (f"mentions {', '.join(n['missing'])}, which doesn't exist (probably out of date)"
|
|
267
|
+
if n["missing"] else "doesn't look like a fact about the project")
|
|
268
|
+
print(f" - {n['text']}\n {why}")
|
|
269
|
+
if known:
|
|
270
|
+
print(f"\nAlready remembered: {len(known)}")
|
|
271
|
+
if not new:
|
|
272
|
+
print("\nNothing new to import.")
|
|
273
|
+
return 0
|
|
274
|
+
if left_out:
|
|
275
|
+
print("\nTo pick notes one by one, including the left-out ones, use Import notes in `aether-cache ui`.")
|
|
276
|
+
if not args.yes and not _ask(f"\nImport {len(new)} {'memory' if len(new) == 1 else 'memories'} into {project}?"):
|
|
277
|
+
print("Nothing imported." + ("" if sys.stdin.isatty() else " Pass --yes to import without asking."))
|
|
278
|
+
return 0
|
|
279
|
+
try:
|
|
280
|
+
result = portability.apply(project, root)
|
|
281
|
+
repos.add(str(root)) # so the dashboard can check the imported memories' files
|
|
282
|
+
except repos.RepoError:
|
|
283
|
+
pass
|
|
284
|
+
except server.EngineDown as e:
|
|
285
|
+
print(server.store_down(e))
|
|
286
|
+
return 1
|
|
287
|
+
saved = len(result["saved"])
|
|
288
|
+
print(f"Imported {saved} {'memory' if saved == 1 else 'memories'} into {project}."
|
|
289
|
+
+ (f" {result['duplicates']} matched memories you already had." if result["duplicates"] else "")
|
|
290
|
+
+ (f"\n{result['conflicts']} may contradict an existing memory: decide in `aether-cache ui`."
|
|
291
|
+
if result["conflicts"] else ""))
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_export(args) -> int:
|
|
296
|
+
import json
|
|
297
|
+
from pathlib import Path
|
|
298
|
+
from . import portability, server
|
|
299
|
+
try:
|
|
300
|
+
engine.ensure_running()
|
|
301
|
+
data = portability.export(args.project or None)
|
|
302
|
+
except (engine.EngineError, server.EngineDown) as e:
|
|
303
|
+
print(server.store_down(e))
|
|
304
|
+
return 1
|
|
305
|
+
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
306
|
+
if args.output == "-":
|
|
307
|
+
sys.stdout.write(text)
|
|
308
|
+
return 0
|
|
309
|
+
out = Path(args.output or portability.export_filename(args.project[0] if len(args.project or []) == 1 else None))
|
|
310
|
+
out = out.expanduser()
|
|
311
|
+
out.write_text(text, encoding="utf-8")
|
|
312
|
+
n = sum(len(p["memories"]) for p in data["projects"])
|
|
313
|
+
print(f"Exported {n} {'memory' if n == 1 else 'memories'} from {len(data['projects'])} "
|
|
314
|
+
f"{'project' if len(data['projects']) == 1 else 'projects'} to {out.resolve()}")
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _uninstall_command() -> str:
|
|
319
|
+
exe = sys.executable.replace("\\", "/")
|
|
320
|
+
if "/uv/tools/" in exe or "/uv/tool/" in exe:
|
|
321
|
+
return "uv tool uninstall aether-cache"
|
|
322
|
+
if "/pipx/" in exe:
|
|
323
|
+
return "pipx uninstall aether-cache"
|
|
324
|
+
return f"{sys.executable} -m pip uninstall aether-cache"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_uninstall(args) -> int:
|
|
328
|
+
import shutil
|
|
329
|
+
print("Uninstalling Aether Cache\n")
|
|
330
|
+
failed = False
|
|
331
|
+
for tool in connect.TOOLS:
|
|
332
|
+
done, message = connect.DISCONNECTORS[tool]()
|
|
333
|
+
_say(OK if done else FAIL, message)
|
|
334
|
+
failed |= not done
|
|
335
|
+
print(" Restart any AI tools that are open, so they stop using Aether Cache.")
|
|
336
|
+
count = None
|
|
337
|
+
if engine.probe() == "aether":
|
|
338
|
+
try:
|
|
339
|
+
from .ui import all_memories
|
|
340
|
+
count = len(all_memories())
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
stopped = engine.stop()
|
|
344
|
+
still_running = engine.probe() == "aether"
|
|
345
|
+
_say(FAIL if still_running else OK, stopped)
|
|
346
|
+
failed |= still_running
|
|
347
|
+
|
|
348
|
+
has_data = config.HOME.exists()
|
|
349
|
+
if still_running and has_data: # it would rewrite them anyway
|
|
350
|
+
print(f"\nYour memories are kept in {config.HOME}: stop the engine first to delete them.")
|
|
351
|
+
print(f"\nLast step, remove the program itself:\n {_uninstall_command()}")
|
|
352
|
+
return 1
|
|
353
|
+
delete = args.delete_data
|
|
354
|
+
if has_data and not delete:
|
|
355
|
+
what = f"Your {count} {'memory is' if count == 1 else 'memories are'}" if count else "Your memories are"
|
|
356
|
+
print(f"\n{what} in {config.HOME}.")
|
|
357
|
+
delete = _ask("Delete them too? This can't be undone (keep a copy first with `aether-cache export`).")
|
|
358
|
+
if has_data and delete:
|
|
359
|
+
shutil.rmtree(config.HOME, ignore_errors=True)
|
|
360
|
+
_say(OK, f"Deleted {config.HOME}")
|
|
361
|
+
elif has_data:
|
|
362
|
+
from . import javart
|
|
363
|
+
if javart.remove():
|
|
364
|
+
_say(OK, f"Removed Aether Cache's private copy of Java ({javart.JAVA_DIR})")
|
|
365
|
+
_say(OK, f"Kept your memories in {config.HOME}. Reinstall any time and they're back.")
|
|
366
|
+
print(f"\nLast step, remove the program itself:\n {_uninstall_command()}")
|
|
367
|
+
return 1 if failed else 0
|
|
368
|
+
|
|
369
|
+
|
|
198
370
|
def cmd_serve(args) -> int:
|
|
199
371
|
from .server import serve
|
|
200
372
|
serve()
|
|
@@ -239,6 +411,17 @@ def main(argv=None) -> None:
|
|
|
239
411
|
p = sub.add_parser("add", help="add a repo so the dashboard can check its files (default: this folder)")
|
|
240
412
|
p.add_argument("path", nargs="?", default=".")
|
|
241
413
|
p.set_defaults(func=cmd_add)
|
|
414
|
+
p = sub.add_parser("import", help="turn notes in CLAUDE.md, AGENTS.md, and Cursor rules into memories")
|
|
415
|
+
p.add_argument("path", nargs="?", default=".", help="the repo to import from (default: this folder)")
|
|
416
|
+
p.add_argument("--yes", "-y", action="store_true", help="import without asking")
|
|
417
|
+
p.set_defaults(func=cmd_import)
|
|
418
|
+
p = sub.add_parser("export", help="save memories, with their history, to a JSON file")
|
|
419
|
+
p.add_argument("--project", "-p", action="append", metavar="NAME", help="only this project (repeatable; default: all)")
|
|
420
|
+
p.add_argument("--output", "-o", metavar="FILE", help="where to write it (default: aether-cache-<date>.json here; - for stdout)")
|
|
421
|
+
p.set_defaults(func=cmd_export)
|
|
422
|
+
p = sub.add_parser("uninstall", help="disconnect your AI tools, stop the engine, and optionally delete memories")
|
|
423
|
+
p.add_argument("--delete-data", action="store_true", help="also delete all memories, without asking")
|
|
424
|
+
p.set_defaults(func=cmd_uninstall)
|
|
242
425
|
p = sub.add_parser("ui", help="open the dashboard: see and fix what your AI tools remember")
|
|
243
426
|
p.add_argument("--port", type=int, help="port for the dashboard (default 7381)")
|
|
244
427
|
p.add_argument("--no-open", action="store_true", help="don't open a browser, just print the link")
|
|
@@ -66,6 +66,28 @@ def add_instructions(path: Path) -> str:
|
|
|
66
66
|
return f"told it to use Aether Cache for project memory in {path}"
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def remove_instructions(path: Path) -> bool:
|
|
70
|
+
"""Takes our section back out of an instruction file, leaving the rest exactly as it was.
|
|
71
|
+
Deletes the file only if our section was all that was in it. Returns whether anything changed."""
|
|
72
|
+
try:
|
|
73
|
+
old = path.read_text(encoding="utf-8")
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
if INSTR_START not in old or INSTR_END not in old:
|
|
77
|
+
return False
|
|
78
|
+
before, after = old[:old.index(INSTR_START)], old[old.index(INSTR_END) + len(INSTR_END):]
|
|
79
|
+
new = before.rstrip() + ("\n\n" + after.lstrip("\n") if before.strip() and after.strip() else after.lstrip("\n"))
|
|
80
|
+
if not new.strip():
|
|
81
|
+
path.unlink()
|
|
82
|
+
return True
|
|
83
|
+
if not new.endswith("\n"):
|
|
84
|
+
new += "\n"
|
|
85
|
+
tmp = path.with_name(path.name + ".aether-tmp")
|
|
86
|
+
tmp.write_text(new, encoding="utf-8")
|
|
87
|
+
os.replace(tmp, path)
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
69
91
|
def claude_instructions_file() -> Path:
|
|
70
92
|
return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "CLAUDE.md"
|
|
71
93
|
|
|
@@ -95,6 +117,20 @@ def connect_claude_code() -> Tuple[bool, str]:
|
|
|
95
117
|
return True, "Claude Code: connected for all projects (" + "; ".join(notes) + ")."
|
|
96
118
|
|
|
97
119
|
|
|
120
|
+
def disconnect_claude_code() -> Tuple[bool, str]:
|
|
121
|
+
notes = []
|
|
122
|
+
if shutil.which("claude"):
|
|
123
|
+
removed = [scope for scope in ("user", "local")
|
|
124
|
+
if _claude("mcp", "remove", NAME, "--scope", scope).returncode == 0]
|
|
125
|
+
if removed:
|
|
126
|
+
notes.append("removed the MCP server")
|
|
127
|
+
if _claude("mcp", "get", NAME).returncode == 0:
|
|
128
|
+
return False, "Claude Code: couldn't remove it. Run `claude mcp remove aether-cache` yourself."
|
|
129
|
+
if remove_instructions(claude_instructions_file()):
|
|
130
|
+
notes.append(f"removed our section from {claude_instructions_file()}")
|
|
131
|
+
return True, "Claude Code: disconnected" + (f" ({'; '.join(notes)})." if notes else " (it wasn't connected).")
|
|
132
|
+
|
|
133
|
+
|
|
98
134
|
def claude_code_connected() -> bool:
|
|
99
135
|
return bool(shutil.which("claude")) and _claude("mcp", "get", NAME).returncode == 0
|
|
100
136
|
|
|
@@ -128,6 +164,25 @@ def connect_cursor() -> Tuple[bool, str]:
|
|
|
128
164
|
return True, f"Cursor: connected for all projects ({path}). Restart Cursor to pick it up."
|
|
129
165
|
|
|
130
166
|
|
|
167
|
+
def disconnect_cursor() -> Tuple[bool, str]:
|
|
168
|
+
path = cursor_config()
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
171
|
+
except OSError:
|
|
172
|
+
return True, "Cursor: disconnected (it wasn't connected)."
|
|
173
|
+
except json.JSONDecodeError as e:
|
|
174
|
+
return False, f"Cursor: couldn't read {path} ({e}). Remove the \"{NAME}\" entry yourself."
|
|
175
|
+
servers = data.get("mcpServers") if isinstance(data, dict) else None
|
|
176
|
+
if not isinstance(servers, dict) or NAME not in servers:
|
|
177
|
+
return True, "Cursor: disconnected (it wasn't connected)."
|
|
178
|
+
shutil.copyfile(path, path.with_name("mcp.json.bak"))
|
|
179
|
+
del servers[NAME]
|
|
180
|
+
tmp = path.with_name("mcp.json.aether-tmp")
|
|
181
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
182
|
+
os.replace(tmp, path)
|
|
183
|
+
return True, f"Cursor: disconnected ({path}). Restart Cursor to pick it up."
|
|
184
|
+
|
|
185
|
+
|
|
131
186
|
def cursor_connected() -> bool:
|
|
132
187
|
try:
|
|
133
188
|
return NAME in json.loads(cursor_config().read_text(encoding="utf-8")).get("mcpServers", {})
|
|
@@ -155,14 +210,7 @@ def _is_our_header(line: str) -> bool:
|
|
|
155
210
|
def connect_codex() -> Tuple[bool, str]:
|
|
156
211
|
path = codex_config()
|
|
157
212
|
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
158
|
-
# drop our old section (if any): from its header to the next table header
|
|
159
|
-
kept, skipping = [], False
|
|
160
|
-
for line in text.splitlines():
|
|
161
|
-
if line.strip().startswith("["):
|
|
162
|
-
skipping = _is_our_header(line)
|
|
163
|
-
if not skipping:
|
|
164
|
-
kept.append(line)
|
|
165
|
-
body = "\n".join(kept).rstrip()
|
|
213
|
+
body = _without_our_section(text) # drop our old section (if any): from its header to the next table header
|
|
166
214
|
new = (body + "\n\n" if body else "") + _codex_block()
|
|
167
215
|
if path.exists():
|
|
168
216
|
shutil.copyfile(path, path.with_name("config.toml.bak"))
|
|
@@ -174,6 +222,35 @@ def connect_codex() -> Tuple[bool, str]:
|
|
|
174
222
|
return True, f"Codex: connected ({path}; {note})."
|
|
175
223
|
|
|
176
224
|
|
|
225
|
+
def _without_our_section(text: str) -> str:
|
|
226
|
+
kept, skipping = [], False
|
|
227
|
+
for line in text.splitlines():
|
|
228
|
+
if line.strip().startswith("["):
|
|
229
|
+
skipping = _is_our_header(line)
|
|
230
|
+
if not skipping:
|
|
231
|
+
kept.append(line)
|
|
232
|
+
return "\n".join(kept).rstrip()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def disconnect_codex() -> Tuple[bool, str]:
|
|
236
|
+
path = codex_config()
|
|
237
|
+
notes = []
|
|
238
|
+
try:
|
|
239
|
+
text = path.read_text(encoding="utf-8")
|
|
240
|
+
except OSError:
|
|
241
|
+
text = ""
|
|
242
|
+
if any(_is_our_header(l) for l in text.splitlines()):
|
|
243
|
+
shutil.copyfile(path, path.with_name("config.toml.bak"))
|
|
244
|
+
body = _without_our_section(text)
|
|
245
|
+
tmp = path.with_name("config.toml.aether-tmp")
|
|
246
|
+
tmp.write_text(body + "\n" if body else "", encoding="utf-8")
|
|
247
|
+
os.replace(tmp, path)
|
|
248
|
+
notes.append(f"removed the MCP server from {path}")
|
|
249
|
+
if remove_instructions(path.with_name("AGENTS.md")):
|
|
250
|
+
notes.append(f"removed our section from {path.with_name('AGENTS.md')}")
|
|
251
|
+
return True, "Codex: disconnected" + (f" ({'; '.join(notes)})." if notes else " (it wasn't connected).")
|
|
252
|
+
|
|
253
|
+
|
|
177
254
|
def codex_connected() -> bool:
|
|
178
255
|
try:
|
|
179
256
|
return any(_is_our_header(l) for l in codex_config().read_text(encoding="utf-8").splitlines())
|
|
@@ -192,4 +269,6 @@ def generic_instructions() -> str:
|
|
|
192
269
|
|
|
193
270
|
|
|
194
271
|
CONNECTORS = {"claude-code": connect_claude_code, "cursor": connect_cursor, "codex": connect_codex}
|
|
272
|
+
DISCONNECTORS = {"claude-code": disconnect_claude_code, "cursor": disconnect_cursor, "codex": disconnect_codex}
|
|
273
|
+
LABELS = {"claude-code": "Claude Code", "cursor": "Cursor", "codex": "Codex"}
|
|
195
274
|
CHECKS = {"claude-code": claude_code_connected, "cursor": cursor_connected, "codex": codex_connected}
|
|
@@ -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:
|