agent-memory-git 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/PKG-INFO +35 -12
  2. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/README.md +34 -11
  3. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/pyproject.toml +1 -1
  4. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/pyproject.toml.orig +1 -1
  5. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/config.py +13 -1
  6. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/install.py +12 -6
  7. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/ui_server.py +179 -12
  8. agent_memory_git-0.1.5/src/agent_memory/update.py +211 -0
  9. agent_memory_git-0.1.5/src/agent_memory/webui/assets/index-MdftBYNd.js +19 -0
  10. agent_memory_git-0.1.5/src/agent_memory/webui/assets/index-h-rsgD7i.css +1 -0
  11. agent_memory_git-0.1.5/src/agent_memory/webui/index.html +14 -0
  12. agent_memory_git-0.1.3/src/agent_memory/webui/app.css +0 -263
  13. agent_memory_git-0.1.3/src/agent_memory/webui/app.js +0 -739
  14. agent_memory_git-0.1.3/src/agent_memory/webui/index.html +0 -47
  15. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/LICENSE +0 -0
  16. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/PROTOCOL.md +0 -0
  17. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/__init__.py +0 -0
  18. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/__main__.py +0 -0
  19. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/capture.py +0 -0
  20. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/cli.py +0 -0
  21. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/context.py +0 -0
  22. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/embed_worker.py +0 -0
  23. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/frontmatter.py +0 -0
  24. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/gitsync.py +0 -0
  25. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/hooks.py +0 -0
  26. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/importers.py +0 -0
  27. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/lint.py +0 -0
  28. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/select.py +0 -0
  29. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/sessions.py +0 -0
  30. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/store.py +0 -0
  31. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/ui.py +0 -0
  32. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/util.py +0 -0
  33. {agent_memory_git-0.1.3 → agent_memory_git-0.1.5}/src/agent_memory/workdir.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-memory-git
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Shared, git-backed memory for Claude Code and Codex.
5
5
  Keywords: claude-code,codex,memory,agents,git
6
6
  Author: Shichao Song
