agent-memory-git 0.1.2__tar.gz → 0.1.4__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.2 → agent_memory_git-0.1.4}/PKG-INFO +37 -12
  2. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/README.md +36 -11
  3. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/pyproject.toml +1 -1
  4. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/pyproject.toml.orig +1 -1
  5. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/install.py +12 -6
  6. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/ui_server.py +179 -12
  7. agent_memory_git-0.1.4/src/agent_memory/update.py +156 -0
  8. agent_memory_git-0.1.4/src/agent_memory/webui/assets/index-MdftBYNd.js +19 -0
  9. agent_memory_git-0.1.4/src/agent_memory/webui/assets/index-h-rsgD7i.css +1 -0
  10. agent_memory_git-0.1.4/src/agent_memory/webui/index.html +14 -0
  11. agent_memory_git-0.1.2/src/agent_memory/webui/app.css +0 -192
  12. agent_memory_git-0.1.2/src/agent_memory/webui/app.js +0 -635
  13. agent_memory_git-0.1.2/src/agent_memory/webui/index.html +0 -31
  14. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/LICENSE +0 -0
  15. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/PROTOCOL.md +0 -0
  16. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/__init__.py +0 -0
  17. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/__main__.py +0 -0
  18. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/capture.py +0 -0
  19. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/cli.py +0 -0
  20. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/config.py +0 -0
  21. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/context.py +0 -0
  22. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/embed_worker.py +0 -0
  23. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/frontmatter.py +0 -0
  24. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/gitsync.py +0 -0
  25. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/hooks.py +0 -0
  26. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/importers.py +0 -0
  27. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/lint.py +0 -0
  28. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/select.py +0 -0
  29. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/sessions.py +0 -0
  30. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/store.py +0 -0
  31. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/ui.py +0 -0
  32. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/src/agent_memory/util.py +0 -0
  33. {agent_memory_git-0.1.2 → agent_memory_git-0.1.4}/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.2
3
+ Version: 0.1.4
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,20 +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
- folder), which tool wrote it, keyword, age, and whether it needs tidying (what `mem lint`
135
- reports, also listed on the open memory); the order is newest, oldest, recently changed or A to Z.
136
- - **Read.** Click a row, or move with `j`/`k` and press Enter. The memory opens on the right with
137
- every frontmatter field (including each `source` entry), the body rendered as Markdown with
138
- `[[links]]` that open other memories, the file exactly as stored, and the most similar memories.
136
+ folder), which tool wrote it, keyword, when it was added (a recent period or a date range), and
137
+ status (needs tidying, as `mem lint` reports and the open memory lists, or unchanged for 60
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.
139
144
  - **Change.** Edit (`e`) or delete from the open memory, add one with New (`n`), or tick several
140
- rows (`x`) and delete them together. Every change is one git commit, pushed in the background,
141
- 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.
142
156
 
143
157
  Similar memories and search by meaning use Qwen3-Embedding-0.6B, int8 ONNX, on the CPU, in a
144
158
  separate `uv run --with onnxruntime --with tokenizers` environment; vectors are cached by content
@@ -147,7 +161,7 @@ pinned revision, checked by SHA-256). Without it, or with `--keywords-only`, sim
147
161
  come from keyword vectors and search matches words only.
148
162
 
149
163
  Keywords are the words that most set a memory apart from the others (TF-IDF). Words that should
150
- 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
151
165
  `~/.config/agent-memory/config.json`.
152
166
 
153
167
  The server only listens on loopback, and every request needs the one-time token from the link it
@@ -162,6 +176,17 @@ uv run ruff check .
162
176
  uv run ruff format .
