agent-memory-git 0.1.10__tar.gz → 0.1.11__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 (68) hide show
  1. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/PKG-INFO +3 -3
  2. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/README.md +2 -2
  3. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/pyproject.toml +1 -1
  4. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/pyproject.toml.orig +1 -1
  5. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/PROTOCOL.md +1 -1
  6. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/cli.py +15 -4
  7. agent_memory_git-0.1.11/src/agent_memory/hooks.py +141 -0
  8. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/install.py +5 -1
  9. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/ui_server.py +6 -1
  10. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/app.css +13 -0
  11. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/main.js +1 -0
  12. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/store.js +22 -5
  13. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/overlays.js +3 -0
  14. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/profiles.js +11 -1
  15. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/settings.js +1 -1
  16. agent_memory_git-0.1.10/src/agent_memory/hooks.py +0 -122
  17. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/LICENSE +0 -0
  18. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/__init__.py +0 -0
  19. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/__main__.py +0 -0
  20. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/capture.py +0 -0
  21. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/config.py +0 -0
  22. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/context.py +0 -0
  23. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/personal/2026/08/20260816-blog-publishing-k3qm.md +0 -0
  24. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/personal/2026/09/20260906-push-personal-repos-w8re.md +0 -0
  25. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/personal/2026/09/20260921-home-server-n2vb.md +0 -0
  26. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/personal/2026/10/20261004-options-first-7hcx.md +0 -0
  27. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/personal/2026/10/20261009-thesis-setup-fr6e.md +0 -0
  28. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/08/20260804-alex-profile-ujzd.md +0 -0
  29. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/08/20260804-reply-language-xdnc.md +0 -0
  30. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/08/20260812-ask-before-push-pf3d.md +0 -0
  31. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/08/20260820-tests-before-done-odzd.md +0 -0
  32. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/08/20260827-etl-orders-export-deadline-pft7.md +0 -0
  33. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/08/20260828-etl-dashboard-72ce.md +0 -0
  34. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/09/20260902-report-eta-is2j.md +0 -0
  35. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/09/20260908-shop-web-stripe-checkout-lgmx.md +0 -0
  36. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/09/20260910-shop-web-deploys-dn73.md +0 -0
  37. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/09/20260915-etl-parquet-partitioning-seh2.md +0 -0
  38. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/09/20260922-design-tokens-source-uvw7.md +0 -0
  39. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/10/20261002-shop-web-v3-freeze-5xtp.md +0 -0
  40. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo/work/2026/10/20261012-payments-runbook-4syw.md +0 -0
  41. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/demo.py +0 -0
  42. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/embed_worker.py +0 -0
  43. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/frontmatter.py +0 -0
  44. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/gitsync.py +0 -0
  45. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/importers.py +0 -0
  46. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/lint.py +0 -0
  47. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/profiles.py +0 -0
  48. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/select.py +0 -0
  49. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/sessions.py +0 -0
  50. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/store.py +0 -0
  51. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/ui.py +0 -0
  52. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/update.py +0 -0
  53. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/util.py +0 -0
  54. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/index.html +0 -0
  55. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/api.js +0 -0
  56. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/avatar.js +0 -0
  57. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/filters.js +0 -0
  58. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/format.js +0 -0
  59. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/h.js +0 -0
  60. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/icons.js +0 -0
  61. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/markdown.js +0 -0
  62. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/search.js +0 -0
  63. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/insights.js +0 -0
  64. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/list.js +0 -0
  65. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/memory.js +0 -0
  66. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/shell.js +0 -0
  67. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/src/agent_memory/webui/js/views/ui.js +0 -0
  68. {agent_memory_git-0.1.10 → agent_memory_git-0.1.11}/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.10
3
+ Version: 0.1.11
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
@@ -58,8 +58,8 @@ Description-Content-Type: text/markdown
58
58
  - **Memory in git.** One Markdown file per memory in a git repository you own, with where it came
59
59
  from recorded by the hooks. Every change is a commit you can review or revert; add a private