@@ -88,7 +88,8 @@ mem install
88
88
  ```
89
89
 
90
90
  To upgrade, run `uv tool upgrade agent-memory-git` and then `mem install`, which refreshes the
91
- protocol copies; `mem doctor` says when they are out of date.
91
+ protocol copies; `mem doctor` says when they are out of date. `mem ui` does both from Settings, with
92
+ one click, when a newer release is out.
92
93
 
93
94
  `install` edits, and records in `~/.config/agent-memory/install-state.json`:
94
95
 
@@ -125,22 +126,33 @@ mem doctor check the installation
125
126
 
126
127
  ## Managing memories in a page
127
128
 
128
- `mem ui` starts a small server on `127.0.0.1` and opens a page listing every memory. Keep the
129
- terminal open while you use it. It needs `numpy`; the hooks do not.
129
+ `mem ui` starts a small server on `127.0.0.1` and opens a page with three parts: Memories, Insights
130
+ and Settings (the logo always leads back to Memories). Keep the terminal open while you use it. It
131
+ needs `numpy`; the hooks do not.
130
132
 
131
133
  - **Find.** One search box (press `/`). Word matches appear as you type, with the matching words
132
134
  marked; a moment later the local model adds memories close in meaning, across Chinese and
133
135
  English. Filters narrow the list by type, where a memory loads (everywhere, or one project or
134
136
  folder), which tool wrote it, keyword, when it was added (a recent period or a date range), and
135
137
  status (needs tidying, as `mem lint` reports and the open memory lists, or unchanged for 60
136
- days). Tick several values in one filter to see memories that match any of them. The date
137
- column's header sets the order: newest, oldest, recently changed or A to Z.
138
- - **Read.** Click a row, or move with `j`/`k` and press Enter. The memory opens on the right with
139
- the body rendered as Markdown with `[[links]]` that open other memories, every frontmatter field
140
- (including each `source` entry), the most similar memories, and the file exactly as stored.
138
+ days). Tick several values in one filter to see memories that match any of them. The list's
139
+ header sets the order: newest, oldest, recently changed or A to Z.
140
+ - **Read.** Click a memory, or move with `j`/`k`. It opens beside the list (drag the line between
141
+ them to resize) with the body rendered as Markdown, `[[links]]` that open other memories, where
142
+ it came from, every other frontmatter field, the most similar memories, and the file exactly as
143
+ stored. On a narrow window the list and the memory take turns.
141
144
  - **Change.** Edit (`e`) or delete from the open memory, add one with New (`n`), or tick several
142
- rows (`x`) and delete them together. Every change is one git commit, pushed in the background,
143
- with Undo. If an agent edits the same file meanwhile, saving is refused rather than overwriting.
145
+ memories (`x`) and delete them together. Every change is one git commit, pushed in the
146
+ background, with Undo. If an agent edits the same file meanwhile, saving is refused rather than
147
+ overwriting.
148
+ - **Insights.** How many memories there are and how they change week by week, where they load and
149
+ which tool wrote them, pairs that read alike (compare them side by side and keep one), and the
150
+ index a session would get at a chosen place, against the 10,000-character hook limit.
151
+ - **Settings.** Light or dark, updates, the data repository and a sync on demand, words never to
152
+ use as keywords, the embedding model, and the same checks as `mem doctor`. Once a day the page
153
+ asks PyPI whether a newer release is out (only the package name is sent; turn it off there); when
154
+ `uv tool` installed agent-memory, Update runs the upgrade and `mem install` and restarts the page
155
+ on the new version.
144
156
 
145
157
  Similar memories and search by meaning use Qwen3-Embedding-0.6B, int8 ONNX, on the CPU, in a
146
158
  separate `uv run --with onnxruntime --with tokenizers` environment; vectors are cached by content
@@ -149,7 +161,7 @@ pinned revision, checked by SHA-256). Without it, or with `--keywords-only`, sim
149
161
  come from keyword vectors and search matches words only.
150
162
 
151
163
  Keywords are the words that most set a memory apart from the others (TF-IDF). Words that should
152
- never be keywords, such as your own name, go in `keyword_stopwords` in
164
+ never be keywords, such as your own name, go in Settings, which keeps them as `keyword_stopwords` in
153
165
  `~/.config/agent-memory/config.json`.
154
166
 
155
167
  The server only listens on loopback, and every request needs the one-time token from the link it
@@ -164,6 +176,17 @@ uv run ruff check .
164
176
  uv run ruff format .
165
177
  ```
166
178
 
179
+ The page `mem ui` serves lives in `web/` (Svelte 5, TypeScript, Vite, Tailwind and bits-ui) and
180
+ needs Node 20 or newer. `npm run build` there writes it into `src/agent_memory/webui/`, which the
181
+ package ships; CI builds it before packaging, so the built files are not committed. While working on it, `npm run dev` serves the page with hot reload and passes API
182
+ calls to a running `mem ui` (set `MEM_UI` if it is not on port 8790); open it with the `#t=…` part of
183
+ the link `mem ui` printed.
184
+
185
+ ```bash
186
+ cd web && npm ci && npm run build # the page, into the package
187
+ npm run check # svelte-check, warnings fail it
188
+ ```
189
+
167
190
  `uv run mem ...` runs the working copy without touching the installed one. To have every
168
191
  session use the working copy, install it in place of the release and rewire the hooks; go back
169
192
  the same way:
