aether-cache 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- aether_cache/__main__.py +5 -0
- aether_cache/cli.py +240 -0
- aether_cache/config.py +44 -0
- aether_cache/connect.py +195 -0
- aether_cache/engine/aether-engine.jar +0 -0
- aether_cache/engine.py +253 -0
- aether_cache/server.py +464 -0
- aether_cache-0.1.0.dist-info/METADATA +353 -0
- aether_cache-0.1.0.dist-info/RECORD +13 -0
- aether_cache-0.1.0.dist-info/WHEEL +5 -0
- aether_cache-0.1.0.dist-info/entry_points.txt +2 -0
- aether_cache-0.1.0.dist-info/top_level.txt +1 -0
aether_cache/server.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The MCP server: the tools AI coding tools (Claude Code, Cursor, Codex, ...) call to use Aether Cache.
|
|
3
|
+
|
|
4
|
+
It turns text into embeddings, links memories to git file versions, and stores and searches them in the
|
|
5
|
+
Java engine over the Redis protocol. The engine is started automatically if it isn't running.
|
|
6
|
+
Settings are environment variables; see config.py.
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
import redis
|
|
18
|
+
|
|
19
|
+
from . import config, engine
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from mcp.server.mcpserver import MCPServer # mcp 2.x
|
|
23
|
+
except ImportError:
|
|
24
|
+
from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x
|
|
25
|
+
|
|
26
|
+
SESSION = uuid.uuid4().hex[:8] # each AI tool session starts its own copy of this server
|
|
27
|
+
|
|
28
|
+
# Shown to the AI by every MCP client, and also written into the tools' own instruction files by
|
|
29
|
+
# `aether-cache connect`, because AI tools otherwise default to their built-in memory.
|
|
30
|
+
INSTRUCTIONS = (
|
|
31
|
+
"Aether Cache is this project's shared memory, kept across sessions and across AI tools (Claude Code, "
|
|
32
|
+
"Cursor, Codex), and checked against the code so it never goes stale silently. For project knowledge, use "
|
|
33
|
+
"its tools instead of any built-in memory, notes file, or memory directory:\n"
|
|
34
|
+
"- At the start of every task, call recall with a short description of the task.\n"
|
|
35
|
+
"- When the user asks you to remember, save, or note something about the project, or you learn something "
|
|
36
|
+
"durable that a future session would need (a convention, a setup gotcha, a decision and its reason), call "
|
|
37
|
+
"remember. Pass files= with the files the fact depends on, so it's retired automatically when they change.\n"
|
|
38
|
+
"- If a memory turns out to be wrong, call mark_outdated. If a new fact makes an old one untrue, call "
|
|
39
|
+
"replace_memory."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
mcp = MCPServer(
|
|
43
|
+
"aether-cache",
|
|
44
|
+
instructions=INSTRUCTIONS,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def quiet_libraries() -> None:
|
|
48
|
+
"""Hides the download libraries' request logs, warnings and progress bars, which otherwise flood the
|
|
49
|
+
terminal (and spill out after setup says it's done). Our own messages say what's happening."""
|
|
50
|
+
import logging
|
|
51
|
+
import warnings
|
|
52
|
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
53
|
+
warnings.filterwarnings("ignore", message="Cannot enable progress bars") # fastembed asks; we said no
|
|
54
|
+
os.environ.setdefault("HF_HUB_VERBOSITY", "error")
|
|
55
|
+
for name in ("httpx", "httpcore", "huggingface_hub", "filelock", "urllib3"):
|
|
56
|
+
logging.getLogger(name).setLevel(logging.ERROR)
|
|
57
|
+
try:
|
|
58
|
+
from loguru import logger # fastembed logs its retries through loguru
|
|
59
|
+
logger.disable("fastembed")
|
|
60
|
+
except ImportError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
quiet_libraries()
|
|
65
|
+
|
|
66
|
+
_db = None
|
|
67
|
+
_embedder = None
|
|
68
|
+
_embedder_lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EngineDown(Exception):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- engine and embeddings ----------
|
|
76
|
+
|
|
77
|
+
def db() -> redis.Redis:
|
|
78
|
+
global _db
|
|
79
|
+
if _db is None:
|
|
80
|
+
_db = engine.client()
|
|
81
|
+
return _db
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(*args):
|
|
85
|
+
"""Sends one command to the engine. If the engine isn't running, starts it and tries once more."""
|
|
86
|
+
try:
|
|
87
|
+
return db().execute_command(*args)
|
|
88
|
+
except (redis.ConnectionError, redis.TimeoutError):
|
|
89
|
+
try:
|
|
90
|
+
engine.ensure_running()
|
|
91
|
+
except engine.EngineError as e:
|
|
92
|
+
raise EngineDown(f"its engine isn't running and couldn't be started. {e}")
|
|
93
|
+
return db().execute_command(*args)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def store_down(error: Exception) -> str:
|
|
97
|
+
return f"Aether Cache hit a problem: {error}\nRun `aether-cache doctor` in a terminal for help."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fake_embed(text: str, dim: int = 384) -> List[float]:
|
|
101
|
+
"""Testing only: hashes each word into a slot, so texts sharing words come out similar."""
|
|
102
|
+
v = [0.0] * dim
|
|
103
|
+
for word in text.lower().split():
|
|
104
|
+
h = int(hashlib.md5(word.strip(".,!?\"'").encode()).hexdigest(), 16)
|
|
105
|
+
v[h % dim] += 1.0 if (h >> 64) % 2 else -1.0
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def embedder():
|
|
110
|
+
"""The embedding model, loaded (and downloaded, the first time) on first use."""
|
|
111
|
+
global _embedder
|
|
112
|
+
with _embedder_lock:
|
|
113
|
+
if _embedder is None:
|
|
114
|
+
from fastembed import TextEmbedding # imported lazily so the server starts instantly
|
|
115
|
+
config.MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
# a fixed cache folder: fastembed's default is a temp folder, which the OS may wipe on reboot
|
|
117
|
+
_embedder = TextEmbedding(config.MODEL, cache_dir=str(config.MODELS_DIR))
|
|
118
|
+
return _embedder
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def embed(text: str) -> List[str]:
|
|
122
|
+
"""Text -> embedding, formatted as strings ready to send."""
|
|
123
|
+
if config.FAKE_EMBEDDER:
|
|
124
|
+
vec = fake_embed(text)
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
vec = next(iter(embedder().embed([text]))).tolist()
|
|
128
|
+
except Exception as e:
|
|
129
|
+
raise EngineDown(f"the embedding model couldn't be loaded ({e}). The first run downloads it "
|
|
130
|
+
f"(about 90 MB), so check your internet connection.")
|
|
131
|
+
return [f"{x:.6f}" for x in vec]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def warm_up() -> None:
|
|
135
|
+
"""Starts the engine and loads the model in the background, so the AI's first call is fast."""
|
|
136
|
+
def work():
|
|
137
|
+
try:
|
|
138
|
+
engine.ensure_running()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass # reported properly when a tool is actually called
|
|
141
|
+
try:
|
|
142
|
+
if not config.FAKE_EMBEDDER:
|
|
143
|
+
embedder()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
threading.Thread(target=work, daemon=True).start()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def when(ms: str) -> str:
|
|
150
|
+
return datetime.fromtimestamp(int(ms) / 1000).strftime("%Y-%m-%d %H:%M")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------- the project and its files ----------
|
|
154
|
+
|
|
155
|
+
def workdir() -> Path:
|
|
156
|
+
override = config.repo_override()
|
|
157
|
+
if override and Path(override).expanduser().is_dir():
|
|
158
|
+
return Path(override).expanduser()
|
|
159
|
+
return Path.cwd()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def git_toplevel() -> Optional[Path]:
|
|
163
|
+
try:
|
|
164
|
+
top = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=workdir(),
|
|
165
|
+
capture_output=True, text=True, timeout=5)
|
|
166
|
+
except (OSError, subprocess.SubprocessError):
|
|
167
|
+
return None # git not installed, or too slow
|
|
168
|
+
if top.returncode == 0 and top.stdout.strip():
|
|
169
|
+
return Path(top.stdout.strip())
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def project_name() -> str:
|
|
174
|
+
"""The project memories belong to: AETHER_PROJECT, else the git repo's folder name, else this folder's."""
|
|
175
|
+
return config.project_override() or (git_toplevel() or workdir()).name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def repo_root() -> Path:
|
|
179
|
+
"""File paths in memories are relative to this: the git repo's top folder, else the current folder."""
|
|
180
|
+
return git_toplevel() or workdir()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def relative(path: str) -> str:
|
|
184
|
+
"""Makes a path repo-relative, so memories still match if the repo is opened from a subfolder."""
|
|
185
|
+
p = Path(path.strip())
|
|
186
|
+
if p.is_absolute():
|
|
187
|
+
try:
|
|
188
|
+
return p.resolve().relative_to(repo_root().resolve()).as_posix()
|
|
189
|
+
except ValueError:
|
|
190
|
+
return str(p)
|
|
191
|
+
return p.as_posix()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def blob_hash(path: Path) -> str:
|
|
195
|
+
"""The same hash `git hash-object` gives for a file's contents, computed directly, so it works without git."""
|
|
196
|
+
data = path.read_bytes()
|
|
197
|
+
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def content_hashes(paths: List[str]) -> dict:
|
|
201
|
+
"""path -> hash of the file's current contents, or "missing"."""
|
|
202
|
+
root = repo_root()
|
|
203
|
+
result = {}
|
|
204
|
+
for p in paths:
|
|
205
|
+
try:
|
|
206
|
+
result[p] = blob_hash(root / p) if (root / p).is_file() else "missing"
|
|
207
|
+
except OSError:
|
|
208
|
+
result[p] = "missing"
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def link_files(paths: List[str]) -> str:
|
|
213
|
+
"""["a.py", "b.yml"] -> "a.py@<hash>,b.yml@<hash>": the files plus a snapshot of their contents."""
|
|
214
|
+
rel = [relative(p) for p in paths if p.strip()]
|
|
215
|
+
hashes = content_hashes(rel)
|
|
216
|
+
return ",".join(f"{p}@{hashes[p]}" for p in rel)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_staleness() -> List[str]:
|
|
220
|
+
"""Marks active memories stale when a file they depend on has changed since they were saved.
|
|
221
|
+
Returns one line per memory that just went stale."""
|
|
222
|
+
newly_stale = []
|
|
223
|
+
linked = [] # (id, text, [(path, saved hash)])
|
|
224
|
+
for mid, _status, files, text in run("MEM.LIST", project_name()):
|
|
225
|
+
entries = [f.rsplit("@", 1) for f in files.split(",") if "@" in f]
|
|
226
|
+
if entries:
|
|
227
|
+
linked.append((mid, text, entries))
|
|
228
|
+
if not linked:
|
|
229
|
+
return newly_stale
|
|
230
|
+
current = content_hashes(sorted({path for _, _, entries in linked for path, _ in entries}))
|
|
231
|
+
for mid, text, entries in linked:
|
|
232
|
+
changed = [path for path, saved in entries if current.get(path) != saved]
|
|
233
|
+
if changed:
|
|
234
|
+
what = ", ".join(f"{p} {'was deleted' if current.get(p) == 'missing' else 'changed'}" for p in changed)
|
|
235
|
+
run("MEM.STATUS", mid, "stale", "REASON", f"{what} since this was saved")
|
|
236
|
+
newly_stale.append(f"[{mid}] \"{text}\" is now marked outdated: {what}.")
|
|
237
|
+
return newly_stale
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------- tools the AI can call ----------
|
|
241
|
+
|
|
242
|
+
@mcp.tool()
|
|
243
|
+
def remember(fact: str, files: Optional[List[str]] = None) -> str:
|
|
244
|
+
"""Save a durable fact about this project so future sessions, in any AI tool, know it.
|
|
245
|
+
Use this, not a built-in memory or notes file, whenever the user asks you to remember something
|
|
246
|
+
about the project.
|
|
247
|
+
|
|
248
|
+
Good facts: conventions ("we use pnpm, not npm"), setup gotchas ("tests need DOCKER_HOST set"),
|
|
249
|
+
decisions and their reasons. Not: temporary task details or anything in the code already.
|
|
250
|
+
files: repo-relative paths this fact depends on (e.g. ["docker-compose.yml"]), so the memory
|
|
251
|
+
can be flagged when those files change.
|
|
252
|
+
"""
|
|
253
|
+
args = ["MEM.ADD", project_name(), fact, "SOURCE", config.SOURCE, "SESSION", SESSION]
|
|
254
|
+
if files:
|
|
255
|
+
args += ["FILES", link_files(files)]
|
|
256
|
+
try:
|
|
257
|
+
vector = embed(fact)
|
|
258
|
+
mem_id, outcome, related = run(*args, "VECTOR", *vector)
|
|
259
|
+
if outcome == "duplicate":
|
|
260
|
+
return f"Already known as {mem_id}; noted that it came up again. Nothing new was stored."
|
|
261
|
+
reply = f"Saved as {mem_id}."
|
|
262
|
+
if outcome == "possible_conflict":
|
|
263
|
+
old = dict(zip(*[iter(run("MEM.GET", related))] * 2))
|
|
264
|
+
reply += (f" It is very similar to {related}: \"{old.get('text')}\" and may contradict it. "
|
|
265
|
+
f"If unsure, ask the user, then call resolve_conflict.")
|
|
266
|
+
# Similarity can't tell "contradicts" from "related" (a real contradiction can score low), so show
|
|
267
|
+
# the closest existing memories and let the AI judge whether the new fact replaces any of them.
|
|
268
|
+
hits = run("MEM.SEARCH", project_name(), 4, "VECTOR", *vector)
|
|
269
|
+
others = [(mid, float(sc), text) for mid, sc, text in hits
|
|
270
|
+
if mid not in (mem_id, related) and float(sc) >= config.RELATED_SIMILARITY]
|
|
271
|
+
if others:
|
|
272
|
+
reply += "\nRelated existing memories:\n" + "\n".join(
|
|
273
|
+
f"[{mid}] (similarity {sc:.2f}) {text}" for mid, sc, text in others)
|
|
274
|
+
reply += (f"\nIf {mem_id} makes any of these no longer true, call replace_memory(old_id, \"{mem_id}\"). "
|
|
275
|
+
f"Otherwise do nothing.")
|
|
276
|
+
return reply
|
|
277
|
+
except EngineDown as e:
|
|
278
|
+
return store_down(e)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@mcp.tool()
|
|
282
|
+
def recall(query: str, limit: int = 5) -> str:
|
|
283
|
+
"""Search this project's shared memory by meaning. Call it at the start of every task, and whenever
|
|
284
|
+
project-specific knowledge (setup, conventions, past decisions) might matter.
|
|
285
|
+
Only current (active) memories are returned; outdated or replaced ones are skipped."""
|
|
286
|
+
try:
|
|
287
|
+
stale = check_staleness() # so we never hand back a memory whose files changed underneath it
|
|
288
|
+
hits = run("MEM.SEARCH", project_name(), max(1, min(limit, 20)), "VECTOR", *embed(query))
|
|
289
|
+
except EngineDown as e:
|
|
290
|
+
return store_down(e)
|
|
291
|
+
lines = [f"[{mid}] (similarity {float(score):.2f}) {text}" for mid, score, text in hits]
|
|
292
|
+
if not lines:
|
|
293
|
+
lines = ["No memories found for this project yet."]
|
|
294
|
+
if stale:
|
|
295
|
+
lines += ["", "These memories were just retired because files they depend on changed. If one is still "
|
|
296
|
+
"true, call confirm_memory; if it's wrong, save the corrected fact with remember:"] + stale
|
|
297
|
+
return "\n".join(lines)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@mcp.tool()
|
|
301
|
+
def explain_memory(memory_id: str) -> str:
|
|
302
|
+
"""Show where a memory came from (which tool and session saved it, which files it's linked to)
|
|
303
|
+
and everything that has happened to it since. Use it when a memory looks wrong or surprising."""
|
|
304
|
+
try:
|
|
305
|
+
r = run("MEM.WHY", memory_id)
|
|
306
|
+
except EngineDown as e:
|
|
307
|
+
return store_down(e)
|
|
308
|
+
if r is None:
|
|
309
|
+
return f"No memory with id {memory_id}."
|
|
310
|
+
d = dict(zip(r[0::2], r[1::2]))
|
|
311
|
+
lines = [f"{d['id']}: \"{d['text']}\"",
|
|
312
|
+
f"status: {d['status']}, project: {d['project']}",
|
|
313
|
+
f"saved by: {d['source']}" + (f" (session {d['session']})" if d["session"] else ""),
|
|
314
|
+
f"linked files: {d['files'] or 'none'}",
|
|
315
|
+
"history:"]
|
|
316
|
+
lines += [f" {when(t)} {event}" + (f": {details}" if details else "") for t, event, details in d["history"]]
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@mcp.tool()
|
|
321
|
+
def mark_outdated(memory_id: str, reason: str) -> str:
|
|
322
|
+
"""Mark a memory as no longer true, with a short reason. It stops showing up in recall
|
|
323
|
+
but keeps its history, so you can still see what it said and why it was retired."""
|
|
324
|
+
try:
|
|
325
|
+
reply = run("MEM.STATUS", memory_id, "stale", "REASON", reason)
|
|
326
|
+
except EngineDown as e:
|
|
327
|
+
return store_down(e)
|
|
328
|
+
except redis.ResponseError as e:
|
|
329
|
+
return f"Couldn't update {memory_id}: {e}"
|
|
330
|
+
return f"Marked {memory_id} as outdated." if reply == "OK" else str(reply)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@mcp.tool()
|
|
334
|
+
def replace_memory(old_id: str, new_id: str) -> str:
|
|
335
|
+
"""Retire an older memory because a newer one replaces it (e.g. "we use Postgres" replaced by
|
|
336
|
+
"we migrated to MySQL"). The old memory stops showing up in recall but keeps its history."""
|
|
337
|
+
try:
|
|
338
|
+
new = run("MEM.GET", new_id)
|
|
339
|
+
if new is None:
|
|
340
|
+
return f"No memory with id {new_id}."
|
|
341
|
+
new_text = dict(zip(new[0::2], new[1::2]))["text"]
|
|
342
|
+
run("MEM.STATUS", old_id, "superseded", "REASON", f"replaced by {new_id}: \"{new_text}\"")
|
|
343
|
+
except EngineDown as e:
|
|
344
|
+
return store_down(e)
|
|
345
|
+
except redis.ResponseError as e:
|
|
346
|
+
return f"Couldn't replace: {e}"
|
|
347
|
+
return f"{old_id} retired; {new_id} replaces it."
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@mcp.tool()
|
|
351
|
+
def check_stale() -> str:
|
|
352
|
+
"""Check whether any memory's linked files have changed since it was saved, and retire those
|
|
353
|
+
memories. recall already does this automatically; call it directly after big changes."""
|
|
354
|
+
try:
|
|
355
|
+
stale = check_staleness()
|
|
356
|
+
except EngineDown as e:
|
|
357
|
+
return store_down(e)
|
|
358
|
+
return "\n".join(stale) if stale else "All memories linked to files are still up to date."
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@mcp.tool()
|
|
362
|
+
def confirm_memory(memory_id: str) -> str:
|
|
363
|
+
"""Mark a memory as still true (e.g. after it was retired because a linked file changed, but the
|
|
364
|
+
fact itself didn't). Re-links it to the current version of its files."""
|
|
365
|
+
try:
|
|
366
|
+
r = run("MEM.GET", memory_id)
|
|
367
|
+
if r is None:
|
|
368
|
+
return f"No memory with id {memory_id}."
|
|
369
|
+
files = [f.rsplit("@", 1)[0] for f in dict(zip(r[0::2], r[1::2]))["files"].split(",") if f]
|
|
370
|
+
if files:
|
|
371
|
+
run("MEM.FILES", memory_id, link_files(files), "REASON", "confirmed still true")
|
|
372
|
+
run("MEM.STATUS", memory_id, "active", "REASON", "confirmed still true")
|
|
373
|
+
except EngineDown as e:
|
|
374
|
+
return store_down(e)
|
|
375
|
+
return f"Confirmed {memory_id}; it's active again" + (" and linked to the current file versions." if files else ".")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------- syncing to AGENTS.md / CLAUDE.md ----------
|
|
379
|
+
|
|
380
|
+
SYNC_START = "<!-- aether-cache:start -->"
|
|
381
|
+
SYNC_END = "<!-- aether-cache:end -->"
|
|
382
|
+
OLD_MARKERS = ("<!-- memstore:start -->", "<!-- memstore:end -->") # from when the project was called memstore
|
|
383
|
+
SYNC_FILES = ["AGENTS.md", "CLAUDE.md"]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def memory_block() -> str:
|
|
387
|
+
rows = run("MEM.LIST", project_name())
|
|
388
|
+
lines = [SYNC_START,
|
|
389
|
+
"## Project memory (managed by Aether Cache)",
|
|
390
|
+
"<!-- Generated by Aether Cache; edits inside this section are overwritten on the next sync. -->",
|
|
391
|
+
""]
|
|
392
|
+
lines += [f"- {text} ({mid})" for mid, _status, _files, text in rows] or ["_No memories yet._"]
|
|
393
|
+
lines.append(SYNC_END)
|
|
394
|
+
return "\n".join(lines)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def sync_files() -> str:
|
|
398
|
+
"""Writes the project's active memories into the managed section of AGENTS.md / CLAUDE.md.
|
|
399
|
+
Only the section between the markers is touched; files that don't exist aren't created, unless
|
|
400
|
+
neither exists, in which case AGENTS.md is."""
|
|
401
|
+
root = repo_root()
|
|
402
|
+
block = memory_block()
|
|
403
|
+
targets = [root / name for name in SYNC_FILES if (root / name).exists()] or [root / "AGENTS.md"]
|
|
404
|
+
report = []
|
|
405
|
+
for path in targets:
|
|
406
|
+
old = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
407
|
+
new = None
|
|
408
|
+
for start, end in ((SYNC_START, SYNC_END), OLD_MARKERS):
|
|
409
|
+
if start in old and end in old:
|
|
410
|
+
new = old[:old.index(start)] + block + old[old.index(end) + len(end):]
|
|
411
|
+
break
|
|
412
|
+
if new is None:
|
|
413
|
+
new = (old.rstrip() + "\n\n" if old.strip() else "") + block + "\n"
|
|
414
|
+
if new == old:
|
|
415
|
+
report.append(f"{path.name}: already up to date")
|
|
416
|
+
continue
|
|
417
|
+
tmp = path.with_name(path.name + ".aether-tmp")
|
|
418
|
+
tmp.write_text(new, encoding="utf-8")
|
|
419
|
+
os.replace(tmp, path) # swap in the new version all at once, so a crash can't leave half a file
|
|
420
|
+
report.append(f"{path.name}: {'updated' if old else 'created'}")
|
|
421
|
+
return "\n".join(report)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@mcp.tool()
|
|
425
|
+
def sync_to_files() -> str:
|
|
426
|
+
"""Write this project's current memories into the managed section of AGENTS.md / CLAUDE.md, so they're
|
|
427
|
+
committed with the code: teammates and tools without Aether Cache see them, and changes show up in reviews.
|
|
428
|
+
Call it when the user asks, or after a batch of new memories."""
|
|
429
|
+
try:
|
|
430
|
+
check_staleness() # never write out a memory whose files have changed
|
|
431
|
+
return sync_files()
|
|
432
|
+
except EngineDown as e:
|
|
433
|
+
return store_down(e)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@mcp.tool()
|
|
437
|
+
def list_conflicts() -> str:
|
|
438
|
+
"""List pairs of memories in this project that may contradict each other and need a decision."""
|
|
439
|
+
try:
|
|
440
|
+
rows = run("MEM.CONFLICTS", project_name())
|
|
441
|
+
except EngineDown as e:
|
|
442
|
+
return store_down(e)
|
|
443
|
+
if not rows:
|
|
444
|
+
return "No conflicts waiting for review."
|
|
445
|
+
return "\n".join(f"{new_id} \"{new_text}\" may contradict {old_id} \"{old_text}\" (similarity {sim})"
|
|
446
|
+
for new_id, new_text, old_id, old_text, sim in rows)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@mcp.tool()
|
|
450
|
+
def resolve_conflict(new_id: str, old_id: str, replaces_old: bool) -> str:
|
|
451
|
+
"""Settle a possible conflict. replaces_old=True: the new memory replaces the old one (the old one
|
|
452
|
+
is retired). replaces_old=False: both are true, keep both. Ask the user if you're not sure."""
|
|
453
|
+
try:
|
|
454
|
+
run("MEM.RESOLVE", new_id, old_id, "SUPERSEDE" if replaces_old else "KEEP")
|
|
455
|
+
except EngineDown as e:
|
|
456
|
+
return store_down(e)
|
|
457
|
+
except redis.ResponseError as e:
|
|
458
|
+
return f"Couldn't resolve: {e}"
|
|
459
|
+
return f"{old_id} retired; {new_id} replaces it." if replaces_old else f"Kept both {new_id} and {old_id}."
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def serve() -> None:
|
|
463
|
+
warm_up()
|
|
464
|
+
mcp.run() # talks to the AI tool over stdin/stdout
|