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.
- agent_memory/PROTOCOL.md +80 -0
- agent_memory/__init__.py +1 -0
- agent_memory/__main__.py +7 -0
- agent_memory/capture.py +207 -0
- agent_memory/cli.py +317 -0
- agent_memory/config.py +53 -0
- agent_memory/context.py +73 -0
- agent_memory/embed_worker.py +90 -0
- agent_memory/frontmatter.py +146 -0
- agent_memory/gitsync.py +119 -0
- agent_memory/hooks.py +95 -0
- agent_memory/importers.py +231 -0
- agent_memory/install.py +483 -0
- agent_memory/lint.py +45 -0
- agent_memory/select.py +105 -0
- agent_memory/sessions.py +64 -0
- agent_memory/store.py +97 -0
- agent_memory/ui.py +410 -0
- agent_memory/ui_server.py +371 -0
- agent_memory/util.py +101 -0
- agent_memory/webui/app.css +192 -0
- agent_memory/webui/app.js +635 -0
- agent_memory/webui/index.html +31 -0
- agent_memory/workdir.py +135 -0
- agent_memory_git-0.1.0.dist-info/METADATA +183 -0
- agent_memory_git-0.1.0.dist-info/RECORD +29 -0
- agent_memory_git-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_git-0.1.0.dist-info/entry_points.txt +3 -0
- agent_memory_git-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|