@@ -68,7 +68,8 @@ mem install
68
68
  ```
69
69
 
70
70
  To upgrade, run `uv tool upgrade agent-memory-git` and then `mem install`, which refreshes the
71
- protocol copies; `mem doctor` says when they are out of date.
71
+ protocol copies; `mem doctor` says when they are out of date. `mem ui` does both from Settings, with
72
+ one click, when a newer release is out.
72
73
 
73
74
  `install` edits, and records in `~/.config/agent-memory/install-state.json`:
74
75
 
@@ -105,22 +106,33 @@ mem doctor check the installation
105
106
 
106
107
  ## Managing memories in a page
107
108
 
108
- `mem ui` starts a small server on `127.0.0.1` and opens a page listing every memory. Keep the
109
- terminal open while you use it. It needs `numpy`; the hooks do not.
109
+ `mem ui` starts a small server on `127.0.0.1` and opens a page with three parts: Memories, Insights
110
+ and Settings (the logo always leads back to Memories). Keep the terminal open while you use it. It
111
+ needs `numpy`; the hooks do not.
110
112
 
111
113
  - **Find.** One search box (press `/`). Word matches appear as you type, with the matching words
112
114
  marked; a moment later the local model adds memories close in meaning, across Chinese and
113
115
  English. Filters narrow the list by type, where a memory loads (everywhere, or one project or
114
116
  folder), which tool wrote it, keyword, when it was added (a recent period or a date range), and
115
117
  status (needs tidying, as `mem lint` reports and the open memory lists, or unchanged for 60
116
- days). Tick several values in one filter to see memories that match any of them. The date
117
- column's header sets the order: newest, oldest, recently changed or A to Z.
118
- - **Read.** Click a row, or move with `j`/`k` and press Enter. The memory opens on the right with
119
- the body rendered as Markdown with `[[links]]` that open other memories, every frontmatter field
120
- (including each `source` entry), the most similar memories, and the file exactly as stored.
118
+ days). Tick several values in one filter to see memories that match any of them. The list's
119
+ header sets the order: newest, oldest, recently changed or A to Z.
120
+ - **Read.** Click a memory, or move with `j`/`k`. It opens beside the list (drag the line between
121
+ them to resize) with the body rendered as Markdown, `[[links]]` that open other memories, where
122
+ it came from, every other frontmatter field, the most similar memories, and the file exactly as
123
+ stored. On a narrow window the list and the memory take turns.
121
124
  - **Change.** Edit (`e`) or delete from the open memory, add one with New (`n`), or tick several
122
- rows (`x`) and delete them together. Every change is one git commit, pushed in the background,
123
- with Undo. If an agent edits the same file meanwhile, saving is refused rather than overwriting.
125
+ memories (`x`) and delete them together. Every change is one git commit, pushed in the
126
+ background, with Undo. If an agent edits the same file meanwhile, saving is refused rather than
127
+ overwriting.
128
+ - **Insights.** How many memories there are and how they change week by week, where they load and
129
+ which tool wrote them, pairs that read alike (compare them side by side and keep one), and the
130
+ index a session would get at a chosen place, against the 10,000-character hook limit.
131
+ - **Settings.** Light or dark, updates, the data repository and a sync on demand, words never to
132
+ use as keywords, the embedding model, and the same checks as `mem doctor`. Once a day the page
133
+ asks PyPI whether a newer release is out (only the package name is sent; turn it off there); when
134
+ `uv tool` installed agent-memory, Update runs the upgrade and `mem install` and restarts the page
135
+ on the new version.
124
136
 
125
137
  Similar memories and search by meaning use Qwen3-Embedding-0.6B, int8 ONNX, on the CPU, in a
126
138
  separate `uv run --with onnxruntime --with tokenizers` environment; vectors are cached by content
@@ -129,7 +141,7 @@ pinned revision, checked by SHA-256). Without it, or with `--keywords-only`, sim
129
141
  come from keyword vectors and search matches words only.
130
142
 
131
143
  Keywords are the words that most set a memory apart from the others (TF-IDF). Words that should
132
- never be keywords, such as your own name, go in `keyword_stopwords` in
144
+ never be keywords, such as your own name, go in Settings, which keeps them as `keyword_stopwords` in
133
145
  `~/.config/agent-memory/config.json`.
134
146
 
135
147
  The server only listens on loopback, and every request needs the one-time token from the link it
@@ -144,6 +156,17 @@ uv run ruff check .
144
156
  uv run ruff format .
145
157
  ```
146
158
 