163
177
  ```
164
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
+
165
190
  `uv run mem ...` runs the working copy without touching the installed one. To have every
166
191
  session use the working copy, install it in place of the release and rewire the hooks; go back
167
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,20 +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
- folder), which tool wrote it, keyword, age, and whether it needs tidying (what `mem lint`
115
- reports, also listed on the open memory); the order is newest, oldest, recently changed or A to Z.
116
- - **Read.** Click a row, or move with `j`/`k` and press Enter. The memory opens on the right with
117
- every frontmatter field (including each `source` entry), the body rendered as Markdown with
118
- `[[links]]` that open other memories, the file exactly as stored, and the most similar memories.
116
+ folder), which tool wrote it, keyword, when it was added (a recent period or a date range), and
117
+ status (needs tidying, as `mem lint` reports and the open memory lists, or unchanged for 60
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.
119
124
  - **Change.** Edit (`e`) or delete from the open memory, add one with New (`n`), or tick several
120
- rows (`x`) and delete them together. Every change is one git commit, pushed in the background,
121
- 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.
122
136
 
123
137
  Similar memories and search by meaning use Qwen3-Embedding-0.6B, int8 ONNX, on the CPU, in a
124
138
  separate `uv run --with onnxruntime --with tokenizers` environment; vectors are cached by content
@@ -127,7 +141,7 @@ pinned revision, checked by SHA-256). Without it, or with `--keywords-only`, sim
127
141
  come from keyword vectors and search matches words only.
128
142
 
129
143
  Keywords are the words that most set a memory apart from the others (TF-IDF). Words that should
130
- 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
131
145
  `~/.config/agent-memory/config.json`.
132
146
 
133
147
  The server only listens on loopback, and every request needs the one-time token from the link it
@@ -142,6 +156,17 @@ uv run ruff check .
142
156
  uv run ruff format .
143
157
  ```
144
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
+
145
170
  `uv run mem ...` runs the working copy without touching the installed one. To have every
146
171
  session use the working copy, install it in place of the release and rewire the hooks; go back
147
172
  the same way:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-memory-git"
3
- version = "0.1.2"
3
+ version = "0.1.4"
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.2"
3
+ version = "0.1.4"
4
4
  description = "Shared, git-backed memory for Claude Code and Codex."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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])
