aether-cache 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {aether_cache-0.1.1 → aether_cache-0.1.2}/PKG-INFO +2 -1
  2. {aether_cache-0.1.1 → aether_cache-0.1.2}/README.md +1 -0
  3. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/__init__.py +1 -1
  4. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/cli.py +19 -0
  5. aether_cache-0.1.2/aether_cache/repos.py +134 -0
  6. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/index.html +211 -14
  7. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui.py +36 -0
  8. {aether_cache-0.1.1 → aether_cache-0.1.2}/pyproject.toml +1 -1
  9. {aether_cache-0.1.1 → aether_cache-0.1.2}/.gitignore +0 -0
  10. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/__main__.py +0 -0
  11. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/config.py +0 -0
  12. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/connect.py +0 -0
  13. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/engine/aether-engine.jar +0 -0
  14. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/engine.py +0 -0
  15. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/server.py +0 -0
  16. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/OFL.txt +0 -0
  17. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  18. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  19. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  20. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  21. {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aether-cache
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Shared memory for AI coding tools that stays true to your code.
5
5
  Project-URL: Homepage, https://github.com/anishchugh05/Aether-Cache-Redis-Compatible-Vector-Store
6
6
  Author: Anish Chugh
@@ -124,6 +124,7 @@ the engine only accepts connections from it, and your memories live in `~/.aethe
124
124
  | Command | What it does |
125
125
  |---|---|
126
126
  | `aether-cache setup` | One-time setup: engine, model, and AI tools |
127
+ | `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 |
127
128
  | `aether-cache ui` | Opens the dashboard: see what your AI remembers, and confirm or retire memories whose files changed |
128
129
  | `aether-cache status` | Is it running, and what's in this project's memory |
129
130
  | `aether-cache doctor` | Checks everything and tells you how to fix what's wrong |
@@ -112,6 +112,7 @@ the engine only accepts connections from it, and your memories live in `~/.aethe
112
112
  | Command | What it does |
113
113
  |---|---|
114
114
  | `aether-cache setup` | One-time setup: engine, model, and AI tools |
115
+ | `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 |
115
116
  | `aether-cache ui` | Opens the dashboard: see what your AI remembers, and confirm or retire memories whose files changed |
116
117
  | `aether-cache status` | Is it running, and what's in this project's memory |
117
118
  | `aether-cache doctor` | Checks everything and tells you how to fix what's wrong |
@@ -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.1"
3
+ __version__ = "0.1.2"
@@ -179,6 +179,22 @@ def _tool_output(name: str, *tool_args) -> int:
179
179
  return 0
180
180
 
181
181
 
182
+ def cmd_add(args) -> int:
183
+ from . import repos
184
+ from .server import EngineDown, store_down
185
+ try:
186
+ engine.ensure_running()
187
+ added = repos.add(args.path)
188
+ except repos.RepoError as e:
189
+ print(e)
190
+ return 1
191
+ except (engine.EngineError, EngineDown) as e:
192
+ print(store_down(e))
193
+ return 1
194
+ print(f"Added {added['name']} ({added['path']})" + (f", github.com/{added['github']}" if added["github"] else "") + ".")
195
+ return 0
196
+
197
+
182
198
  def cmd_serve(args) -> int:
183
199
  from .server import serve
184
200
  serve()
@@ -220,6 +236,9 @@ def main(argv=None) -> None:
220
236
  func=lambda a: _tool_output("sync_to_files"))
221
237
  sub.add_parser("check", help="retire memories whose linked files changed").set_defaults(
222
238
  func=lambda a: _tool_output("check_stale"))
239
+ p = sub.add_parser("add", help="add a repo so the dashboard can check its files (default: this folder)")
240
+ p.add_argument("path", nargs="?", default=".")
241
+ p.set_defaults(func=cmd_add)
223
242
  p = sub.add_parser("ui", help="open the dashboard: see and fix what your AI tools remember")
224
243
  p.add_argument("--port", type=int, help="port for the dashboard (default 7381)")
225
244
  p.add_argument("--no-open", action="store_true", help="don't open a browser, just print the link")
@@ -0,0 +1,134 @@
1
+ """
2
+ Finding and adding the git repos whose memories Aether Cache manages.
3
+
4
+ Adding a repo just records where it lives (aether:repo:<project> -> folder), which lets the dashboard check
5
+ its memories' files from anywhere. Nothing is read from the repo beyond git metadata; nothing leaves the machine.
6
+ """
7
+ import os
8
+ import platform
9
+ import re
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+ from . import server
16
+
17
+ # Where people usually keep code. Only these are searched, a few levels deep, and only when asked.
18
+ SEARCH_ROOTS = ["Desktop", "Documents", "Projects", "projects", "Developer", "dev", "code", "Code", "src",
19
+ "repos", "git", "GitHub", "workspace", "work", "Sites"]
20
+ SKIP = {"node_modules", "Library", "Applications", "venv", ".venv", "env", "__pycache__", "build", "dist", "target",
21
+ "vendor", "Pods", "DerivedData", ".Trash", "Pictures", "Music", "Movies", "Downloads"}
22
+ MAX_DEPTH = 4
23
+ MAX_RESULTS = 300
24
+ TIME_BUDGET = 3.0 # seconds; a huge Documents folder shouldn't make the dashboard hang
25
+
26
+
27
+ class RepoError(Exception):
28
+ pass
29
+
30
+
31
+ def git_top(path: Path) -> Optional[Path]:
32
+ """The repo's top folder if path is inside a git repo, else None."""
33
+ try:
34
+ out = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=path, capture_output=True, text=True, timeout=5)
35
+ except (OSError, subprocess.SubprocessError):
36
+ return None
37
+ return Path(out.stdout.strip()) if out.returncode == 0 and out.stdout.strip() else None
38
+
39
+
40
+ def github_remote(path: Path) -> Optional[str]:
41
+ """"owner/repo" if the repo's origin is on GitHub (read from local git config, no network), else None."""
42
+ try:
43
+ out = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=path, capture_output=True, text=True, timeout=5)
44
+ except (OSError, subprocess.SubprocessError):
45
+ return None
46
+ m = re.search(r"github\.com[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?/?$", out.stdout.strip())
47
+ return f"{m.group(1)}/{m.group(2)}" if m else None
48
+
49
+
50
+ def discover() -> List[dict]:
51
+ """Git repos in the usual code folders under your home folder, newest first."""
52
+ home = Path.home()
53
+ roots = [home / r for r in SEARCH_ROOTS if (home / r).is_dir()]
54
+ found, seen = [], set()
55
+ deadline = time.monotonic() + TIME_BUDGET
56
+
57
+ def walk(folder: Path, depth: int) -> None:
58
+ if depth > MAX_DEPTH or len(found) >= MAX_RESULTS or time.monotonic() > deadline:
59
+ return
60
+ try:
61
+ entries = list(os.scandir(folder))
62
+ except OSError:
63
+ return # no permission (e.g. macOS-protected folders); skip quietly
64
+ if any(e.name == ".git" for e in entries):
65
+ real = folder.resolve()
66
+ if real not in seen:
67
+ seen.add(real)
68
+ try:
69
+ modified = (folder / ".git").stat().st_mtime
70
+ except OSError:
71
+ modified = 0
72
+ found.append({"path": str(folder), "modified": modified})
73
+ return # don't look for repos inside a repo
74
+ for e in entries:
75
+ if e.is_dir(follow_symlinks=False) and not e.name.startswith(".") and e.name not in SKIP:
76
+ walk(Path(e.path), depth + 1)
77
+
78
+ for root in roots:
79
+ walk(root, 1)
80
+ found.sort(key=lambda r: -r["modified"])
81
+ for r in found:
82
+ r["name"] = Path(r["path"]).name
83
+ r["github"] = github_remote(Path(r["path"]))
84
+ return found
85
+
86
+
87
+ def add(path: str) -> dict:
88
+ """Adds a project by folder (for a git repo, any folder inside it works). Returns {name, path, github}."""
89
+ folder = Path(path).expanduser()
90
+ if not folder.is_dir():
91
+ raise RepoError(f"{path} isn't a folder.")
92
+ # A git repo is added by its top folder (so memories match from any subfolder). A plain folder works too:
93
+ # file fingerprints don't need git, and the AI tools treat a non-git folder as a project named after it.
94
+ top = git_top(folder) or folder.resolve()
95
+ name = top.name
96
+ existing = server.db().get(server.REPO_KEY + name)
97
+ if existing and Path(existing).resolve() != top.resolve() and Path(existing).is_dir():
98
+ raise RepoError(f"A different repo with the same folder name, {name}, is already added ({existing}). "
99
+ f"Projects are named after their folder, so rename one of the folders to keep both.")
100
+ server.db().set(server.REPO_KEY + name, str(top))
101
+ return {"name": name, "path": str(top), "github": github_remote(top)}
102
+
103
+
104
+ def remove(name: str) -> None:
105
+ """Forgets where a repo lives. Its memories are kept; it just stops being listed if it has none."""
106
+ server.db().set(server.REPO_KEY + name, "") # the engine has no DEL; an empty location means "not added"
107
+
108
+
109
+ def pick_folder() -> Optional[str]:
110
+ """Opens this computer's own folder picker and returns the chosen path, or None if cancelled.
111
+ Raises RepoError if there's no picker available (then the user can type a path instead)."""
112
+ system = platform.system()
113
+ if system == "Darwin":
114
+ # "tell me to activate" brings the picker to the front without asking to control another app
115
+ script = ('tell me to activate\n'
116
+ 'POSIX path of (choose folder with prompt "Choose a project folder to add to Aether Cache")')
117
+ cmd = ["osascript", "-e", script]
118
+ elif system == "Windows":
119
+ ps = ("Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; "
120
+ "$d.Description = 'Choose a git repo to add to Aether Cache'; "
121
+ "if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }")
122
+ cmd = ["powershell", "-NoProfile", "-Command", ps]
123
+ else:
124
+ cmd = ["zenity", "--file-selection", "--directory", "--title=Choose a git repo to add to Aether Cache"]
125
+ try:
126
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
127
+ except FileNotFoundError:
128
+ raise RepoError("This computer has no folder picker available. Paste the folder's path instead.")
129
+ except subprocess.TimeoutExpired:
130
+ return None
131
+ if out.returncode != 0: # cancelled
132
+ return None
133
+ chosen = out.stdout.strip()
134
+ return (chosen.rstrip("/\\") or chosen) or None
@@ -84,7 +84,8 @@ nav.rail {
84
84
  }