159
+ The page `mem ui` serves lives in `web/` (Svelte 5, TypeScript, Vite, Tailwind and bits-ui) and
160
+ needs Node 20 or newer. `npm run build` there writes it into `src/agent_memory/webui/`, which the
161
+ package ships; CI builds it before packaging, so the built files are not committed. While working on it, `npm run dev` serves the page with hot reload and passes API
162
+ calls to a running `mem ui` (set `MEM_UI` if it is not on port 8790); open it with the `#t=…` part of
163
+ the link `mem ui` printed.
164
+
165
+ ```bash
166
+ cd web && npm ci && npm run build # the page, into the package
167
+ npm run check # svelte-check, warnings fail it
168
+ ```
169
+
147
170
  `uv run mem ...` runs the working copy without touching the installed one. To have every
148
171
  session use the working copy, install it in place of the release and rewire the hooks; go back
149
172
  the same way:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-memory-git"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Shared, git-backed memory for Claude Code and Codex."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-memory-git"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Shared, git-backed memory for Claude Code and Codex."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,9 +14,21 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
14
14
  PROTOCOL_COPY = CONFIG_DIR / "PROTOCOL.md" # what Claude Code imports; `mem install` refreshes it
15
15
 
16
16
 
17
+ def _python():
18
+ """This interpreter, as the `python` beside it when that is the same file. Whether `mem` was
19
+ started as python3 or python3.11 (uv has changed its entry scripts) then leaves the hook
20
+ commands alone, so `mem install` does not rewrite them and Codex does not ask to trust them again."""
21
+ exe = Path(sys.executable)
22
+ try:
23
+ plain = exe.with_name("python")
24
+ return str(plain) if plain != exe and plain.samefile(exe) else sys.executable
25
+ except (OSError, ValueError): # no `python` beside it, or no sys.executable at all
26
+ return sys.executable
27
+
28
+
17
29
  def mem_command():
18
30
  """The argv that runs `mem` with this interpreter, for hooks and background sync."""
19
- return [sys.executable, "-m", "agent_memory"]
31
+ return [_python(), "-m", "agent_memory"]
20
32
 
21
33
 
22
34
  def _mem_path():
@@ -479,13 +479,12 @@ def recent_hook_errors(log_text, now=None, days=ERROR_DAYS):
479
479
  return out
480
480
 
481
481
 
482
- def doctor():
483
- ok = True
482
+ def doctor_checks():
483
+ """Every check as (passed, description), for `mem doctor` and the page's Health section."""
484
+ results = []
484
485
 
485
486
  def check(cond, msg):
486
- nonlocal ok
487
- print(("ok " if cond else "FAIL ") + msg)
488
- ok &= bool(cond)
487
+ results.append((bool(cond), msg))
489
488
 
490
489
  data = config.data_dir()
491
490
  check(data and (data / ".git").exists(), f"data repository: {data}")
@@ -528,4 +527,11 @@ def doctor():
528
527
  errors = recent_hook_errors(_read(logf) or "")
529
528
  latest = f"; latest: {errors[-1]} (details in {logf})" if errors else ""
530
529
  check(not errors, f"no hook errors in the last {ERROR_DAYS} days ({len(errors)}{latest})")
