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.
Files changed (24) hide show
  1. {aether_cache-0.1.2 → aether_cache-0.1.4}/.gitignore +1 -0
  2. aether_cache-0.1.2/README.md → aether_cache-0.1.4/PKG-INFO +30 -4
  3. aether_cache-0.1.2/PKG-INFO → aether_cache-0.1.4/README.md +17 -16
  4. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/__init__.py +1 -1
  5. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/cli.py +187 -4
  6. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/connect.py +87 -8
  7. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/engine.py +36 -14
  8. aether_cache-0.1.4/aether_cache/javart.py +174 -0
  9. aether_cache-0.1.4/aether_cache/portability.py +354 -0
  10. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/server.py +13 -0
  11. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/index.html +248 -11
  12. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui.py +73 -1
  13. aether_cache-0.1.4/docs/README.md +41 -0
  14. {aether_cache-0.1.2 → aether_cache-0.1.4}/pyproject.toml +3 -2
  15. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/__main__.py +0 -0
  16. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/config.py +0 -0
  17. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/engine/aether-engine.jar +0 -0
  18. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/repos.py +0 -0
  19. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/OFL.txt +0 -0
  20. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  21. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  22. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  23. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  24. {aether_cache-0.1.2 → aether_cache-0.1.4}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
@@ -19,3 +19,4 @@ dist/
19
19
  .impeccable/
20
20
  PRODUCT.md
21
21
  DESIGN.md
22
+ .DS_Store
@@ -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
  [![progress-banner](https://backend.codecrafters.io/progress/redis/f43a7cd3-11e6-45dc-a8ba-9d6690c73ac0)](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 **Java 11+** (`brew install openjdk@21` on macOS, or [adoptium.net](https://adoptium.net)) and
93
- [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes care of
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 stop && uv tool 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
  [![progress-banner](https://backend.codecrafters.io/progress/redis/f43a7cd3-11e6-45dc-a8ba-9d6690c73ac0)](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 **Java 11+** (`brew install openjdk@21` on macOS, or [adoptium.net](https://adoptium.net)) and
105
- [**uv**](https://docs.astral.sh/uv/) (`curl -LsSf https://astral.sh/uv/install.sh | sh`), which also takes care of
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 stop && uv tool 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)
@@ -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.2"
3
+ __version__ = "0.1.4"
@@ -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
@@ -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"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: