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.
- {aether_cache-0.1.1 → aether_cache-0.1.2}/PKG-INFO +2 -1
- {aether_cache-0.1.1 → aether_cache-0.1.2}/README.md +1 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/__init__.py +1 -1
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/cli.py +19 -0
- aether_cache-0.1.2/aether_cache/repos.py +134 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/index.html +211 -14
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui.py +36 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/pyproject.toml +1 -1
- {aether_cache-0.1.1 → aether_cache-0.1.2}/.gitignore +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/__main__.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/config.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/connect.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/engine/aether-engine.jar +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/engine.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/server.py +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/OFL.txt +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- {aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- {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.
|
|
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 |
|
|
@@ -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")
|
|
569
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-mono-latin-400-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-mono-latin-500-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-400-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-500-normal.woff2
RENAMED
|
File without changes
|
{aether_cache-0.1.1 → aether_cache-0.1.2}/aether_cache/ui/fonts/ibm-plex-sans-latin-600-normal.woff2
RENAMED
|
File without changes
|