531
- return 0 if ok else 1
530
+ return results
531
+
532
+
533
+ def doctor():
534
+ results = doctor_checks()
535
+ for passed, msg in results:
536
+ print(("ok " if passed else "FAIL ") + msg)
537
+ return 0 if all(passed for passed, _ in results) else 1
@@ -10,7 +10,10 @@ background like hook commits; Undo reverts that commit.
10
10
  """
11
11
 
12
12
  import hashlib
13
+ import importlib.metadata
13
14
  import json
15
+ import os
16
+ import re
14
17
  import secrets
15
18
  import subprocess
16
19
  import sys
@@ -21,18 +24,35 @@ from urllib.parse import parse_qs, urlparse
21
24
 
22
25
  import numpy as np
23
26
 
24
- from . import frontmatter, gitsync, store, ui
25
- from .context import detect, hostname
27
+ from . import config, frontmatter, gitsync, select, store, ui, update
28
+ from .context import detect, hostname, normalize_remote
26
29
  from .util import SCOPES, TYPES, atomic_write, id_path, log_exc, new_id, now_iso, slugify
27
30
 
28
- STATIC = Path(__file__).with_name("webui")
29
- MIME = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8"}
31
+ STATIC = Path(__file__).with_name("webui") # the page, built from web/ by `npm run build`
32
+ MIME = {
33
+ ".html": "text/html; charset=utf-8",
34
+ ".css": "text/css; charset=utf-8",
35
+ ".js": "text/javascript; charset=utf-8",
36
+ ".svg": "image/svg+xml",
37
+ ".png": "image/png",
38
+ ".woff2": "font/woff2",
39
+ }
30
40
  CSP = (
31
41
  "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
32
42
  "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
33
43
  )
44
+ NOT_BUILT = (
45
+ "<!doctype html><meta charset=utf-8><title>agent-memory</title>"
46
+ "<p style='font:15px/1.6 system-ui;margin:3rem auto;max-width:36rem'>This copy of agent-memory has no built page. "
47
+ "In a checkout, build it with <code>cd web &amp;&amp; npm ci &amp;&amp; npm run build</code>, then reload.</p>"
48
+ )
49
+ RESTART_TOKEN = "AGENT_MEMORY_UI_TOKEN"
34
50
  PLACE_FIELDS = ("cwd", "git_root", "git_remote", "git_branch")
35
51
  SEARCH_HITS = 8
52
+ ACTIVITY_COMMITS = 2000
53
+ HOOK_LIMIT = 10000 # what Claude Code injects at most from a hook
54
+ COUNTS = re.compile(r"memory: \+(\d+) ~(\d+) -(\d+)")
55
+ UI_SUBJECT = re.compile(r"memory: (add|edit|delete|undo) (?:(\d+) memories)?")
36
56
 
37
57
 
38
58
  class ApiError(Exception):
@@ -57,11 +77,13 @@ class UiApp:
57
77
 
58
78
  def stamp(self):
59
79
  head = gitsync.git(self.data, "rev-parse", "--short", "HEAD", timeout=5).stdout.strip()
80
+ # a push in the background moves the upstream ref but not HEAD; without it a stale "not pushed" would stay
81
+ pushed = gitsync.git(self.data, "rev-parse", "--short", "@{u}", timeout=5).stdout.strip()
60
82
  newest, count = 0, 0
61
83
  for path in self.mdir.rglob("*.md"):
62
84
  count += 1
63
85
  newest = max(newest, path.stat().st_mtime_ns)
64
- return f"{head}:{count}:{newest}"
86
+ return f"{head}:{pushed}:{count}:{newest}"
65
87
 
66
88
  def refresh(self):
67
89
  with self.lock:
@@ -84,6 +106,116 @@ class UiApp:
84
106
  ahead = int(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip().isdigit() else 0
85
107
  return {"remote": True, "ahead": ahead}
86
108
 
109
+ # ------------------------------------------------------------ insights and settings
110
+
111
+ def activity(self):
112
+ """Commits to the data repository, newest first: when, which tool, how many memories added, changed, deleted."""
113
+ fmt = "%H%x1f%aI%x1f%s%x1f%(trailers:key=Tool,valueonly,separator=%x2C)%x1e"
114
+ r = gitsync.git(self.data, "log", f"--format={fmt}", "-n", str(ACTIVITY_COMMITS), timeout=10)
115
+ commits = []
116
+ for record in r.stdout.split("\x1e") if r.returncode == 0 else []:
117
+ parts = record.strip("\n").split("\x1f")
118
+ if len(parts) != 4 or not parts[2].startswith("memory: "):
119
+ continue
120
+ sha, when, subject, tool = parts
121
+ counts = COUNTS.match(subject)
122
+ if counts:
123
+ added, changed, deleted = map(int, counts.groups())
124
+ else:
125
+ ui_change = UI_SUBJECT.match(subject)
126
+ verb, many = ui_change.groups() if ui_change else ("", None)
127
+ n = int(many) if many else 1
128
+ added, changed, deleted = (
129
+ (n if verb == "add" else 0),
130
+ (n if verb in ("edit", "undo") else 0),
131
+ (n if verb == "delete" else 0),
132
+ )
133
+ commits.append(
134
+ {
135
+ "sha": sha[:10],
136
+ "date": when,
137
+ "subject": subject,
138
+ "tool": tool.strip() or None,
139
+ "added": added,
140
+ "changed": changed,
141
+ "deleted": deleted,
142
+ }
143
+ )
144
+ return {"commits": commits}
145
+
146
+ def index_preview(self, place):
147
+ """The index a session gets at one of the known places, or with no place only the global part."""
148
+ ctx = {}
149
+ if place:
150
+ known = {p["key"]: p.get("ctx", {}) for p in self.current().get("places", [])}
151
+ if place not in known:
152
+ raise ApiError(404, "unknown place")
153
+ ctx = dict(known[place])
154
+ memories = store.load_all(self.data)
155
+ paths = {
156
+ "memories": str(self.mdir),
157
+ "staging": str(self.data / "staging" / "<session>"),
158
+ "mem": str(config.BIN),
159
+ }
160
+ text = select.render(memories, ctx, paths)
161
+ globals_, here = select.visible(memories, ctx)
162
+ return {"text": text, "chars": len(text), "limit": HOOK_LIMIT, "global": len(globals_), "here": len(here)}
163
+
164
+ def settings(self):
165
+ remote = gitsync.git(self.data, "remote", "get-url", "origin", timeout=5)
166
+ try:
167
+ version = importlib.metadata.version("agent-memory-git")
168
+ except importlib.metadata.PackageNotFoundError:
169
+ version = "dev"
170
+ return {
171
+ "version": version,
172
+ "dataDir": ui._short(str(self.data)),
173
+ "remote": normalize_remote(remote.stdout.strip()) if remote.returncode == 0 else None,
174
+ "sync": self.sync_status(),
175
+ "keywordStopwords": config.load().get("keyword_stopwords", []),
176
+ "model": {
177
+ "name": ui.MODEL["name"],
178
+ "ready": ui.model_ready(),
179
+ "dir": ui._short(str(ui.model_dir())),
180
+ "fetch": "mem ui --fetch-model",
181
+ "size": "about 625 MB",
182
+ },
183
+ "semantic": bool(self.state.get("semantic")),
184
+ }
185
+
186
+ def set_settings(self, p):
187
+ """Change keyword_stopwords, check_updates, or both."""
188
+ cfg = config.load()
189
+ if "keyword_stopwords" in p:
190
+ words = p["keyword_stopwords"]
191
+ if not isinstance(words, list) or not all(isinstance(w, str) for w in words):
192
+ raise ApiError(400, "keyword_stopwords must be a list of words")
193
+ cfg["keyword_stopwords"] = sorted({w.strip().lower() for w in words if w.strip()})
194
+ if "check_updates" in p:
195
+ if not isinstance(p["check_updates"], bool):
196
+ raise ApiError(400, "check_updates must be true or false")
197
+ cfg["check_updates"] = p["check_updates"]
198
+ config.save(cfg)
199
+ if "keyword_stopwords" in p:
200
+ self.refresh() # keywords are chosen when the state is built
201
+ return self.settings()
202
+
203
+ def check_update(self, p):
204
+ return update.status(force=True)
205
+
206
+ def run_update(self, p):
207
+ return update.upgrade()
208
+
209
+ def doctor(self):
210
+ from .install import doctor_checks
211
+
212
+ return {"checks": [{"ok": passed, "text": text} for passed, text in doctor_checks()]}
213
+
214
+ def sync(self, p):
215
+ ok = gitsync.sync(self.data)
216
+ self.refresh()
217
+ return {"ok": ok, "sync": self.state["sync"]}
218
+
87
219
  def search(self, query):
88
220
  """Memories closest in meaning to the query (the page does word matching itself)."""
89
221
  query = (query or "").strip()
@@ -300,7 +432,16 @@ class UiApp:
300
432
 
301
433
  class Handler(BaseHTTPRequestHandler):
302
434
  server_version = "agent-memory"
303
- POSTS = {"/api/create": "create", "/api/edit": "edit", "/api/delete": "delete", "/api/undo": "undo"}
435
+ POSTS = {
436
+ "/api/create": "create",
437
+ "/api/edit": "edit",
438
+ "/api/delete": "delete",
439
+ "/api/undo": "undo",
440
+ "/api/settings": "set_settings",
441
+ "/api/sync": "sync",
442
+ "/api/update/check": "check_update",
443
+ "/api/update": "run_update",
444
+ }
304
445
 
305
446
  def log_message(self, *args):
306
447
  pass
@@ -351,25 +492,44 @@ class Handler(BaseHTTPRequestHandler):
351
492
  if not self._guard(api):
352
493
  return
353
494
  if not api:
354
- name = "index.html" if url.path in ("/", "/index.html") else url.path.lstrip("/")
355
- path = STATIC / name
356
- if "/" in name or not path.is_file() or path.suffix not in MIME:
357
- return self._send(404, {"error": "not found"})
358
- return self._send(200, path.read_bytes(), MIME[path.suffix])
495
+ return self._page(url.path.lstrip("/"))
359
496
  app, q = self.server.app, {k: v[0] for k, v in parse_qs(url.query).items()}
360
497
  routes = {
361
498
  "/api/state": app.current,
362
499
  "/api/stamp": lambda: {"stamp": app.stamp()},
363
500
  "/api/search": lambda: app.search(q.get("q")),
501
+ "/api/activity": app.activity,
502
+ "/api/index-preview": lambda: app.index_preview(q.get("place", "")),
503
+ "/api/settings": app.settings,
504
+ "/api/doctor": app.doctor,
505
+ "/api/update": update.status,
364
506
  }
365
507
  if url.path not in routes:
366
508
  return self._send(404, {"error": "not found"})
367
509
  self._run(routes[url.path])
368
510
 
511
+ def _page(self, rel):
512
+ """The page's files, and index.html for any other path so the page can route."""
513
+ root = STATIC.resolve()
514
+ target = (root / rel).resolve() if rel else None
515
+ if target and target.is_file() and root in target.parents and target.suffix in MIME:
516
+ return self._send(200, target.read_bytes(), MIME[target.suffix])
517
+ index = root / "index.html"
518
+ if not index.is_file(): # a checkout where the page was never built
519
+ return self._send(503, NOT_BUILT.encode(), MIME[".html"])
520
+ if rel.startswith("assets/"):
521
+ return self._send(404, {"error": "not found"})
522
+ return self._send(200, index.read_bytes(), MIME[".html"])
523
+
369
524
  def do_POST(self):