@@ -0,0 +1,156 @@
1
+ """Whether a newer release is out, and updating this copy when `uv tool` installed it.
2
+
3
+ The check asks PyPI for the package's latest version (only the package name goes out) and keeps the
4
+ answer for a day. Automatic checks can be turned off with `check_updates: false` in config.json.
5
+ """
6
+
7
+ import datetime as _dt
8
+ import importlib.metadata
9
+ import json
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ from . import config
19
+
20
+ PACKAGE = "agent-memory-git"
21
+ INDEX = f"https://pypi.org/pypi/{PACKAGE}/json"
22
+ CHECK_EVERY = 24 * 3600
23
+ CACHE_NAME = "update-check.json"
24
+ # how to update, by how this copy was installed
25
+ COMMANDS = {
26
+ "uv-tool": f"uv tool upgrade {PACKAGE} && mem install",
27
+ "uvx": f'uvx --refresh --from "{PACKAGE}[ui]" mem ui',
28
+ "dev": "git pull && (cd web && npm ci && npm run build) && mem install", # run in the checkout
29
+ "other": f'pip install -U "{PACKAGE}[ui]" && mem install',
30
+ }
31
+
32
+
33
+ def _cache():
34
+ return config.CONFIG_DIR / CACHE_NAME
35
+
36
+
37
+ def current():
38
+ try:
39
+ return importlib.metadata.version(PACKAGE)
40
+ except importlib.metadata.PackageNotFoundError:
41
+ return None
42
+
43
+
44
+ def _key(version):
45
+ """A version as numbers to compare: 0.1.10 > 0.1.9; a pre-release sorts before its release."""
46
+ out = []
47
+ for part in version.split("."):
48
+ m = re.match(r"(\d+)(.*)", part)
49
+ if not m:
50
+ break
51
+ out += [int(m.group(1)), 0 if m.group(2) else 1]
52
+ return out
53
+
54
+
55
+ def newer(latest, cur):
56
+ return bool(latest and cur) and _key(latest) > _key(cur)
57
+
58
+
59
+ def fetch_latest(timeout=5):
60
+ """The latest release on PyPI, or None when PyPI cannot be reached."""
61
+ try:
62
+ with urllib.request.urlopen(INDEX, timeout=timeout) as r:
63
+ return json.load(r)["info"]["version"]
64
+ except Exception: # some machines only reach PyPI through the system's certificates; curl uses those
65
+ pass
66
+ curl = shutil.which("curl")
67
+ if not curl:
68
+ return None
69
+ try:
70
+ r = subprocess.run([curl, "-fsSL", "--max-time", str(timeout), INDEX], capture_output=True, text=True)
71
+ return json.loads(r.stdout)["info"]["version"] if r.returncode == 0 else None
72
+ except (OSError, ValueError, KeyError):
73
+ return None
74
+
75
+
76
+ def _inside(path, root):
77
+ try:
78
+ return Path(path).resolve().is_relative_to(Path(root).resolve())
79
+ except OSError:
80
+ return False
81
+
82
+
83
+ def how_installed():
84
+ """'uv-tool' when `uv tool upgrade` can update this copy; else 'dev', 'uvx' or 'other'."""
85
+ try:
86
+ direct = importlib.metadata.distribution(PACKAGE).read_text("direct_url.json")
87
+ except importlib.metadata.PackageNotFoundError:
88
+ return "dev"
89
+ if direct and json.loads(direct).get("dir_info", {}).get("editable"):
90
+ return "dev"
91
+ uv = shutil.which("uv")
92
+ if uv:
93
+ for kind, args in (("uv-tool", ["tool", "dir"]), ("uvx", ["cache", "dir"])):
94
+ r = subprocess.run([uv, *args], capture_output=True, text=True, timeout=10)
95
+ if r.returncode == 0 and r.stdout.strip() and _inside(sys.prefix, r.stdout.strip()):
96
+ return kind
97
+ return "other"
98
+
99
+
100
+ def status(force=False):
101
+ """What the page shows: this version, the latest one known, and how to update."""
102
+ auto = config.load().get("check_updates", True)
103
+ try:
104
+ cached = json.loads(_cache().read_text("utf-8"))
105
+ except (OSError, ValueError):
106
+ cached = {}
107
+ if force or (auto and time.time() - cached.get("checked", 0) > CHECK_EVERY):
108
+ latest = fetch_latest()
109
+ if latest:
110
+ cached = {"checked": time.time(), "latest": latest}
111
+ _cache().parent.mkdir(parents=True, exist_ok=True)
112
+ _cache().write_text(json.dumps(cached), "utf-8")
113
+ cur, latest, method = current(), cached.get("latest"), how_installed()
114
+ checked = cached.get("checked")
115
+ return {
116
+ "current": cur,
117
+ "latest": latest,
118
+ "available": newer(latest, cur),
119
+ "checked": _dt.datetime.fromtimestamp(checked).astimezone().isoformat() if checked else None,
120
+ "auto": auto,
121
+ "method": method,
122
+ "command": COMMANDS[method],
123
+ "oneClick": method == "uv-tool",
124
+ }
125
+
126
+
127
+ def _tail(*outputs, lines=12):
128
+ text = "\n".join(o.strip() for o in outputs if o and o.strip())
129
+ return "\n".join(text.splitlines()[-lines:])
130
+
131
+
132
+ def upgrade():
133
+ """`uv tool upgrade`, then `mem install` from the upgraded code. Returns what happened."""
134
+ if how_installed() != "uv-tool":
135
+ return {"ok": False, "error": "This copy was not installed with `uv tool`; update it with the command shown."}
136
+ before = current()
137
+ up = subprocess.run([shutil.which("uv"), "tool", "upgrade", PACKAGE], capture_output=True, text=True, timeout=600)
138
+ if up.returncode != 0:
139
+ return {"ok": False, "error": "uv tool upgrade failed.", "log": _tail(up.stdout, up.stderr)}
140
+ # a new process, so both run the upgraded code
141
+ after = subprocess.run(
142
+ [sys.executable, "-c", f"import importlib.metadata as m; print(m.version({PACKAGE!r}))"],
143
+ capture_output=True,
144
+ text=True,
145
+ ).stdout.strip()
146
+ inst = subprocess.run(
147
+ [sys.executable, "-m", "agent_memory", "install"], capture_output=True, text=True, timeout=120
148
+ )
149
+ return {
150
+ "ok": inst.returncode == 0,
151
+ "from": before,
152
+ "to": after or before,
153
+ "changed": bool(after) and after != before,
154
+ "error": None if inst.returncode == 0 else "mem install failed after the upgrade.",
155
+ "log": _tail(up.stdout, up.stderr, inst.stdout, inst.stderr),
156
+ }