agent-memory-git 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.
@@ -0,0 +1,371 @@
1
+ """The local server behind `mem ui`: the memory list, search, and edits.
2
+
3
+ Listens on 127.0.0.1 only. The page gets a per-run token in its URL fragment
4
+ and sends it on every API call; Host and Origin are checked too, so other
5
+ pages in the browser cannot read or change memories.
6
+
7
+ Every edit, addition or deletion is written to the data repository and
8
+ committed on its own (only the file it touched), then pushed in the
9
+ background like hook commits; Undo reverts that commit.
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import secrets
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path
20
+ from urllib.parse import parse_qs, urlparse
21
+
22
+ import numpy as np
23
+
24
+ from . import frontmatter, gitsync, store, ui
25
+ from .context import detect, hostname
26
+ from .util import SCOPES, TYPES, atomic_write, id_path, log_exc, new_id, now_iso, slugify
27
+
28
+ STATIC = Path(__file__).with_name("webui")
29
+ MIME = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
30
+ ".js": "text/javascript; charset=utf-8"}
31
+ CSP = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
32
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
33
+ PLACE_FIELDS = ("cwd", "git_root", "git_remote", "git_branch")
34
+ SEARCH_HITS = 8
35
+
36
+
37
+ class ApiError(Exception):
38
+ def __init__(self, status, message):
39
+ super().__init__(message)
40
+ self.status, self.message = status, message
41
+
42
+
43
+ class UiApp:
44
+ def __init__(self, data_dir, semantic=True):
45
+ self.data = Path(data_dir)
46
+ self.mdir = store.memories_dir(data_dir)
47
+ self.semantic = semantic
48
+ self.lock = threading.Lock()
49
+ self.encoder = ui.QueryEncoder() if semantic else None
50
+ self.state, self.vectors, self.stamp_value = None, None, None
51
+ self.refresh()
52
+ if self.encoder and self.state.get("semantic"): # load the model now, not on the first search
53
+ threading.Thread(target=self.encoder.encode, args=("warm up",), daemon=True).start()
54
+
55
+ # ------------------------------------------------------------ reads
56
+
57
+ def stamp(self):
58
+ head = gitsync.git(self.data, "rev-parse", "--short", "HEAD", timeout=5).stdout.strip()
59
+ newest, count = 0, 0
60
+ for path in self.mdir.rglob("*.md"):
61
+ count += 1
62
+ newest = max(newest, path.stat().st_mtime_ns)
63
+ return f"{head}:{count}:{newest}"
64
+
65
+ def refresh(self):
66
+ with self.lock:
67
+ out = []
68
+ state = ui.build(self.data, semantic=self.semantic, vectors_out=out)
69
+ self.state, self.vectors = state, (out[0] if out else None)
70
+ self.stamp_value = self.stamp()
71
+ self.state["stamp"] = self.stamp_value
72
+ self.state["sync"] = self.sync_status()
73
+
74
+ def current(self):
75
+ if self.stamp() != self.stamp_value: # an agent or another editor changed the store
76
+ self.refresh()
77
+ return self.state
78
+
79
+ def sync_status(self):
80
+ if not gitsync.has_remote(self.data):
81
+ return {"remote": False}
82
+ r = gitsync.git(self.data, "rev-list", "--count", "@{u}..HEAD", timeout=5)
83
+ ahead = int(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip().isdigit() else 0
84
+ return {"remote": True, "ahead": ahead}
85
+
86
+ def search(self, query):
87
+ """Memories closest in meaning to the query (the page does word matching itself)."""
88
+ query = (query or "").strip()
89
+ if not query or not self.semantic or self.vectors is None or not self.state["semantic"]:
90
+ return {"hits": [], "semantic": False}
91
+ vec = self.encoder.encode(query)
92
+ if vec is None:
93
+ return {"hits": [], "semantic": False}
94
+ scores = self.vectors @ vec
95
+ order = np.argsort(-scores)[:SEARCH_HITS]
96
+ ids = [self.state["memories"][int(i)]["id"] for i in order]
97
+ return {"hits": [{"id": mid, "s": round(float(scores[i]), 3)} for mid, i in zip(ids, order)],
98
+ "semantic": True}
99
+
100
+ # ------------------------------------------------------------ writes
101
+
102
+ def _find(self, mid):
103
+ m = store.find(self.data, str(mid or ""))
104
+ if not m or m["id"] != mid:
105
+ raise ApiError(404, "That memory no longer exists; it may have been deleted elsewhere.")
106
+ return m
107
+
108
+ def _load(self, mid, base):
109
+ path = self.mdir / self._find(mid)["rel"]
110
+ text = path.read_text("utf-8")
111
+ if base and hashlib.sha1(text.encode()).hexdigest()[:16] != base:
112
+ raise ApiError(409, "This memory changed on disk after you opened it (an agent may have "
113
+ "edited it). Close it and open it again to see the new version.")
114
+ meta, body = frontmatter.split(text)
115
+ return path, meta, body
116
+
117
+ def _place_ctx(self, place):
118
+ if isinstance(place, dict) and place.get("path"):
119
+ path = Path(str(place["path"])).expanduser()
120
+ if not path.is_dir():
121
+ raise ApiError(400, f"{place['path']} is not a folder on this machine.")
122
+ return detect(str(path))
123
+ key = place.get("key") if isinstance(place, dict) else place
124
+ for p in self.state.get("places", []):
125
+ if p["key"] == key:
126
+ return dict(p["ctx"])
127
+ raise ApiError(400, "Pick a place from the list or type a folder path.")
128
+
129
+ def _commit(self, paths, message):
130
+ rels = [str(Path(p).relative_to(self.data)) for p in ([paths] if isinstance(paths, (str, Path)) else paths)]
131
+ gitsync.git(self.data, "add", "-A", "--", *rels)
132
+ r = gitsync.git(self.data, "-c", "commit.gpgsign=false", "commit", "-q", "--no-verify",
133
+ "-m", message + "\n\nTool: ui", "--", *rels)
134
+ if r.returncode != 0:
135
+ if "nothing" in r.stdout + r.stderr:
136
+ return None
137
+ raise ApiError(500, f"git commit failed: {(r.stderr or r.stdout).strip()}")
138
+ head = gitsync.git(self.data, "rev-parse", "--short", "HEAD").stdout.strip()
139
+ if gitsync.has_remote(self.data):
140
+ gitsync.spawn_sync(self.data, push=True)
141
+ return head
142
+
143
+ def _write(self, fn):
144
+ lock = gitsync.Lock(self.data, timeout=10)
145
+ if not lock.__enter__():
146
+ raise ApiError(503, "The memory repository is busy (a hook is committing). Try again.")
147
+ try:
148
+ result = fn()
149
+ finally:
150
+ lock.__exit__()
151
+ self.refresh()
152
+ return result
153
+
154
+ def _apply(self, meta, body, change):
155
+ changed = []
156
+ for key, allowed in (("type", TYPES), ("scope", SCOPES)):
157
+ if key in change and change[key] != meta.get(key):
158
+ if change[key] not in allowed:
159
+ raise ApiError(400, f"Unknown {key} {change[key]!r}.")
160
+ meta[key] = change[key]
161
+ changed.append(key)
162
+ if "description" in change:
163
+ desc = " ".join(str(change["description"]).split())
164
+ if not desc:
165
+ raise ApiError(400, "A memory needs a one-line description.")
166
+ if desc != meta.get("description"):
167
+ meta["description"] = desc
168
+ changed.append("description")
169
+ if "body" in change and str(change["body"]).rstrip() != body.rstrip():
170
+ body = str(change["body"])
171
+ changed.append("body")
172
+ source = dict(meta.get("source") or {})
173
+ if change.get("place") and meta.get("scope") == "here":
174
+ ctx = self._place_ctx(change["place"])
175
+ old = source.get("cwd")
176
+ for key in PLACE_FIELDS:
177
+ source.pop(key, None)
178
+ source.update({k: ctx[k] for k in PLACE_FIELDS if ctx.get(k)})
179
+ if old and old != source.get("cwd"):
180
+ source.setdefault("original_cwd", old)
181
+ changed.append("place")
182
+ if meta.get("scope") == "here" and not (source.get("cwd") or source.get("git_remote")):
183
+ raise ApiError(400, "Pick the place where this memory should load.")
184
+ if changed:
185
+ meta["updated"] = now_iso()
186
+ meta.pop("source", None)
187
+ if source:
188
+ meta["source"] = source # provenance stays last, as the hooks write it
189
+ return body, changed
190
+
191
+ def create(self, p):
192
+ typ, scope = p.get("type"), p.get("scope")
193
+ if typ not in TYPES or scope not in SCOPES:
194
+ raise ApiError(400, "Pick a type and where the memory loads.")
195
+ desc = " ".join(str(p.get("description", "")).split())
196
+ if not desc:
197
+ raise ApiError(400, "A memory needs a one-line description.")
198
+ source = {"tool": "ui", "host": hostname()}
199
+ if scope == "here":
200
+ if not p.get("place"):
201
+ raise ApiError(400, "Pick the place where this memory should load.")
202
+ ctx = self._place_ctx(p["place"])
203
+ source.update({k: ctx[k] for k in PLACE_FIELDS if ctx.get(k)})
204
+ name = slugify(p.get("name") or desc)
205
+ mid = new_id(name)
206
+ meta = {"id": mid, "name": name, "type": typ, "scope": scope, "description": desc,
207
+ "created": now_iso(), "source": source}
208
+ path = self.mdir / id_path(mid)
209
+
210
+ def write():
211
+ atomic_write(path, frontmatter.join(meta, str(p.get("body", ""))))
212
+ return {"id": mid, "commit": self._commit(path, f"memory: add {mid} (ui)")}
213
+ return self._write(write)
214
+
215
+ def edit(self, p):
216
+ def write():
217
+ path, meta, body = self._load(p.get("id"), p.get("base"))
218
+ new_body, changed = self._apply(meta, body, p.get("change") or {})
219
+ if not changed:
220
+ return {"id": p.get("id"), "commit": None}
221
+ atomic_write(path, frontmatter.join(meta, new_body))
222
+ return {"id": p.get("id"),
223
+ "commit": self._commit(path, f"memory: edit {p.get('id')} ({', '.join(changed)}) (ui)")}
224
+ return self._write(write)
225
+
226
+ def delete(self, p):
227
+ """Delete one memory ({id, base}) or several ({items: [{id, base}, ...]}) in one commit."""
228
+ items = p.get("items") if isinstance(p.get("items"), list) else [p]
229
+ if not items or len(items) > 200:
230
+ raise ApiError(400, "Nothing to delete.")
231
+
232
+ def write():
233
+ loaded = [self._load(it.get("id"), it.get("base")) for it in items] # all or nothing
234
+ for path, _, _ in loaded:
235
+ path.unlink()
236
+ if len(loaded) == 1:
237
+ message = f"memory: delete {items[0].get('id')} (ui)\n\n{loaded[0][1].get('description', '')}"
238
+ else:
239
+ message = f"memory: delete {len(loaded)} memories (ui)\n\n" + "\n".join(
240
+ f"- {it.get('id')}: {meta.get('description', '')}" for it, (_, meta, _) in zip(items, loaded))
241
+ return {"commit": self._commit([path for path, _, _ in loaded], message)}
242
+ return self._write(write)
243
+
244
+ def undo(self, p):
245
+ rev = str(p.get("commit") or "")
246
+ if not rev or len(rev) > 40 or any(c not in "0123456789abcdef" for c in rev):
247
+ raise ApiError(400, "Bad commit id.")
248
+ shown = gitsync.git(self.data, "show", "-s", "--format=%B", rev, timeout=10)
249
+ if shown.returncode != 0 or "Tool: ui" not in shown.stdout:
250
+ raise ApiError(400, "Only changes made on this page can be undone here.")
251
+
252
+ def write():
253
+ r = gitsync.git(self.data, "revert", "--no-commit", rev, timeout=20)
254
+ if r.returncode != 0:
255
+ gitsync.git(self.data, "revert", "--abort")
256
+ raise ApiError(409, "Could not undo: that memory changed again since. Edit it instead.")
257
+ gitsync.git(self.data, "-c", "commit.gpgsign=false", "commit", "-q", "--no-verify",
258
+ "-m", f"memory: undo {rev} (ui)\n\nTool: ui", timeout=10)
259
+ if gitsync.has_remote(self.data):
260
+ gitsync.spawn_sync(self.data, push=True)
261
+ return {"ok": True}
262
+ return self._write(write)
263
+
264
+
265
+ class Handler(BaseHTTPRequestHandler):
266
+ server_version = "agent-memory"
267
+ POSTS = {"/api/create": "create", "/api/edit": "edit", "/api/delete": "delete", "/api/undo": "undo"}
268
+
269
+ def log_message(self, *args):
270
+ pass
271
+
272
+ def _send(self, status, payload, ctype="application/json; charset=utf-8"):
273
+ body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode()
274
+ self.send_response(status)
275
+ for k, v in (("Content-Type", ctype), ("Content-Length", str(len(body))), ("Cache-Control", "no-store"),
276
+ ("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "no-referrer")):
277
+ self.send_header(k, v)
278
+ if ctype.startswith("text/html"):
279
+ self.send_header("Content-Security-Policy", CSP)
280
+ self.end_headers()
281
+ self.wfile.write(body)
282
+
283
+ def _guard(self, api):
284
+ if self.headers.get("Host", "") not in self.server.hosts:
285
+ self._send(421, {"error": "wrong host"})
286
+ return False
287
+ origin = self.headers.get("Origin")
288
+ if origin and origin not in {f"http://{h}" for h in self.server.hosts}:
289
+ self._send(403, {"error": "cross-origin request refused"})
290
+ return False
291
+ if api and not secrets.compare_digest(self.headers.get("X-Mem-Token", ""), self.server.token):
292
+ self._send(401, {"error": "This link has expired. Run `mem ui` again and use the new link."})
293
+ return False
294
+ return True
295
+
296
+ def _run(self, fn):
297
+ try:
298
+ self._send(200, fn())
299
+ except ApiError as e:
300
+ self._send(e.status, {"error": e.message})
301
+ except Exception as e:
302
+ log_exc(self.server.app.data, f"ui {self.path}")
303
+ self._send(500, {"error": f"{type(e).__name__}: {e}"})
304
+
305
+ def do_GET(self):
306
+ url = urlparse(self.path)
307
+ api = url.path.startswith("/api/")
308
+ if not self._guard(api):
309
+ return
310
+ if not api:
311
+ name = "index.html" if url.path in ("/", "/index.html") else url.path.lstrip("/")
312
+ path = STATIC / name
313
+ if "/" in name or not path.is_file() or path.suffix not in MIME:
314
+ return self._send(404, {"error": "not found"})
315
+ return self._send(200, path.read_bytes(), MIME[path.suffix])
316
+ app, q = self.server.app, {k: v[0] for k, v in parse_qs(url.query).items()}
317
+ routes = {"/api/state": app.current, "/api/stamp": lambda: {"stamp": app.stamp()},
318
+ "/api/search": lambda: app.search(q.get("q"))}
319
+ if url.path not in routes:
320
+ return self._send(404, {"error": "not found"})
321
+ self._run(routes[url.path])
322
+
323
+ def do_POST(self):
324
+ url = urlparse(self.path)
325
+ if not self._guard(True):
326
+ return
327
+ method = self.POSTS.get(url.path)
328
+ if not method:
329
+ return self._send(404, {"error": "not found"})
330
+ try:
331
+ length = int(self.headers.get("Content-Length") or 0)
332
+ payload = json.loads(self.rfile.read(length) or b"{}") if length else {}
333
+ except ValueError:
334
+ return self._send(400, {"error": "bad JSON"})
335
+ self._run(lambda: getattr(self.server.app, method)(payload))
336
+
337
+
338
+ def make_server(data_dir, port=8790, semantic=True):
339
+ app = UiApp(data_dir, semantic=semantic)
340
+ try:
341
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
342
+ except OSError:
343
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
344
+ port = server.server_address[1]
345
+ server.hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
346
+ server.token = secrets.token_urlsafe(18)
347
+ server.app = app
348
+ server.url = f"http://127.0.0.1:{port}/#t={server.token}"
349
+ return server
350
+
351
+
352
+ def run(data_dir, port=8790, open_browser=True, semantic=True):
353
+ if semantic and not ui.model_ready():
354
+ print("No local embedding model; similar memories use keywords only. "
355
+ "Run `mem ui --fetch-model` (about 625 MB) to also search by meaning.", flush=True)
356
+ server = make_server(data_dir, port, semantic)
357
+ print(f"Memories: {server.url}", flush=True)
358
+ print("Keep this terminal open while you use it; Ctrl+C stops it.", flush=True)
359
+ if open_browser:
360
+ try:
361
+ subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", server.url], check=False)
362
+ except OSError:
363
+ pass
364
+ try:
365
+ server.serve_forever()
366
+ except KeyboardInterrupt:
367
+ print("\nstopped")
368
+ finally:
369
+ server.server_close()
370
+ if server.app.encoder:
371
+ server.app.encoder.close()
agent_memory/util.py ADDED
@@ -0,0 +1,101 @@
1
+ import datetime as _dt
2
+ import os
3
+ import re
4
+ import secrets
5
+ import tempfile
6
+ import traceback
7
+ from pathlib import Path
8
+
9
+ HOME = Path.home()
10
+
11
+ TYPES = ("user", "feedback", "project", "reference")
12
+ SCOPES = ("global", "here")
13
+
14
+ _B32 = "0123456789abcdefghjkmnpqrstvwxyz"
15
+
16
+
17
+ def now():
18
+ return _dt.datetime.now().astimezone().replace(microsecond=0)
19
+
20
+
21
+ def now_iso():
22
+ return now().isoformat()
23
+
24
+
25
+ def parse_time(value):
26
+ """Best-effort ISO 8601 parse; returns an aware datetime or None."""
27
+ if not value:
28
+ return None
29
+ s = str(value).strip().replace("Z", "+00:00")
30
+ try:
31
+ t = _dt.datetime.fromisoformat(s)
32
+ except ValueError:
33
+ return None
34
+ return t if t.tzinfo else t.astimezone()
35
+
36
+
37
+ def slugify(text, limit=40):
38
+ s = re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-")
39
+ s = s[:limit].rstrip("-")
40
+ return s or "note"
41
+
42
+
43
+ def new_id(slug, when=None):
44
+ when = when or now()
45
+ suffix = "".join(_B32[b % 32] for b in secrets.token_bytes(4))
46
+ return f"{when:%Y%m%d}-{slugify(slug)}-{suffix}"
47
+
48
+
49
+ def id_path(memory_id):
50
+ """Relative path of a memory inside the memories directory."""
51
+ return f"{memory_id[:4]}/{memory_id[4:6]}/{memory_id}.md"
52
+
53
+
54
+ def is_under(child, parent):
55
+ try:
56
+ return os.path.commonpath([child, parent]) == parent and child != parent
57
+ except ValueError:
58
+ return False
59
+
60
+
61
+ def specific(path):
62
+ """True if a folder is narrow enough to share memories with its relatives.
63
+
64
+ Home, the filesystem root and direct children of home (like ~/repos) are
65
+ too broad: matching on them would leak memories across unrelated work.
66
+ """
67
+ if not path:
68
+ return False
69
+ p = Path(path)
70
+ home = HOME
71
+ if p == home or p == Path(p.anchor):
72
+ return False
73
+ try:
74
+ return len(p.relative_to(home).parts) >= 2
75
+ except ValueError:
76
+ return len(p.parts) >= 4
77
+
78
+
79
+ def atomic_write(path, text):
80
+ path = Path(path)
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
83
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
84
+ f.write(text)
85
+ os.replace(tmp, path)
86
+
87
+
88
+ def log(data_dir, message):
89
+ try:
90
+ path = Path(data_dir) / ".cache" / "agent-memory.log"
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ if path.exists() and path.stat().st_size > 1_000_000:
93
+ path.replace(path.with_suffix(".log.1"))
94
+ with path.open("a", encoding="utf-8") as f:
95
+ f.write(f"{now_iso()} {message}\n")
96
+ except OSError:
97
+ pass
98
+
99
+
100
+ def log_exc(data_dir, where):
101
+ log(data_dir, f"ERROR in {where}:\n{traceback.format_exc()}")
@@ -0,0 +1,192 @@
1
+ /* Memories: a search box, a row of filters, the list, and one memory at a time in the drawer. */
2
+ :root {
3
+ color-scheme: light;
4
+ --page: #f7f7f5;
5
+ --surface: #ffffff;
6
+ --ink: #171716;
7
+ --ink-2: #57564f;
8
+ --ink-3: #8d8c85;
9
+ --line: #e6e5df;
10
+ --d1: #efeee9;
11
+ --accent: #2a78d6;
12
+ --accent-soft: rgba(42, 120, 214, 0.12);
13
+ --danger: #c93838;
14
+ --shadow: 0 12px 40px rgba(20, 20, 18, 0.14);
15
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
16
+ --t-user: #8a62cf; --t-feedback: #2a78d6; --t-project: #3c9a6b; --t-reference: #df8530;
17
+ --drawer: min(600px, 100vw);
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ color-scheme: dark;
22
+ --page: #121211;
23
+ --surface: #1b1b1a;
24
+ --ink: #f2f2ef;
25
+ --ink-2: #bcbbb3;
26
+ --ink-3: #85847d;
27
+ --line: #2b2b29;
28
+ --d1: #242422;
29
+ --accent: #4b93ea;
30
+ --accent-soft: rgba(75, 147, 234, 0.2);
31
+ --danger: #e66767;
32
+ --shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
33
+ --t-user: #a98ae6; --t-feedback: #6aa1ec; --t-project: #5fbf8e; --t-reference: #f0a45c;
34
+ }
35
+ }
36
+
37
+ * { box-sizing: border-box; }
38
+ [hidden] { display: none !important; }
39
+ html, body { margin: 0; }
40
+ body { background: var(--page); color: var(--ink); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif; }
41
+ button, input, select, textarea { font: inherit; color: inherit; }
42
+ button { cursor: pointer; }
43
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
44
+
45
+ .btn { border: 1px solid var(--line); background: var(--surface); border-radius: 10px; padding: 7px 14px; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
46
+ .btn:hover { background: var(--d1); }
47
+ .btn.primary { background: var(--ink); color: var(--surface); border-color: var(--ink); font-weight: 600; }
48
+ .btn.primary:hover { opacity: .9; background: var(--ink); }
49
+ .btn.danger { color: var(--danger); }
50
+ .btn:disabled { opacity: .45; cursor: default; }
51
+ .icon-btn { border: 0; background: none; width: 32px; height: 32px; border-radius: 8px; display: inline-grid; place-items: center; color: var(--ink-2); }
52
+ .icon-btn:hover { background: var(--d1); color: var(--ink); }
53
+ .icon-btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
54
+
55
+ /* ---------- top: search, then filters */
56
+ .top { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--page) 92%, transparent); backdrop-filter: blur(6px); border-bottom: 1px solid var(--line); }
57
+ .bar, .filters, .list { max-width: 1080px; margin: 0 auto; padding-left: 24px; padding-right: 24px; }
58
+ .bar { display: flex; align-items: center; gap: 14px; padding-top: 14px; }
59
+ .search { position: relative; flex: 1; }
60
+ .search input { width: 100%; height: 42px; padding: 0 40px 0 40px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface); outline: none; font-size: 15px; }
61
+ .search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
62
+ .search input::-webkit-search-cancel-button { display: none; }
63
+ .search-ico { position: absolute; left: 14px; top: 13px; width: 16px; height: 16px; fill: none; stroke: var(--ink-3); stroke-width: 1.6; stroke-linecap: round; }
64
+ .search kbd { position: absolute; right: 12px; top: 11px; font: 11px var(--mono); color: var(--ink-3); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }
65
+ .sync { font-size: 12px; color: var(--ink-3); white-space: nowrap; }
66
+
67
+ .filters { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 10px; padding-bottom: 10px; }
68
+ .pill { position: relative; display: inline-flex; align-items: center; }
69
+ .pill select { appearance: none; -webkit-appearance: none; border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 4px 26px 4px 11px; font-size: 13px; color: var(--ink-2); max-width: 230px; text-overflow: ellipsis; cursor: pointer; }
70
+ .pill select:hover { border-color: var(--ink-3); }
71
+ .pill::after { content: ""; position: absolute; right: 11px; top: 50%; width: 5px; height: 5px; border-right: 1.5px solid var(--ink-3); border-bottom: 1.5px solid var(--ink-3); transform: translateY(-70%) rotate(45deg); pointer-events: none; }
72
+ .pill.on select { border-color: var(--ink); color: var(--ink); font-weight: 600; }
73
+ .filters .clear { border: 0; background: none; color: var(--accent); font-size: 13px; padding: 4px 6px; }
74
+ .filters .spacer { flex: 1; }
75
+ .filters .count { font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
76
+
77
+ /* ---------- the list */
78
+ .list { padding-top: 6px; padding-bottom: 120px; }
79
+ .group { font-size: 12px; font-weight: 600; color: var(--ink-3); padding: 18px 10px 6px; letter-spacing: .02em; }
80
+ .row { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 12px; align-items: start; padding: 10px 10px; border-radius: 10px; cursor: pointer; }
81
+ .row + .row { border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent); }
82
+ .row:hover { background: var(--surface); }
83
+ .row.cursor { box-shadow: inset 0 0 0 1.5px var(--ink-3); }
84
+ .row.open { background: var(--accent-soft); }
85
+ .row.picked { background: var(--d1); }
86
+ .row .chk { margin: 3px 0 0; opacity: 0; cursor: pointer; }
87
+ .row:hover .chk, .row.picked .chk, .selecting .row .chk, .row.cursor .chk { opacity: 1; }
88
+ .row .main { min-width: 0; }
89
+ .row .desc { overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
90
+ .row .snip { font-size: 12.5px; color: var(--ink-3); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
91
+ .row mark { background: color-mix(in srgb, var(--accent) 22%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
92
+ .kws { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
93
+ .kw { border: 0; background: var(--d1); color: var(--ink-2); font-size: 11.5px; line-height: 18px; border-radius: 6px; padding: 0 7px; }
94
+ .kw:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--ink); }
95
+ .kw.on { background: var(--ink); color: var(--surface); }
96
+ .d-head .kws { margin-top: 10px; }
97
+ .d-head .kws + .chips { margin-top: 8px; }
98
+ .issues { margin: 10px 0 0; padding: 7px 12px 7px 26px; border-radius: 8px; background: color-mix(in srgb, #d9822b 10%, transparent); color: var(--ink-2); font-size: 12.5px; }
99
+ .issues li { margin: 1px 0; }
100
+ .row .meta { display: flex; gap: 14px; font-size: 12px; color: var(--ink-3); white-space: nowrap; padding-top: 2px; }
101
+ .row .meta .t { display: inline-flex; align-items: center; gap: 6px; width: 78px; }
102
+ .row .meta .t i { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); }
103
+ .row .meta .t.user i { background: var(--t-user); } .row .meta .t.feedback i { background: var(--t-feedback); }
104
+ .row .meta .t.project i { background: var(--t-project); } .row .meta .t.reference i { background: var(--t-reference); }
105
+ .row .meta .p { width: 200px; overflow: hidden; text-overflow: ellipsis; }
106
+ .row .meta .d { width: 84px; text-align: right; font-variant-numeric: tabular-nums; }
107
+ .empty { text-align: center; color: var(--ink-3); padding: 70px 0; }
108
+ .empty .btn { margin-top: 12px; }
109
+
110
+ .selbar { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); display: flex; gap: 8px; align-items: center; background: var(--ink); color: var(--surface); padding: 8px 8px 8px 16px; border-radius: 12px; box-shadow: var(--shadow); z-index: 20; }
111
+ .selbar .n { font-weight: 600; margin-right: 6px; }
112
+ .selbar .btn { background: transparent; color: var(--surface); border-color: color-mix(in srgb, var(--surface) 30%, transparent); }
113
+ .selbar .btn:hover { background: color-mix(in srgb, var(--surface) 14%, transparent); }
114
+ .selbar .btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
115
+
116
+ /* with the drawer open on a wide window, the list moves over to stay readable */
117
+ @media (min-width: 1300px) {
118
+ body.drawer-open .bar, body.drawer-open .filters, body.drawer-open .list { margin-left: max(0px, calc((100vw - var(--drawer) - 1080px) / 2)); margin-right: var(--drawer); }
119
+ body.drawer-open .selbar { left: calc((100vw - var(--drawer)) / 2); }
120
+ }
121
+
122
+ /* ---------- drawer: one memory, everything in its file */
123
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(600px, 100vw); background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow); transform: translateX(104%); transition: transform .22s ease; z-index: 30; display: flex; flex-direction: column; }
124
+ .drawer.open { transform: none; }
125
+ .d-head { padding: 18px 20px 14px; border-bottom: 1px solid var(--line); }
126
+ .d-bar { display: flex; align-items: center; gap: 4px; margin: -6px -8px 8px 0; }
127
+ .d-bar .where { flex: 1; font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
128
+ .d-head h1 { font-size: 18px; line-height: 1.4; margin: 0; font-weight: 650; overflow-wrap: anywhere; }
129
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
130
+ .chip { font-size: 12px; color: var(--ink-2); background: var(--d1); border-radius: 6px; padding: 2px 8px; }
131
+ .tabs { display: flex; gap: 2px; margin-top: 14px; }
132
+ .tabs button { border: 0; background: none; padding: 5px 10px; border-radius: 7px; font-size: 13px; color: var(--ink-3); }
133
+ .tabs button[aria-selected="true"] { background: var(--d1); color: var(--ink); font-weight: 600; }
134
+ .d-body { flex: 1; overflow-y: auto; padding: 18px 20px 40px; }
135
+ .props { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 16px; font-size: 12.5px; margin: 0 0 20px; padding: 12px 14px; background: var(--page); border-radius: 10px; }
136
+ .props dt { color: var(--ink-3); white-space: nowrap; }
137
+ .props dt.sub { padding-left: 12px; }
138
+ .props dd { margin: 0; color: var(--ink-2); overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; }
139
+ .props .group { grid-column: 1 / -1; color: var(--ink-3); font-size: 11px; margin-top: 4px; }
140
+ pre.raw { white-space: pre-wrap; overflow-wrap: anywhere; font: 12.5px/1.6 var(--mono); background: var(--page); border-radius: 10px; padding: 14px; margin: 0; }
141
+ .similar { margin-top: 28px; }
142
+ .similar h2 { font-size: 12px; color: var(--ink-3); font-weight: 600; margin: 0 0 6px; letter-spacing: .02em; }
143
+ .similar button { display: flex; gap: 10px; width: 100%; text-align: left; border: 0; background: none; padding: 7px 8px; border-radius: 8px; align-items: baseline; }
144
+ .similar button:hover { background: var(--d1); }
145
+ .similar .t { flex: 1; overflow-wrap: anywhere; }
146
+ .similar .p { font-size: 12px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
147
+
148
+ /* rendered markdown */
149
+ .md { font-size: 14.5px; line-height: 1.65; overflow-wrap: anywhere; }
150
+ .md > :first-child { margin-top: 0; }
151
+ .md h1, .md h2, .md h3, .md h4 { line-height: 1.35; margin: 1.3em 0 .5em; }
152
+ .md h1 { font-size: 17px; } .md h2 { font-size: 15.5px; } .md h3, .md h4 { font-size: 14.5px; }
153
+ .md p { margin: .6em 0; }
154
+ .md ul, .md ol { padding-left: 1.4em; margin: .5em 0; }
155
+ .md li { margin: .2em 0; }
156
+ .md code { font: 12.5px var(--mono); background: var(--d1); padding: 1px 5px; border-radius: 5px; }
157
+ .md pre { background: var(--page); border-radius: 10px; padding: 12px 14px; overflow-x: auto; }
158
+ .md pre code { background: none; padding: 0; white-space: pre; }
159
+ .md blockquote { margin: .6em 0; padding: 2px 14px; border-left: 3px solid var(--line); color: var(--ink-2); }
160
+ .md table { border-collapse: collapse; font-size: 13px; margin: .8em 0; display: block; overflow-x: auto; }
161
+ .md th, .md td { border: 1px solid var(--line); padding: 5px 9px; text-align: left; vertical-align: top; }
162
+ .md hr { border: 0; border-top: 1px solid var(--line); margin: 1.2em 0; }
163
+ .md a { color: var(--accent); }
164
+ .md .wikilink { color: var(--accent); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
165
+
166
+ /* editor */
167
+ .form { display: grid; gap: 16px; }
168
+ .form label { display: grid; gap: 6px; font-size: 12px; color: var(--ink-3); font-weight: 600; }
169
+ .form input[type="text"], .form textarea, .form select { border: 1px solid var(--line); background: var(--surface); border-radius: 10px; padding: 9px 11px; font-weight: 400; color: var(--ink); font-size: 14px; }
170
+ .form textarea.desc { font-size: 15px; font-weight: 500; resize: vertical; min-height: 48px; }
171
+ .form textarea.body { font: 13px/1.6 var(--mono); min-height: 320px; resize: vertical; }
172
+ .form .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
173
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; padding: 2px; gap: 2px; }
174
+ .seg button { border: 0; background: none; padding: 6px 12px; border-radius: 8px; font-size: 13px; color: var(--ink-2); font-weight: 400; }
175
+ .seg button[aria-pressed="true"] { background: var(--d1); color: var(--ink); font-weight: 600; }
176
+ .form .hint { font-weight: 400; color: var(--ink-3); }
177
+ .form .hint.over { color: var(--danger); }
178
+ .d-foot { padding: 12px 20px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
179
+ .d-foot .err { flex: 1; color: var(--danger); font-size: 13px; }
180
+
181
+ .toasts { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); display: grid; gap: 8px; z-index: 40; }
182
+ .toast { background: var(--ink); color: var(--surface); border-radius: 10px; padding: 10px 14px; font-size: 13px; display: flex; align-items: center; gap: 16px; box-shadow: var(--shadow); }
183
+ .toast.error { background: var(--danger); color: #fff; }
184
+ .toast button { border: 0; background: none; color: inherit; font-weight: 700; padding: 0; text-decoration: underline; text-underline-offset: 2px; }
185
+ .boot { position: fixed; inset: 0; display: grid; place-items: center; background: var(--page); color: var(--ink-3); z-index: 50; text-align: center; padding: 24px; }
186
+
187
+ @media (max-width: 900px) { .row .meta .p { display: none; } }
188
+ @media (max-width: 760px) {
189
+ .bar, .filters, .list { padding-left: 12px; padding-right: 12px; }
190
+ .search kbd { display: none; }
191
+ .row .meta .t { display: none; }
192
+ }