370
525
  url = urlparse(self.path)
371
526
  if not self._guard(True):
372
527
  return
528
+ if url.path == "/api/restart": # after an update: answer, then serve_forever ends and run() starts the new code
529
+ self.server.restart = True
530
+ self._send(200, {"restarting": True})
531
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
532
+ return
373
533
  method = self.POSTS.get(url.path)
374
534
  if not method:
375
535
  return self._send(404, {"error": "not found"})
@@ -389,7 +549,9 @@ def make_server(data_dir, port=8790, semantic=True):
389
549
  server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
390
550
  port = server.server_address[1]
391
551
  server.hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
392
- server.token = secrets.token_urlsafe(18)
552
+ # a restart after an update keeps the token, so the open page carries on
553
+ server.token = os.environ.pop(RESTART_TOKEN, None) or secrets.token_urlsafe(18)
554
+ server.restart = False
393
555
  server.app = app
394
556
  server.url = f"http://127.0.0.1:{port}/#t={server.token}"
395
557
  return server
@@ -418,3 +580,8 @@ def run(data_dir, port=8790, open_browser=True, semantic=True):
418
580
  server.server_close()
419
581
  if server.app.encoder:
420
582
  server.app.encoder.close()
583
+ if server.restart:
584
+ args = ["ui", "--port", str(server.server_address[1]), "--no-open"] + ([] if semantic else ["--keywords-only"])
585
+ print("Restarting with the updated version...", flush=True)
586
+ os.environ[RESTART_TOKEN] = server.token
587
+ os.execv(sys.executable, [sys.executable, "-m", "agent_memory", *args])