85
85
  .wordmark { display: flex; align-items: center; gap: 10px; padding: 18px 20px 22px; font-weight: 600; font-size: var(--t-md); letter-spacing: -0.01em; }
86
86
  .rail h2 { font-size: var(--t-xs); font-weight: 500; color: var(--rail-2); margin: 0 20px var(--s2); }
87
- .projects { list-style: none; margin: 0; padding: 0 10px; overflow-y: auto; flex: 1; }
87
+ .projects { list-style: none; margin: 0; padding: 0 10px; overflow-y: auto; flex: 0 1 auto; }
88
+ .projects:empty { display: none; }
88
89
  .project {
89
90
  display: block; width: 100%; text-align: left; border: 0; background: none; color: inherit; cursor: pointer;
90
91
  padding: 9px 10px; border-radius: 6px; transition: background-color 150ms ease;
@@ -93,7 +94,7 @@ nav.rail {
93
94
  .project .meta { display: block; font-size: var(--t-xs); color: var(--rail-2); }
94
95
  .project .meta .hot { color: var(--rail-stale); }
95
96
  .project[aria-current="true"] { background: var(--rail-active); }
96
- .engine { display: flex; align-items: center; gap: var(--s2); padding: 14px 20px; font-size: var(--t-xs); color: var(--rail-2); border-top: 1px solid var(--rail-active); }
97
+ .engine { display: flex; align-items: center; gap: var(--s2); margin-top: auto; padding: 14px 20px; font-size: var(--t-xs); color: var(--rail-2); border-top: 1px solid var(--rail-active); }
97
98
  .engine .dot { width: 7px; height: 7px; border-radius: 50%; background: #3BBF8E; flex-shrink: 0; }
98
99
  .engine.down .dot { background: var(--rail-stale); }
99
100
  .engine .ver { margin-left: auto; font-family: var(--mono); }
@@ -252,6 +253,38 @@ ol.timeline .details { font-size: var(--t-sm); color: var(--ink-2); margin: 1px
252
253
  .blocker p { color: var(--ink-2); }
253
254
  .blocker code { font-family: var(--mono); font-size: 0.9em; color: var(--ink); background: var(--surface); border: 1px solid var(--rule); padding: 2px 6px; border-radius: 4px; }
254
255
 
256
+ /* ---------- add a project ---------- */
257
+ .add-project {
258
+ display: flex; align-items: center; gap: 8px; width: calc(100% - 20px); margin: 6px 10px 12px; padding: 8px 10px;
259
+ border: 1px dashed color-mix(in srgb, var(--rail-2) 55%, transparent); border-radius: 6px; background: none;
260
+ color: var(--rail-ink); font-size: var(--t-sm); cursor: pointer; transition: background-color 150ms ease, border-color 150ms ease, transform 160ms var(--ease-out);
261
+ }
262
+ .add-project:active { transform: scale(0.98); }
263
+ .add-project[aria-current="true"] { background: var(--rail-active); border-style: solid; border-color: var(--rail-active); }
264
+ .source-tabs { margin-bottom: var(--s4); }
265
+ .repo-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 12px 16px; }
266
+ .repo-row + .repo-row { border-top: 1px solid var(--rule); }
267
+ .repo-row .name { font-weight: 500; overflow-wrap: anywhere; }
268
+ .repo-row .where { display: block; font-size: var(--t-xs); color: var(--ink-2); overflow-wrap: break-word; }
269
+ .repo-row .gh { display: block; font-size: var(--t-xs); color: var(--ink-2); }
270
+ .added-word { font-size: var(--t-xs); font-weight: 600; color: var(--fresh); display: inline-flex; gap: 6px; align-items: center; }
271
+ .path-form { display: flex; gap: var(--s2); flex-wrap: wrap; margin-top: 6px; }
272
+ .path-label { display: block; margin-top: var(--s5); font-size: var(--t-sm); font-weight: 500; }
273
+ .path-form input { flex: 1 1 280px; border: 1px solid var(--rule); border-radius: 6px; background: var(--surface); height: 34px; padding: 0 12px; font-family: var(--mono); font-size: var(--t-xs); }
274
+ .path-form input:focus { outline: none; border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 20%, transparent); }
275
+ .note { padding: 18px 20px; font-size: var(--t-sm); color: var(--ink-2); }
276
+ .note p { margin: 0 0 10px; max-width: 70ch; } .note p:last-child { margin-bottom: 0; }
277
+ .note .example { background: var(--surface-2); border-radius: 6px; padding: 10px 14px; color: var(--ink); font-size: var(--t-md); max-width: none; }
278
+ .note strong { color: var(--ink); font-weight: 600; }
279
+ .head-links { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline; margin-top: 2px; font-size: var(--t-xs); color: var(--ink-2); }
280
+ .head-links a { color: var(--ink-2); text-underline-offset: 3px; text-decoration-color: var(--rule); }
281
+ .linkish { border: 0; background: none; padding: 0; color: var(--ink-2); font-size: var(--t-xs); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--rule); cursor: pointer; }
282
+ [hidden] { display: none !important; }
283
+ @media (hover: hover) and (pointer: fine) {
284
+ .add-project:hover { background: var(--rail-active); border-color: var(--rail-2); }
285
+ .head-links a:hover, .linkish:hover { color: var(--ink); text-decoration-color: currentColor; }
286
+ }
287
+
255
288
  /* ---------- small screens ---------- */
256
289
  @media (max-width: 900px) {
257
290
  .app, .app.has-detail { grid-template-columns: 1fr; grid-template-rows: auto 1fr; height: auto; min-height: 100vh; }
@@ -260,6 +293,7 @@ ol.timeline .details { font-size: var(--t-sm); color: var(--ink-2); margin: 1px
260
293
  .rail h2, .engine { display: none; }
261
294
  .projects { display: flex; gap: 4px; padding: 8px 8px 8px 0; overflow: visible; }
262
295
  .project { width: auto; max-width: 200px; padding: 6px 10px; }
296
+ .add-project { width: auto; margin: 0 8px 0 0; flex-shrink: 0; white-space: nowrap; }
263
297
  .page { padding: 20px 16px 56px; }
264
298
  .pair { grid-template-columns: 1fr; }
265
299
  aside.detail { position: fixed; inset: 0; z-index: 10; border-left: 0; }
@@ -300,6 +334,7 @@ const state = {
300
334
  projects: [], project: null, filter: "any", q: "",
301
335
  memories: [], attention: null, selected: null, detail: null,
302
336
  version: "", engineUp: true, loadingList: false, listLoaded: false,
337
+ view: "project", source: "local", discovered: null, discovering: false,
303
338
  };
304
339
  const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
305
340
 
@@ -329,6 +364,9 @@ function icon(name, size = 14) {
329
364
  search: '<circle cx="7" cy="7" r="4.75"/><path d="M10.5 10.5 14 14"/>',
330
365
  arrow: '<path d="M2 8h11M9.5 4.5 13 8l-3.5 3.5"/>',
331
366
  close: '<path d="M4 4l8 8M12 4l-8 8"/>',
367
+ plus: '<path d="M8 3v10M3 8h10"/>',
368
+ folder: '<path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h2.6l1.5 1.5h4.9A1.5 1.5 0 0 1 14 6v5.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 11.5z"/>',
369
+ check: '<path d="M3 8.5 6.5 12 13 4.5"/>',
332
370
  };
333
371
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
334
372
  svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("width", size); svg.setAttribute("height", size);
@@ -491,6 +529,7 @@ async function refreshAll() {
491
529
  }
492
530
 
493
531
  async function selectProject(name) {
532
+ showView("project");
494
533
  state.project = name; state.q = ""; state.filter = "any"; state.selected = null; state.detail = null;
495
534
  state.memories = []; state.listLoaded = false; state.attention = null;
496
535
  try { sessionStorage.setItem("aether-project", name); } catch (e) {}
@@ -500,6 +539,146 @@ async function selectProject(name) {
500
539
  try { await refreshAll(); } catch (e) { notify(e.message); }
501
540
  }
502
541
 
542
+ // ---------- add a project ----------
543
+ function showView(view) {
544
+ state.view = view;
545
+ document.getElementById("project-view").hidden = view !== "project";
546
+ document.getElementById("add-view").hidden = view !== "add";
547
+ if (view === "add") document.querySelector(".app").classList.remove("has-detail");
548
+ renderProjects();
549
+ }
550
+
551
+ async function openAdd(firstRun) {
552
+ state.firstRun = !!firstRun;
553
+ showView("add");
554
+ renderAdd();
555
+ document.querySelector("#add-view h1")?.focus();
556
+ if (!state.discovered && !state.discovering) {
557
+ state.discovering = true;
558
+ try { state.discovered = (await api("/api/discover")).repos; }
559
+ catch (e) { state.discovered = []; notify(e.message); }
560
+ finally { state.discovering = false; renderAdd(); }
561
+ }
562
+ }
563
+
564
+ async function addRepo(path, button) {
565
+ if (button) button.disabled = true;
566
+ try {
567
+ const { project } = await api("/api/projects/add", { path });
568
+ notify(`Added ${project.name}.`);
569
+ if (state.discovered) {
570
+ const found = state.discovered.find((r) => r.path === project.path);
571
+ if (found) found.added = true;
572
+ else state.discovered.unshift({ name: project.name, path: project.path, github: project.github, added: true });
573
+ }
574
+ await loadProjects();
575
+ renderProjects();
576
+ renderAdd();
577
+ return project;
578
+ } catch (e) {
579
+ notify(e.message);
580
+ if (button) button.disabled = false;
581
+ return null;
582
+ }
583
+ }
584
+
585
+ async function chooseFolder(button) {
586
+ button.disabled = true;
587
+ try {
588
+ const { path } = await api("/api/pick-folder", {});
589
+ if (path) await addRepo(path);
590
+ } catch (e) {
591
+ notify(e.message);
592
+ document.getElementById("path-input")?.focus();
593
+ } finally {
594
+ button.disabled = false;
595
+ }
596
+ }
597
+
598
+ async function removeProject(name) {
599
+ try {
600
+ await api("/api/projects/remove", { name });
601
+ notify(`Removed ${name} from the dashboard.`);
602
+ if (state.discovered) state.discovered.forEach((r) => { if (r.name === name) r.added = false; });
603
+ state.project = null;
604
+ await loadProjects();
605
+ if (state.projects.length) await selectProject(state.projects[0].name); else openAdd(true);
606
+ } catch (e) { notify(e.message); }
607
+ }
608
+
609
+ function renderAdd() {
610
+ const view = document.getElementById("add-view");
611
+ const filter = state.addFilter || "";
612
+ const sourceTabs = h("div", { class: "tabs source-tabs", role: "group", "aria-label": "Where your repos are" },
613
+ [["local", "On this computer"], ["github", "GitHub"]].map(([key, label]) =>
614
+ h("button", { type: "button", "aria-pressed": state.source === key ? "true" : "false",
615
+ onclick: () => { state.source = key; renderAdd(); } }, label)));
616
+
617
+ let body;
618
+ if (state.source === "github") {
619
+ body = h("div", { class: "panel note" },
620
+ h("p", {}, h("strong", {}, "GitHub is coming later. "), "Connecting GitHub will let a team share one project memory, and flag pull requests that would make memories stale before they merge."),
621
+ h("p", {}, "For now, add repos you’ve cloned on this computer. That’s where your AI tools actually work, so it’s where their memories live, and it means nothing ever leaves your machine."));
622
+ } else {
623
+ const pick = h("button", { class: "btn primary", type: "button", onclick: () => chooseFolder(pick) }, icon("folder", 14), " Choose a folder…");
624
+ pick.style.display = "inline-flex"; pick.style.alignItems = "center"; pick.style.gap = "6px";
625
+ const filterInput = h("input", { type: "search", placeholder: "Filter repos by name or folder", "aria-label": "Filter the repos found",
626
+ value: filter, autocomplete: "off", spellcheck: "false" });
627
+ filterInput.addEventListener("input", () => { state.addFilter = filterInput.value; renderRepoList(); });
628
+ const pathInput = h("input", { id: "path-input", type: "text", placeholder: "~/code/my-repo", spellcheck: "false" });
629
+ const addPath = h("button", { class: "btn", type: "button", onclick: async () => {
630
+ if (pathInput.value.trim() && await addRepo(pathInput.value.trim(), addPath)) pathInput.value = ""; } }, "Add path");
631
+ pathInput.addEventListener("keydown", (e) => { if (e.key === "Enter") addPath.click(); });
632
+ body = [
633
+ h("div", { class: "toolbar" }, h("div", { class: "search" }, icon("search", 15), filterInput), pick),
634
+ h("div", { id: "repo-list" }),
635
+ h("label", { class: "path-label", for: "path-input" }, "Or paste a project folder’s path"),
636
+ h("div", { class: "path-form" }, pathInput, addPath),
637
+ ];
638
+ }
639
+ put(view,
640
+ h("div", { class: "page-head" },
641
+ h("h1", { tabindex: "-1" }, state.firstRun ? "Pick the repos you work in" : "Add a project"),
642
+ h("p", { class: "lede", style: "margin-top:6px" }, state.firstRun
643
+ ? "Aether Cache keeps your AI tools’ memories per repo and checks them against the code. Add the repos you use with AI. Only their location is saved, and nothing leaves this computer."
644
+ : "Add a repo so its memories show up here and get checked against its files. Only its location is saved, and nothing leaves this computer.")),
645
+ sourceTabs, body);
646
+ if (state.source === "local") renderRepoList();
647
+ }
648
+
649
+ function renderRepoList() {
650
+ const box = document.getElementById("repo-list");
651
+ if (!box) return;
652
+ if (state.discovering || !state.discovered) {
653
+ put(box, h("div", { class: "panel skeleton", "aria-busy": "true", "aria-label": "Looking for git repos" },
654
+ h("p", { class: "note", style: "padding-bottom:0" }, "Looking for git repos in your usual code folders…"),
655
+ [60, 44, 52].map((w, i) => h("div", { class: "repo-row", style: i ? "border-top:1px solid var(--rule)" : "" },
656
+ h("span", {}, h("span", { class: "bar", style: `width:${w}%;margin-bottom:8px` }), h("span", { class: "bar", style: `width:${w + 20}%` })), h("span")))));
657
+ return;
658
+ }
659
+ const f = (state.addFilter || "").toLowerCase();
660
+ const repos = state.discovered.filter((r) => !f || r.name.toLowerCase().includes(f) || r.path.toLowerCase().includes(f));
661
+ if (!state.discovered.length) {
662
+ put(box, h("div", { class: "panel note" }, h("p", {}, h("strong", {}, "No git repos found "),
663
+ "in Desktop, Documents, Projects, Developer, code, or similar folders. Choose a folder, or paste its path below.")));
664
+ return;
665
+ }
666
+ if (!repos.length) { put(box, h("div", { class: "panel empty" }, `No repos match “${state.addFilter}”.`)); return; }
667
+ put(box, h("div", { class: "panel" }, h("div", { role: "list", "aria-label": `${plural(repos.length, "repo")} found` },
668
+ repos.map((r) => {
669
+ const add = h("button", { class: "btn", type: "button", onclick: () => addRepo(r.path, add) }, "Add");
670
+ return h("div", { class: "repo-row", role: "listitem" },
671
+ h("div", {},
672
+ h("span", { class: "name" }, r.name),
673
+ h("span", { class: "where mono" }, pathNodes(r.path.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"))),
674
+ r.github ? h("span", { class: "gh" }, `github.com/${r.github}`) : null),
675
+ r.added
676
+ ? h("span", { class: "actions" }, h("span", { class: "added-word" }, icon("check", 13), "Added"),
677
+ h("button", { class: "btn", type: "button", onclick: () => selectProject(r.name) }, "Open"))
678
+ : add);
679
+ }))));
680
+ }
681
+
503
682
  // ---------- actions ----------
504
683
  const wait = (ms) => new Promise((r) => setTimeout(r, REDUCED ? 0 : ms));
505
684
 
@@ -555,18 +734,30 @@ function renderEngine() {
555
734
  function renderProjects() {
556
735
  put(document.getElementById("projects"), state.projects.map((p) => {
557
736
  const total = p.counts.active + p.counts.stale + p.counts.retired + p.counts.superseded;
558
- return h("li", {}, h("button", { class: "project", type: "button", "aria-current": p.name === state.project ? "true" : "false",
737
+ return h("li", {}, h("button", { class: "project", type: "button", "aria-current": state.view === "project" && p.name === state.project ? "true" : "false",
559
738
  onclick: () => selectProject(p.name), title: p.name },
560
739
  h("span", { class: "name" }, p.name),
561
740
  h("span", { class: "meta" }, plural(total, "memory", "memories"),
562
741
  p.counts.stale ? [", ", h("span", { class: "hot" }, `${p.counts.stale} stale`)] : null)));
563
742
  }));
743
+ const heading = document.getElementById("projects-h");
744
+ if (heading) heading.hidden = !state.projects.length;
745
+ const add = document.getElementById("add-project");
746
+ if (add) add.setAttribute("aria-current", state.view === "add" ? "true" : "false");
564
747
  }
565
748
 
749
+ const totalOf = (p) => p.counts.active + p.counts.stale + p.counts.retired + p.counts.superseded;
750
+
566
751
  function renderHead() {
567
752
  const p = state.projects.find((x) => x.name === state.project);
568
- put(document.getElementById("head"), p ? [h("h1", {}, p.name),
569
- h("span", { class: "repo mono" }, p.repo || "Repo location not known yet")] : null);
753
+ if (!p) { put(document.getElementById("head")); return; }
754
+ const remove = p.repo && totalOf(p) === 0
755
+ ? h("button", { class: "linkish", type: "button", onclick: () => removeProject(p.name) }, "Remove from dashboard") : null;
756
+ put(document.getElementById("head"), h("h1", {}, p.name),
757
+ h("div", { class: "head-links" },
758
+ h("span", { class: "mono" }, p.repo ? pathNodes(p.repo) : "Repo location not known yet"),
759
+ p.github ? h("a", { href: `https://github.com/${p.github}`, target: "_blank", rel: "noopener noreferrer" }, `github.com/${p.github}`) : null,
760
+ remove));
570
761
  }
571
762
 
572
763
  function renderAttention() {
@@ -626,6 +817,9 @@ function renderAttention() {
626
817
  ? "These went stale because files they depend on changed, so your AI no longer sees them. Confirm the ones that are still true and retire the rest."
627
818
  : "Your AI saved facts that may contradict each other. Decide which one holds."),
628
819
  h("div", { class: "panel" }, items));
820
+ } else if (state.projects.some((x) => x.name === state.project && totalOf(x) === 0)) {
821
+ put(sec, head, h("div", { class: "panel calm" }, pip("active"),
822
+ h("span", {}, h("strong", {}, "Nothing to decide yet. "), "When a file that a memory depends on changes, the memory shows up here.")));
629
823
  } else if (a.checked_files) {
630
824
  put(sec, head, h("div", { class: "panel calm" }, pip("active"),
631
825
  h("span", {}, h("strong", {}, "Nothing to decide. "), "Every linked file still matches what its memory was based on.")));
@@ -658,6 +852,14 @@ function renderList() {
658
852
  }
659
853
  if (!state.memories.length) {
660
854
  const kind = state.filter === "any" ? "" : STATE_WORD[state.filter] + " ";
855
+ const p = state.projects.find((x) => x.name === state.project);
856
+ if (p && totalOf(p) === 0 && !state.q) {
857
+ put(list, h("div", { class: "panel note" },
858
+ h("p", {}, h("strong", {}, "No memories yet. "), "Open your AI tool in this repo and ask it to remember something that a future session should know, for example:"),
859
+ h("p", { class: "example" }, "“Remember that our tests need DOCKER_HOST set. It depends on docker-compose.yml.”"),
860
+ h("p", {}, "It will show up here, and Aether Cache will flag it if docker-compose.yml changes.")));
861
+ return;
862
+ }
661
863
  put(list, h("div", { class: "panel empty" }, state.q ? `No ${kind}memories match “${state.q}”.` : `No ${kind}memories in this project.`));
662
864
  return;
663
865
  }
@@ -723,10 +925,11 @@ function renderShell() {
723
925
  put(document.getElementById("root"), h("div", { class: "app" },
724
926
  h("nav", { class: "rail", "aria-label": "Projects" },
725
927
  h("div", { class: "wordmark" }, mark, "Aether Cache"),
726
- h("h2", {}, "Projects"),
928
+ h("h2", { id: "projects-h" }, "Projects"),
727
929
  h("ul", { class: "projects", id: "projects" }),
930
+ h("button", { class: "add-project", id: "add-project", type: "button", onclick: () => openAdd(false) }, icon("plus", 14), "Add project"),
728
931
  h("div", { class: "engine", id: "engine", role: "status" })),
729
- h("main", {}, h("div", { class: "page" },
932
+ h("main", {}, h("div", { class: "page", id: "add-view", hidden: true }), h("div", { class: "page", id: "project-view" },
730
933
  h("div", { class: "page-head", id: "head" }),
731
934
  h("section", { id: "attention", "aria-labelledby": "attention-h" }),
732
935
  h("section", { "aria-labelledby": "mem-h" },
@@ -758,14 +961,8 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && state.se
758
961
  renderBlocker("Can’t load your memories", h("p", {}, e.message));
759
962
  return;
760
963
  }
761
- if (!state.projects.length) {
762
- renderBlocker("No memories yet",
763
- h("p", {}, "Open your AI tool in any git repo and ask it to remember something, for example:"),
764
- h("p", {}, h("code", {}, "Remember that our tests need DOCKER_HOST set. It depends on docker-compose.yml.")),
765
- h("p", {}, "Then refresh this page."));
766
- return;
767
- }
768
964
  renderShell();
965
+ if (!state.projects.length) { renderProjects(); openAdd(true); return; }
769
966
  renderProjects(); renderHead(); renderAttention(); renderList();
770
967
  try { await refreshAll(); } catch (e) { notify(e.message); }
771
968
  })();
@@ -19,6 +19,7 @@ from urllib.parse import parse_qs, urlparse
19
19
  import redis
20
20
 
21
21
  from . import __version__, config, engine, server
22
+ from . import repos as repo_tools
22
23
 
23
24
  PAGE = Path(__file__).resolve().parent / "ui" / "index.html"
24
25
  FONTS = {f.name: f for f in (PAGE.parent / "fonts").glob("*.woff2")} # only these files are ever served
@@ -120,13 +121,44 @@ def api_projects(_q, _body) -> dict:
120
121
  if status == "stale" and not last_reason(m["id"]).endswith(FILE_CHANGE):
121
122
  status = "retired"
122
123
  p["counts"][status] += 1
124
+ for name, repo in repos.items(): # added repos show up even before they have any memories
125
+ projects.setdefault(name, {"name": name, "counts": dict.fromkeys(STATUSES + ("retired",), 0)})
123
126
  for name, p in projects.items():
124
127
  repo = repos.get(name)
125
128
  p["repo"] = repo if repo and Path(repo).is_dir() else None
129
+ p["github"] = repo_tools.github_remote(Path(p["repo"])) if p["repo"] else None
126
130
  order = sorted(projects.values(), key=lambda p: (-p["counts"]["stale"], p["name"].lower()))
127
131
  return {"projects": order, "version": __version__}
128
132
 
129
133
 
134
+ def api_discover(_q, _body) -> dict:
135
+ added = {str(Path(v).resolve()) for v in repo_locations().values() if Path(v).is_dir()}
136
+ found = repo_tools.discover()
137
+ for r in found:
138
+ r["added"] = str(Path(r["path"]).resolve()) in added
139
+ del r["modified"]
140
+ return {"repos": found}
141
+
142
+
143
+ def api_add(_q, body) -> dict:
144
+ try:
145
+ return {"project": repo_tools.add(_need(body, "path"))}
146
+ except repo_tools.RepoError as e:
147
+ raise ApiError(400, str(e))
148
+
149
+
150
+ def api_remove(_q, body) -> dict:
151
+ repo_tools.remove(_need(body, "name"))
152
+ return {"ok": True}
153
+
154
+
155
+ def api_pick(_q, _body) -> dict:
156
+ try:
157
+ return {"path": repo_tools.pick_folder()}
158
+ except repo_tools.RepoError as e:
159
+ raise ApiError(501, str(e))
160
+
161
+
130
162
  def api_memories(q, _body) -> dict:
131
163
  project = _need(q, "project")
132
164
  status = q.get("status", "any")
@@ -228,6 +260,10 @@ ROUTES = {
228
260
  ("POST", "/api/confirm"): api_confirm,
229
261
  ("POST", "/api/retire"): api_retire,
230
262
  ("POST", "/api/resolve"): api_resolve,
263
+ ("GET", "/api/discover"): api_discover,
264
+ ("POST", "/api/projects/add"): api_add,
265
+ ("POST", "/api/projects/remove"): api_remove,
266
+ ("POST", "/api/pick-folder"): api_pick,
231
267
  }
232
268
 
233
269
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "aether-cache"
7
- version = "0.1.1"
7
+ version = "0.1.2"
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"
File without changes