60
60
  remote to sync machines, or keep it all on one. Here is [an example](https://github.com/Ki-Seki/agent-memory-data-demo).
61
- [Profiles](https://ki-seki.github.io/agent-memory/docs.html#profiles) keep work and personal
62
- memories in repositories of their own.
61
+ [Profiles](https://ki-seki.github.io/agent-memory/docs.html#profiles) keep separate sets of
62
+ memories in repositories of their own, and a switch reaches running sessions too.
63
63
  - **Nothing to run.** Two hooks and git. No service, no account; search by meaning runs on your CPU.
64
64
  - **A page to keep it tidy.** `mem ui` searches by words and by meaning, edits with undo, and shows
65
65
  what changed week by week.
@@ -37,8 +37,8 @@
37
37
  - **Memory in git.** One Markdown file per memory in a git repository you own, with where it came
38
38
  from recorded by the hooks. Every change is a commit you can review or revert; add a private
39
39
  remote to sync machines, or keep it all on one. Here is [an example](https://github.com/Ki-Seki/agent-memory-data-demo).
40
- [Profiles](https://ki-seki.github.io/agent-memory/docs.html#profiles) keep work and personal
41
- memories in repositories of their own.
40
+ [Profiles](https://ki-seki.github.io/agent-memory/docs.html#profiles) keep separate sets of
41
+ memories in repositories of their own, and a switch reaches running sessions too.
42
42
  - **Nothing to run.** Two hooks and git. No service, no account; search by meaning runs on your CPU.
43
43
  - **A page to keep it tidy.** `mem ui` searches by words and by meaning, edits with undo, and shows
44
44
  what changed week by week.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-memory-git"
3
- version = "0.1.10"
3
+ version = "0.1.11"
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.10"
3
+ version = "0.1.11"
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
  # Shared memory
2
2
 
3
- You have a persistent memory that Claude Code and Codex share. Every memory is one Markdown file in a git repository. At the start of each session a hook injects a block titled "Shared memory index": it lists the memories relevant to the current folder and repository, and gives the memories directory, this session's staging directory and the path of the `mem` command. If that block is missing, the memory system is not active in this session; ignore this section.
3
+ You have a persistent memory that Claude Code and Codex share. Every memory is one Markdown file in a git repository. At the start of each session a hook injects a block titled "Shared memory index": it lists the memories relevant to the current folder and repository, and gives the memories directory, this session's staging directory and the path of the `mem` command. If the active profile changes while the session runs, a new index arrives with your next message and replaces the earlier one: from then on use only its memories and paths. If that block is missing, the memory system is not active in this session; ignore this section.
4
4
 
5
5
  ## Reading
6
6
 
@@ -249,25 +249,36 @@ def _access_note():
249
249
  print(f"updated {' and '.join(tools)} so agents can reach every profile's folder")
250
250
 
251
251
 
252
+ SWITCH_NOTE = "they keep what they've already read; start a new session for a clean slate"
253
+
254
+
255
+ def _switched(name):
256
+ print(f"sessions now use {name}; running ones switch with their next message")
257
+ if config.load().get("switch_note", True):
258
+ print(SWITCH_NOTE)
259
+
260
+
252
261
  def cmd_profile(args):
253
262
  if profiles.locked():
254
263
  sys.exit("AGENT_MEMORY_DATA picks the data repository here, so profiles do not apply")
255
264
  try:
256
265
  if args.action == "use":
257
266
  pid = profiles.use(args.profile)
258
- print(f"new sessions now use {profiles.listing()[0][pid]['name']}")
267
+ _switched(profiles.listing()[0][pid]["name"])
259
268
  elif args.action == "add":
260
269
  pid, note = profiles.add(args.folder, remote=args.remote, switch=not args.keep)
261
270
  found, active = profiles.listing()
262
271
  print(note)
263
- print(f"added {found[pid]['name']}" + ("; new sessions now use it" if active == pid else ""))
272
+ print(f"added {found[pid]['name']}")
273
+ if active == pid:
274
+ _switched(found[pid]["name"])
264
275
  _access_note()
265
276
  elif args.action == "remove":
266
277
  found, _ = profiles.listing()
267
278
  pid = profiles.find(args.profile, found)
268
279
  active = profiles.remove(pid)
269
280
  print(f"removed {found[pid]['name']}; {profiles.short(found[pid]['data_dir'])} is left as it is")
270
- print(f"new sessions use {profiles.listing()[0][active]['name']}")
281
+ _switched(profiles.listing()[0][active]["name"])
271
282
  _access_note()
272
283
  else:
273
284
  for p in profiles.summary()["profiles"]:
@@ -387,7 +398,7 @@ def main(argv=None):
387
398
  sp.set_defaults(fn=cmd_profile)
388
399
 
389
400
  sp = sub.add_parser("hook", help=argparse.SUPPRESS)
390
- sp.add_argument("event", choices=["session-start", "stop", "session-end"])
401
+ sp.add_argument("event") # any name: an event from a newer install is logged and ignored, never refused
391
402
  sp.add_argument("--tool", required=True, choices=["claude-code", "codex"])
392
403
  sp.set_defaults(fn=cmd_hook)
393
404
 
@@ -0,0 +1,141 @@
1
+ """Hook entry points shared by Claude Code and Codex.
2
+
3
+ Both tools send a JSON payload on stdin with at least `session_id` and `cwd`,
4
+ and accept `hookSpecificOutput.additionalContext` from SessionStart hooks.
5
+ A hook must never break the host session, so every failure is logged and
6
+ swallowed.
7
+ """
8
+
9
+ import json
10
+ import secrets
11
+ import sys
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from . import capture, config, gitsync, profiles, select, store, workdir
17
+ from .context import detect
18
+ from .util import log, log_exc
19
+
20
+ PULL_INTERVAL = 60
21
+ SWITCHED = (
22
+ "The active memory profile changed while this session was running. This index replaces the one shown "
23
+ "earlier: from now on use only its memories directory, its staging directory and the memories it lists, "
24
+ "and set aside memories you read under the earlier one.\n\n"
25
+ )
26
+
27
+
28
+ def _payload():
29
+ try:
30
+ data = sys.stdin.read()
31
+ return json.loads(data) if data.strip() else {}
32
+ except (OSError, ValueError):
33
+ return {}
34
+
35
+
36
+ def _session_id(payload):
37
+ return str(payload.get("session_id") or f"nosession-{secrets.token_hex(4)}")
38
+
39
+
40
+ def session_start(payload, tool, data_dir):
41
+ sid = _session_id(payload)
42
+ ctx = detect(payload.get("cwd"))
43
+ sdir = capture.session_dir(data_dir, tool, sid)
44
+ sdir.mkdir(parents=True, exist_ok=True)
45
+ capture.write_session(
46
+ sdir,
47
+ {
48
+ "tool": tool,
49
+ "session_id": sid,
50
+ "session_title": payload.get("session_title"),
51
+ "model": payload.get("model"),
52
+ "transcript_path": payload.get("transcript_path"),
53
+ "ctx": ctx,
54
+ "shown_at": time.time(),
55
+ },
56
+ )
57
+
58
+ recovered = capture.recover_orphans(data_dir, keep=sdir)
59
+ stamp = Path(data_dir) / ".cache" / "last-pull"
60
+ if gitsync.has_remote(data_dir):
61
+ last = stamp.stat().st_mtime if stamp.exists() else 0
62
+ if recovered or time.time() - last > PULL_INTERVAL:
63
+ stamp.parent.mkdir(parents=True, exist_ok=True)
64
+ stamp.touch()
65
+ gitsync.spawn_sync(data_dir, push=recovered)
66
+
67
+ paths = {
68
+ "memories": str(store.memories_dir(data_dir)),
69
+ "staging": str(sdir),
70
+ "mem": str(config.BIN),
71
+ }
72
+ return select.render(
73
+ store.load_all(data_dir),
74
+ ctx,
75
+ paths,
76
+ throwaway=workdir.is_throwaway(ctx.get("cwd")),
77
+ profile=profiles.active_name(),
78
+ )
79
+
80
+
81
+ def _shown(sdir):
82
+ """When this staging folder's profile last gave the session its index; 0 when it never did."""
83
+ info = capture.read_session(sdir)
84
+ if isinstance(info.get("shown_at"), (int, float)):
85
+ return info["shown_at"]
86
+ try: # sessions that started before shown_at existed got their index when the folder was made
87
+ return datetime.fromisoformat(info["started_at"]).timestamp()
88
+ except (KeyError, TypeError, ValueError):
89
+ return 0
90
+
91
+
92
+ def prompt(payload, tool, data_dir):
93
+ """The active profile's index when the session last got another profile's; None when nothing changed.
94
+ Running sessions follow the active profile, so a switch reaches them with their next message."""
95
+ sid = payload.get("session_id")
96
+ if not sid:
97
+ return None
98
+ seen = [(_shown(sdir), d) for d in profiles.dirs() if (sdir := capture.session_dir(d, tool, str(sid))).is_dir()]
99
+ if not seen or max(seen, key=lambda s: s[0])[1] == data_dir:
100
+ return None # no index yet (SessionStart gives it), or this profile's is the latest
101
+ return SWITCHED + session_start(payload, tool, data_dir)
102
+
103
+
104
+ def flush(payload, tool, final):
105
+ """Commit what the session staged, into each profile it staged in: a switch can leave some in the one
106
+ before, written while the session still had that profile's index."""
107
+ sid = _session_id(payload)
108
+ for data_dir in profiles.dirs():
109
+ sdir = capture.session_dir(data_dir, tool, sid)
110
+ if not sdir.is_dir() or not (data_dir / ".git").exists():
111
+ continue
112
+ if payload.get("transcript_path"):
113
+ capture.write_session(sdir, {"transcript_path": payload["transcript_path"]})
114
+ committed = capture.flush(data_dir, sdir, final=final, lock_timeout=1.0 if final else 5.0)
115
+ if committed and gitsync.has_remote(data_dir):
116
+ gitsync.spawn_sync(data_dir, push=True)
117
+
118
+
119
+ def _context(event, text):
120
+ return json.dumps({"hookSpecificOutput": {"hookEventName": event, "additionalContext": text}}, ensure_ascii=False)
121
+
122
+
123
+ def main(event, tool):
124
+ payload = _payload()
125
+ data_dir = config.data_dir() # the active profile's: running sessions follow it too
126
+ if not data_dir or not (data_dir / ".git").exists():
127
+ return 0
128
+ try:
129
+ if event == "session-start":
130
+ print(_context("SessionStart", session_start(payload, tool, data_dir)))
131
+ elif event == "prompt":
132
+ text = prompt(payload, tool, data_dir)
133
+ if text:
134
+ print(_context("UserPromptSubmit", text))
135
+ elif event in ("stop", "session-end"):
136
+ flush(payload, tool, final=event == "session-end")
137
+ else:
138
+ log(data_dir, f"unknown hook event {event!r}")
139
+ except Exception:
140
+ log_exc(data_dir, f"hook {event} ({tool})")
141
+ return 0
@@ -35,6 +35,7 @@ PATH_LINE = 'export PATH="$HOME/.local/bin:$PATH"'
35
35
 
36
36
  HOOK_EVENTS = [
37
37
  ("SessionStart", "session-start", 15),
38
+ ("UserPromptSubmit", "prompt", 10),
38
39
  ("Stop", "stop", 15),
39
40
  ("SessionEnd", "session-end", 3),
40
41
  ] # Codex clamps SessionEnd to 3s
@@ -44,7 +45,9 @@ HOOK_EVENTS = [
44
45
 
45
46
 
46
47
  def hook_command(event, tool):
47
- return " ".join(shlex.quote(a) for a in config.mem_command()) + f" hook {event} --tool {tool}"
48
+ command = " ".join(shlex.quote(a) for a in config.mem_command()) + f" hook {event} --tool {tool}"
49
+ # a mem from before this event exits 2 on it, and exit 2 from UserPromptSubmit blocks the prompt
50
+ return command + " || true" if event == "prompt" else command
48
51
 
49
52
 
50
53
  def _is_ours(group):
@@ -318,6 +321,7 @@ def _codex_plan(plan, data_dirs, state):
318
321
  handler = {"type": "command", "command": hook_command(name, "codex"), "timeout": timeout}
319
322
  if event == "SessionStart":
320
323
  handler["statusMessage"] = "Loading shared memory"
324
+ if event in ("SessionStart", "UserPromptSubmit"):
321
325
  handler["additionalContextLimit"] = 0 # keep the index inline, never spill to disk
322
326
  groups.append({"hooks": [handler]})
323
327
  hooks[event] = groups
@@ -173,6 +173,7 @@ class UiApp:
173
173
  "remoteUrl": re.sub(r"(://)[^/@]+@", r"\1", url) or None, # without any credentials in it
174
174
  "sync": self.sync_status(),
175
175
  "keywordStopwords": config.load().get("keyword_stopwords", []),
176
+ "switchNote": config.load().get("switch_note", True),
176
177
  "model": {
177
178
  "name": ui.MODEL["name"],
178
179
  "ready": ui.model_ready(),
@@ -185,7 +186,7 @@ class UiApp:
185
186
  }
186
187
 
187
188
  def set_settings(self, p):
188
- """Change keyword_stopwords, check_updates, or both."""
189
+ """Change keyword_stopwords, check_updates, switch_note, or several."""
189
190
  cfg = config.load()
190
191
  if "keyword_stopwords" in p:
191
192
  words = p["keyword_stopwords"]
@@ -196,6 +197,10 @@ class UiApp:
196
197
  if not isinstance(p["check_updates"], bool):
197
198
  raise ApiError(400, "check_updates must be true or false")
198
199
  cfg["check_updates"] = p["check_updates"]
200
+ if "switch_note" in p:
201
+ if not isinstance(p["switch_note"], bool):
202
+ raise ApiError(400, "switch_note must be true or false")
203
+ cfg["switch_note"] = p["switch_note"]
199
204
  config.save(cfg)
200
205
  if "keyword_stopwords" in p:
201
206
  self.refresh() # keywords are chosen when the state is built
@@ -617,6 +617,19 @@ button.hero-meta-item:hover .copy-hint, button.hero-meta-item:focus-visible .cop
617
617
  .dialog::backdrop { background: rgb(0 0 0 / .3); }
618
618
  .dialog-title { font-size: 15px; font-weight: 600; }
619
619
  .dialog-body { margin-top: 8px; font-size: 13.5px; white-space: pre-line; color: var(--ink-2); }
620
+ .sline { display: grid; grid-template-columns: auto auto; justify-content: start; margin-top: 14px; font-size: 12px; color: var(--ink-3); }
621
+ .sline > span { padding-right: 14px; }
622
+ .sline > .next:not(.sline-name) { padding-left: 14px; border-left: 1.5px dashed var(--ink-3); }
623
+ .sline > .sline-name.next { padding-left: 15.5px; }
624
+ .sline > .sline-when { padding-bottom: 4px; }
625
+ .sline-msgs { display: flex; align-items: center; gap: 5px; height: 22px; }
626
+ .sline-msgs i { width: 20px; height: 11px; border-radius: 6px; overflow: hidden; box-shadow: 0 0 0 0.5px var(--line); }
627
+ .sline-msgs i svg { display: block; width: 100%; height: 100%; }
628
+ .sline-msgs b { font-weight: 400; margin-left: 2px; }
629
+ .sline-name { display: flex; align-items: center; gap: 6px; margin-top: 6px; color: var(--ink-2); }
630
+ .sline-name .pavatar { width: 16px; height: 16px; }
631
+ .dialog-check { display: flex; align-items: center; gap: 8px; margin-top: 14px; padding: 0; border: 0; background: none; color: var(--ink-2); font: inherit; font-size: 13px; cursor: pointer; }
632
+ .dialog-check[aria-checked="true"] .check { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); }
620
633
  .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
621
634
  .dialog.wide { width: min(1040px, calc(100vw - 32px)); max-height: min(760px, calc(100vh - 48px)); padding: 0; }
622
635
  .dialog.wide[open] { display: flex; flex-direction: column; }
@@ -102,6 +102,7 @@ const ACTIONS = {
102
102
  removePicked: () => app.remove([...app.picked]),
103
103
  undoToast: (id) => { const t = app.toasts.find((x) => String(x.id) === id); if (t) { app.dismiss(t.id); app.undo(t.undo); } },
104
104
  answer: (v) => app.answer(v === "yes"),
105
+ askCheck: () => { if (app.asking) { app.asking.checked = !app.asking.checked; app.changed(); } },
105
106
  compare: (i) => { app.comparing = duplicatePairs()[Number(i)] ?? null; app.changed(); },
106
107
  closeCompare: () => { app.comparing = null; app.changed(); },
107
108
  removeCompared: (id) => { app.comparing = null; app.remove([id]); },
@@ -24,6 +24,9 @@ const SORTERS = {
24
24
  az: (a, b) => a.description.localeCompare(b.description),
25
25
  };
26
26
 
27
+ /** What switching profiles means for the sessions that are running. */
28
+ const SWITCH_NOTE = "What a session has already read stays with it. Start a new session for a clean slate.";
29
+
27
30
  class App {
28
31
  data = null;
29
32
  bootError = "";
@@ -385,13 +388,26 @@ class App {
385
388
  if (this.profiles?.active === id) return this.pform && this.cancelProfile();
386
389
  if (!(await this.#leaveEdit())) return;
387
390
  const to = this.profiles?.profiles.find((p) => p.id === id);
391
+ if (!(await this.#switchOk(to?.name ?? id))) return;
388
392
  this.switching = id; this.changed();
389
393
  try {
390
394
  this.#took(await api("/api/profiles/use", { id }));
391
- this.toast(`Switched to ${to?.name ?? id}${this.profiles?.demo ? "" : "; new sessions use it too"}`);
395
+ this.toast(`Switched to ${to?.name ?? id}`);
392
396
  } catch (e) { this.toast(e.message, { error: true }); }
393
397
  finally { this.switching = ""; this.changed(); }
394
398
  }
399
+ /** Whether to go on with a switch, once told what it means for sessions that are running; until told not to say. */
400
+ async #switchOk(name) {
401
+ if (this.profiles?.demo || this.settings.s?.switchNote === false) return true;
402
+ const from = this.profiles?.profiles.find((p) => p.active)?.name;
403
+ const q = { title: `Switch to ${name}?`, timeline: from && { from, to: name }, body: SWITCH_NOTE, action: "Switch", check: "Don't show this again" };
404
+ if (!(await this.ask(q))) return false;
405
+ if (q.checked) {
406
+ try { this.settings.s = await api("/api/settings", { switch_note: false }); }
407
+ catch (e) { this.toast(e.message, { error: true }); }
408
+ }
409
+ return true;
410
+ }
395
411
  /** After the server answers a profile change: its list, and the memories of the profile it now shows. */
396
412
  async #took(r) {
397
413
  const before = this.profiles?.active;
@@ -423,20 +439,20 @@ class App {
423
439
  }
424
440
  async saveProfile() {
425
441
  const f = this.pform;
426
- if (!f || f.busy) return;
442
+ if (!f || f.busy || !(await this.#switchOk(f.name))) return;
427
443
  f.busy = true; f.error = ""; this.changed();
428
444
  try {
429
445
  const r = await api("/api/profiles/add", { name: f.name, folder: f.folder, remote: f.remote.trim() || null });
430
446
  const made = r.profiles.find((p) => p.id === r.id)?.name ?? f.name;
431
447
  await this.#took(r);
432
- this.toast(r.demo || r.active !== r.id ? `${made}: ${r.message}` : `${made}: ${r.message} New sessions use it.`);
448
+ this.toast(`${made}: ${r.message}`);
433
449
  } catch (e) { f.busy = false; f.error = e.message; this.changed(); }
434
450
  }
435
451
  /** Forget the profile the page shows, after asking; its folder and remote stay. */
436
452
  async removeProfile() {
437
453
  const p = this.profiles?.profiles.find((x) => x.active);
438
454
  if (!p) return;
439
- const ok = await this.ask({ title: `Remove the ${p.name} profile?`, body: `agent-memory stops using it. The folder ${p.dataDir} and its memories stay on disk, and any remote is not touched; add the folder again later to bring it back.`, action: "Remove", danger: true });
455
+ const ok = await this.ask({ title: `Remove the ${p.name} profile?`, body: `agent-memory stops using it. The folder ${p.dataDir} and its memories stay on disk, and any remote is not touched; add the folder again later to bring it back. Running sessions move to the profile left active with their next message.`, action: "Remove", danger: true });
440
456
  if (!ok) return;
441
457
  try { await this.#took(await api("/api/profiles/remove", { id: p.id })); this.toast(`Removed ${p.name}`); }
442
458
  catch (e) { this.toast(e.message, { error: true }); }
@@ -453,7 +469,8 @@ class App {
453
469
  this.changed();
454
470
  }
455
471
  dismiss(id) { this.toasts = this.toasts.filter((t) => t.id !== id); this.changed(); }
456
- ask(q) { return new Promise((resolve) => { this.asking = { ...q, resolve }; this.changed(); }); }
472
+ /** Ask in a dialog; resolves true or false, and with `check` set leaves the box's state in q.checked. */
473
+ ask(q) { return new Promise((resolve) => { this.asking = Object.assign(q, { resolve }); this.changed(); }); }
457
474
  answer(ok) { const a = this.asking; this.asking = null; a?.resolve(ok); this.changed(); }
458
475
  }
459
476
 
@@ -4,6 +4,7 @@ import { html } from "../h.js";
4
4
  import { icon } from "../icons.js";
5
5
  import { parse, renderBlocks } from "../markdown.js";
6
6
  import { app } from "../store.js";
7
+ import { switchLine } from "./profiles.js";
7
8
  import { typeDot } from "./ui.js";
8
9
 
9
10
  export function selectionBar() {
@@ -29,7 +30,9 @@ export function confirmDialog() {
29
30
  const a = app.asking;
30
31
  if (!a) return "";
31
32
  return html`<h2 class="dialog-title">${a.title}</h2>
33
+ ${a.timeline ? switchLine(a.timeline.from, a.timeline.to) : ""}
32
34
  ${a.body ? html`<p class="dialog-body">${a.body}</p>` : ""}
35
+ ${a.check ? html`<button type="button" role="checkbox" class="dialog-check" aria-checked="${String(!!a.checked)}" data-act="askCheck"><span class="check" aria-hidden="true">${a.checked ? icon("check", "", 3) : ""}</span>${a.check}</button>` : ""}
33
36
  <div class="dialog-actions">
34
37
  <button type="button" class="btn" data-act="answer" data-arg="no">Cancel</button>
35
38
  <button type="button" class="${a.danger ? "btn danger" : "btn primary"}" data-act="answer" data-arg="yes" autofocus>${a.action}</button>
@@ -1,7 +1,7 @@
1
1
  // Profiles: separate memory repositories, each its own folder and remote. Settings opens on the active one, with
2
2
  // the others in its foot; the rail's last button is the active profile's picture, which leads there. A profile's name is picked for it when it is made, and its picture is
3
3
  // drawn from the name.
4
- import { avatar } from "../avatar.js";
4
+ import { avatar, glow } from "../avatar.js";
5
5
  import { attrs, html } from "../h.js";
6
6
  import { icon } from "../icons.js";
7
7
  import { app } from "../store.js";
@@ -43,6 +43,16 @@ export function newProfile() {
43
43
  </form>`;
44
44
  }
45
45
 
46
+ /** An open session as a row of messages: those so far in `from`'s colours, the next ones in `to`'s. */
47
+ export function switchLine(from, to) {
48
+ const msgs = (name, n) => Array.from({ length: n }, () => html`<i>${glow(name)}</i>`);
49
+ return html`<div class="sline" role="img"${attrs({ "aria-label": `An open session: its messages so far used ${from}; from its next message it uses ${to}` })}>
50
+ <span class="sline-when">So far</span><span class="sline-when next">Next message</span>
51
+ <span class="sline-msgs">${msgs(from, 3)}</span><span class="sline-msgs next">${msgs(to, 2)}<b>…</b></span>
52
+ <span class="sline-name">${avatar(from)}${from}</span><span class="sline-name next">${avatar(to)}${to}</span>
53
+ </div>`;
54
+ }
55
+
46
56
  // ---------------------------------------------------------------- the rail
47
57
 
48
58
  /** The rail's last button: the active profile's picture, which opens Settings, where profiles are switched. Without
@@ -52,7 +52,7 @@ function hero() {
52
52
  const s = app.settings.s, st = app.settings, p = active();
53
53
  const name = p?.name ?? s?.name ?? "agent-memory";
54
54
  const count = `${app.M.length} ${app.M.length === 1 ? "memory" : "memories"}`;
55
- const role = !p ? "" : app.profiles.demo ? "The profile the page shows" : "New sessions use this profile";
55
+ const role = !p ? "" : app.profiles.demo ? "The profile the page shows" : "Sessions use this profile";
56
56
  const note = st.remoteNote ? html`<p${attrs({ class: cx("hero-note", !st.remoteNote.ok && "bad"), role: "status" })}>${st.remoteNote.text}</p>` : "";
57
57
  return html`<section class="hero" data-key="hero" aria-label="${p ? "The open profile" : "The data repository"}">
58
58
  <div class="hero-glow" aria-hidden="true">${glow(name)}</div>
@@ -1,122 +0,0 @@
1
- """Hook entry points shared by Claude Code and Codex.
2
-
3
- Both tools send a JSON payload on stdin with at least `session_id` and `cwd`,
4
- and accept `hookSpecificOutput.additionalContext` from SessionStart hooks.
5
- A hook must never break the host session, so every failure is logged and
6
- swallowed.
7
- """
8
-
9
- import json
10
- import secrets
11
- import sys
12
- import time
13
- from pathlib import Path
14
-
15
- from . import capture, config, gitsync, profiles, select, store, workdir
16
- from .context import detect
17
- from .util import log, log_exc
18
-
19
- PULL_INTERVAL = 60
20
-
21
-
22
- def _payload():
23
- try:
24
- data = sys.stdin.read()
25
- return json.loads(data) if data.strip() else {}
26
- except (OSError, ValueError):
27
- return {}
28
-
29
-
30
- def _session_id(payload):
31
- return str(payload.get("session_id") or f"nosession-{secrets.token_hex(4)}")
32
-
33
-
34
- def session_start(payload, tool, data_dir):
35
- sid = _session_id(payload)
36
- ctx = detect(payload.get("cwd"))
37
- sdir = capture.session_dir(data_dir, tool, sid)
38
- sdir.mkdir(parents=True, exist_ok=True)
39
- capture.write_session(
40
- sdir,
41
- {
42
- "tool": tool,
43
- "session_id": sid,
44
- "session_title": payload.get("session_title"),
45
- "model": payload.get("model"),
46
- "transcript_path": payload.get("transcript_path"),
47
- "ctx": ctx,
48
- },
49
- )
50
-
51
- recovered = capture.recover_orphans(data_dir, keep=sdir)
52
- stamp = Path(data_dir) / ".cache" / "last-pull"
53
- if gitsync.has_remote(data_dir):
54
- last = stamp.stat().st_mtime if stamp.exists() else 0
55
- if recovered or time.time() - last > PULL_INTERVAL:
56
- stamp.parent.mkdir(parents=True, exist_ok=True)
57
- stamp.touch()
58
- gitsync.spawn_sync(data_dir, push=recovered)
59
-
60
- paths = {
61
- "memories": str(store.memories_dir(data_dir)),
62
- "staging": str(sdir),
63
- "mem": str(config.BIN),
64
- }
65
- return select.render(
66
- store.load_all(data_dir),
67
- ctx,
68
- paths,
69
- throwaway=workdir.is_throwaway(ctx.get("cwd")),
70
- profile=profiles.active_name(),
71
- )
72
-
73
-
74
- def flush(payload, tool, data_dir, final):
75
- sdir = capture.session_dir(data_dir, tool, _session_id(payload))
76
- if not sdir.is_dir():
77
- return
78
- if payload.get("transcript_path"):
79
- capture.write_session(sdir, {"transcript_path": payload["transcript_path"]})
80
- committed = capture.flush(data_dir, sdir, final=final, lock_timeout=1.0 if final else 5.0)
81
- if committed and gitsync.has_remote(data_dir):
82
- gitsync.spawn_sync(data_dir, push=True)
83
-
84
-
85
- def _home(payload, tool, active):
86
- """The data repository holding this session's staging folder: the profile that was active when the
87
- session started, which need not be the active one now."""
88
- sid = payload.get("session_id")
89
- for data_dir in profiles.dirs() if sid else []:
90
- if capture.session_dir(data_dir, tool, str(sid)).is_dir():
91
- return data_dir
92
- return active
93
-
94
-
95
- def main(event, tool):
96
- payload = _payload()
97
- data_dir = config.data_dir()
98
- if data_dir and event != "session-start":
99
- data_dir = _home(payload, tool, data_dir)
100
- if not data_dir or not (data_dir / ".git").exists():
101
- return 0
102
- try:
103
- if event == "session-start":
104
- text = session_start(payload, tool, data_dir)
105
- print(
106
- json.dumps(
107
- {
108
- "hookSpecificOutput": {
109
- "hookEventName": "SessionStart",
110
- "additionalContext": text,
111
- }
112
- },
113
- ensure_ascii=False,
114
- )
115
- )
116
- elif event in ("stop", "session-end"):
117
- flush(payload, tool, data_dir, final=event == "session-end")
118
- else:
119
- log(data_dir, f"unknown hook event {event!r}")
120
- except Exception:
121
- log_exc(data_dir, f"hook {event} ({tool})")
122
- return 0