agent-memory-git 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ """Where a session is running: folder, host and git identity."""
2
+
3
+ import os
4
+ import re
5
+ import socket
6
+ import subprocess
7
+
8
+ _GIT_ENV = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
9
+
10
+
11
+ def _git(cwd, *args):
12
+ try:
13
+ r = subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True,
14
+ timeout=3, env=_GIT_ENV)
15
+ except (OSError, subprocess.SubprocessError):
16
+ return ""
17
+ return r.stdout.strip() if r.returncode == 0 else ""
18
+
19
+
20
+ def normalize_remote(url):
21
+ """Reduce a git remote URL to host/owner/repo, dropping credentials."""
22
+ url = (url or "").strip()
23
+ if not url:
24
+ return ""
25
+ m = re.match(r"^[\w.-]+@([^:/]+):(.+)$", url)
26
+ if not m:
27
+ m = re.match(r"^[a-z][a-z0-9+.-]*://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+)$", url, re.I)
28
+ if not m:
29
+ return url.lower()
30
+ host, path = m.groups()
31
+ path = path.strip("/")
32
+ if path.endswith(".git"):
33
+ path = path[:-4]
34
+ return f"{host}/{path}".lower()
35
+
36
+
37
+ def hostname():
38
+ h = socket.gethostname()
39
+ return h[:-6] if h.endswith(".local") else h
40
+
41
+
42
+ def detect(cwd=None):
43
+ cwd = os.path.realpath(os.path.expanduser(cwd or os.getcwd()))
44
+ ctx = {"host": hostname(), "cwd": cwd}
45
+ if not os.path.isdir(cwd):
46
+ return ctx
47
+ root = _git(cwd, "rev-parse", "--show-toplevel")
48
+ if not root:
49
+ return ctx
50
+ ctx["git_root"] = os.path.realpath(root)
51
+ remote = _git(cwd, "remote", "get-url", "origin")
52
+ if not remote:
53
+ names = _git(cwd, "remote").split()
54
+ remote = _git(cwd, "remote", "get-url", names[0]) if names else ""
55
+ if remote:
56
+ ctx["git_remote"] = normalize_remote(remote)
57
+ branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
58
+ if branch and branch != "HEAD":
59
+ ctx["git_branch"] = branch
60
+ commit = _git(cwd, "rev-parse", "--short", "HEAD")
61
+ if commit:
62
+ ctx["git_commit"] = commit
63
+ return ctx
64
+
65
+
66
+ def describe(ctx):
67
+ parts = [f"cwd={ctx.get('cwd', '?')}"]
68
+ if ctx.get("git_remote"):
69
+ parts.append(f"remote={ctx['git_remote']}")
70
+ elif ctx.get("git_root"):
71
+ parts.append(f"git_root={ctx['git_root']}")
72
+ parts.append(f"host={ctx.get('host', '?')}")
73
+ return ", ".join(parts)
@@ -0,0 +1,90 @@
1
+ """Embed texts with a local ONNX embedding model. Runs in its own environment.
2
+
3
+ `mem ui` starts this with `uv run --with onnxruntime --with tokenizers`, so
4
+ neither package has to be installed where the hooks run. It reads a JSON job
5
+ on stdin and writes an .npy matrix of L2-normalized vectors:
6
+
7
+ {"model_dir": ..., "texts": [...], "out": "vectors.npy", "max_tokens": 512}
8
+
9
+ With `--serve MODEL_DIR` it stays up and answers one {"text": ...} per line
10
+ with {"vector": [...]}, for search queries.
11
+
12
+ The model directory holds model_quantized.onnx (or model.onnx) and a
13
+ Hugging Face tokenizer.json. Pooling follows the model family: last token for
14
+ decoder models such as Qwen3-Embedding, attention-masked mean otherwise.
15
+ """
16
+
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import onnxruntime as ort
23
+ from tokenizers import Tokenizer
24
+
25
+
26
+ def load(model_dir):
27
+ model_dir = Path(model_dir)
28
+ onnx = next(p for p in (model_dir / "model_quantized.onnx", model_dir / "model.onnx") if p.exists())
29
+ opts = ort.SessionOptions()
30
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
31
+ session = ort.InferenceSession(str(onnx), opts, providers=["CPUExecutionProvider"])
32
+ tok = Tokenizer.from_file(str(model_dir / "tokenizer.json"))
33
+ config = json.loads((model_dir / "config.json").read_text())
34
+ return session, tok, config
35
+
36
+
37
+ def embed_one(session, tok, config, text, max_tokens):
38
+ ids = tok.encode(text).ids[: max_tokens - 1]
39
+ decoder = config.get("model_type", "").startswith("qwen")
40
+ eos = config.get("eos_token_id")
41
+ if decoder and isinstance(eos, int) and (not ids or ids[-1] != eos):
42
+ ids.append(eos) # last-token pooling reads the end-of-text position
43
+ n = len(ids)
44
+ feed = {}
45
+ for inp in session.get_inputs():
46
+ name = inp.name
47
+ if name == "input_ids":
48
+ feed[name] = np.array([ids], dtype=np.int64)
49
+ elif name == "attention_mask":
50
+ feed[name] = np.ones((1, n), dtype=np.int64)
51
+ elif name == "position_ids":
52
+ feed[name] = np.arange(n, dtype=np.int64)[None, :]
53
+ elif name == "token_type_ids":
54
+ feed[name] = np.zeros((1, n), dtype=np.int64)
55
+ elif name.startswith("past_key_values"):
56
+ shape = [1 if d == "batch_size" else 0 if isinstance(d, str) else d for d in inp.shape]
57
+ feed[name] = np.zeros(shape, dtype=np.float16 if "float16" in inp.type else np.float32)
58
+ else:
59
+ raise SystemExit(f"unsupported model input {name}")
60
+ hidden = session.run(None, feed)[0][0] # (tokens, dim)
61
+ vec = hidden[-1] if decoder else hidden.mean(axis=0)
62
+ return vec / (np.linalg.norm(vec) or 1.0)
63
+
64
+
65
+ def serve(model_dir):
66
+ """One JSON request per line, {"text": ...} -> {"vector": [...]}, until stdin closes."""
67
+ session, tok, config = load(model_dir)
68
+ for line in sys.stdin:
69
+ text = json.loads(line)["text"]
70
+ vec = embed_one(session, tok, config, text, 256)
71
+ sys.stdout.write(json.dumps({"vector": [round(float(v), 6) for v in vec]}) + "\n")
72
+ sys.stdout.flush()
73
+
74
+
75
+ def main():
76
+ if len(sys.argv) == 3 and sys.argv[1] == "--serve":
77
+ return serve(sys.argv[2])
78
+ job = json.load(sys.stdin)
79
+ session, tok, config = load(job["model_dir"])
80
+ texts = job["texts"]
81
+ out = []
82
+ for i, text in enumerate(texts, 1):
83
+ out.append(embed_one(session, tok, config, text, job.get("max_tokens", 512)))
84
+ if i % 10 == 0 or i == len(texts):
85
+ print(f"embedded {i}/{len(texts)}", file=sys.stderr, flush=True)
86
+ np.save(job["out"], np.array(out, dtype=np.float32))
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,146 @@
1
+ """Minimal YAML-subset frontmatter parser and serializer.
2
+
3
+ Memory files only need scalars, quoted strings, flow lists, block lists and
4
+ one level of nested mappings, so this avoids a PyYAML dependency.
5
+ """
6
+
7
+ import json
8
+ import re
9
+
10
+ FENCE = "---"
11
+
12
+
13
+ def split(text):
14
+ """Return (meta, body). A file without frontmatter yields ({}, text)."""
15
+ lines = text.split("\n")
16
+ if not lines or lines[0].strip() != FENCE:
17
+ return {}, text
18
+ for i in range(1, len(lines)):
19
+ if lines[i].strip() == FENCE:
20
+ meta = parse("\n".join(lines[1:i]))
21
+ body = "\n".join(lines[i + 1:]).lstrip("\n")
22
+ return meta, body
23
+ return {}, text
24
+
25
+
26
+ def join(meta, body):
27
+ return f"{FENCE}\n{dump(meta)}{FENCE}\n{body.rstrip()}\n"
28
+
29
+
30
+ def parse(block):
31
+ lines = [line.rstrip() for line in block.split("\n")
32
+ if line.strip() and not line.lstrip().startswith("#")]
33
+ if not lines:
34
+ return {}
35
+ value, _ = _parse_map(lines, 0, _indent(lines[0]))
36
+ return value
37
+
38
+
39
+ def _indent(line):
40
+ return len(line) - len(line.lstrip(" "))
41
+
42
+
43
+ def _parse_map(lines, i, indent):
44
+ out = {}
45
+ while i < len(lines):
46
+ line = lines[i]
47
+ ind = _indent(line)
48
+ if ind < indent:
49
+ break
50
+ stripped = line.strip()
51
+ if ind > indent or stripped.startswith("- "):
52
+ i += 1
53
+ continue
54
+ key, sep, rest = stripped.partition(":")
55
+ i += 1
56
+ if not sep:
57
+ continue
58
+ key, rest = key.strip(), rest.strip()
59
+ if rest:
60
+ out[key] = _scalar(rest)
61
+ elif i < len(lines) and _indent(lines[i]) > indent:
62
+ child = _indent(lines[i])
63
+ if lines[i].strip().startswith("- "):
64
+ out[key], i = _parse_list(lines, i, child)
65
+ else:
66
+ out[key], i = _parse_map(lines, i, child)
67
+ else:
68
+ out[key] = ""
69
+ return out, i
70
+
71
+
72
+ def _parse_list(lines, i, indent):
73
+ out = []
74
+ while i < len(lines) and _indent(lines[i]) >= indent:
75
+ stripped = lines[i].strip()
76
+ if _indent(lines[i]) == indent and stripped.startswith("- "):
77
+ out.append(_scalar(stripped[2:].strip()))
78
+ i += 1
79
+ return out, i
80
+
81
+
82
+ def _scalar(s):
83
+ if s.startswith('"'):
84
+ try:
85
+ return json.loads(s)
86
+ except ValueError:
87
+ return s.strip('"')
88
+ if s.startswith("'") and s.endswith("'") and len(s) >= 2:
89
+ return s[1:-1].replace("''", "'")
90
+ if s.startswith("[") and s.endswith("]"):
91
+ try:
92
+ value = json.loads(s)
93
+ if isinstance(value, list):
94
+ return value
95
+ except ValueError:
96
+ pass
97
+ inner = s[1:-1].strip()
98
+ return [_scalar(p.strip()) for p in inner.split(",") if p.strip()] if inner else []
99
+ low = s.lower()
100
+ if low == "true":
101
+ return True
102
+ if low == "false":
103
+ return False
104
+ if low in ("null", "~"):
105
+ return None
106
+ return s
107
+
108
+
109
+ _RESERVED = {"true", "false", "null", "yes", "no", "on", "off", "~"}
110
+
111
+
112
+ def _needs_quote(s):
113
+ if s == "" or s != s.strip() or "\n" in s:
114
+ return True
115
+ if s.lower() in _RESERVED:
116
+ return True
117
+ if s[0] in "-?:,[]{}#&*!|>'\"%@`":
118
+ return True
119
+ if ": " in s or " #" in s or s.endswith(":"):
120
+ return True
121
+ return bool(re.fullmatch(r"[-+]?\d+(\.\d+)?", s))
122
+
123
+
124
+ def _emit(value):
125
+ if value is None:
126
+ return "null"
127
+ if isinstance(value, bool):
128
+ return "true" if value else "false"
129
+ if isinstance(value, (int, float)):
130
+ return str(value)
131
+ if isinstance(value, (list, tuple)):
132
+ return "[" + ", ".join(_emit(v) for v in value) + "]"
133
+ s = str(value)
134
+ return json.dumps(s, ensure_ascii=False) if _needs_quote(s) else s
135
+
136
+
137
+ def dump(meta):
138
+ out = []
139
+ for key, value in meta.items():
140
+ if isinstance(value, dict):
141
+ out.append(f"{key}:")
142
+ for k, v in value.items():
143
+ out.append(f" {k}: {_emit(v)}")
144
+ else:
145
+ out.append(f"{key}: {_emit(value)}")
146
+ return "\n".join(out) + "\n"
@@ -0,0 +1,119 @@
1
+ """Git operations on the data repository, serialized by a file lock."""
2
+
3
+ import fcntl
4
+ import os
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from .config import mem_command
10
+ from .util import log
11
+
12
+ _ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
13
+
14
+
15
+ class Lock:
16
+ """Exclusive lock shared by every hook and CLI process on this machine."""
17
+
18
+ def __init__(self, data_dir, timeout=10.0):
19
+ self.path = Path(data_dir) / ".cache" / "lock"
20
+ self.timeout = timeout
21
+ self.fd = None
22
+
23
+ def __enter__(self):
24
+ self.path.parent.mkdir(parents=True, exist_ok=True)
25
+ self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
26
+ deadline = time.monotonic() + self.timeout
27
+ while True:
28
+ try:
29
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
30
+ return True
31
+ except BlockingIOError:
32
+ if time.monotonic() >= deadline:
33
+ os.close(self.fd)
34
+ self.fd = None
35
+ return False
36
+ time.sleep(0.05)
37
+
38
+ def __exit__(self, *exc):
39
+ if self.fd is not None:
40
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
41
+ os.close(self.fd)
42
+ self.fd = None
43
+
44
+
45
+ def git(data_dir, *args, timeout=30):
46
+ return subprocess.run(["git", "-C", str(data_dir), *args], capture_output=True,
47
+ text=True, timeout=timeout, env=_ENV)
48
+
49
+
50
+ def has_remote(data_dir):
51
+ # A system gitconfig can declare remote.origin.* without a URL, so look for URLs.
52
+ r = git(data_dir, "config", "--get-regexp", r"^remote\..*\.url$", timeout=5)
53
+ return r.returncode == 0 and bool(r.stdout.strip())
54
+
55
+
56
+ def commit(data_dir, message):
57
+ """Stage everything under memories/ and commit. Returns True if a commit was made."""
58
+ git(data_dir, "add", "-A", "--", "memories")
59
+ if git(data_dir, "diff", "--cached", "--quiet").returncode == 0:
60
+ return False
61
+ r = git(data_dir, "-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "-m", message)
62
+ if r.returncode != 0:
63
+ log(data_dir, f"commit failed: {r.stderr.strip()}")
64
+ return False
65
+ return True
66
+
67
+
68
+ def _rebasing(data_dir):
69
+ git_dir = Path(data_dir) / ".git"
70
+ return (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists()
71
+
72
+
73
+ def sync(data_dir, push=True):
74
+ """Fetch, rebase onto upstream and optionally push.
75
+
76
+ Only the local rebase holds the lock; network round trips do not, so hooks
77
+ that commit are never stuck behind a slow fetch or push.
78
+ """
79
+ if not has_remote(data_dir):
80
+ return True
81
+ r = git(data_dir, "fetch", "-q", timeout=60)
82
+ if r.returncode != 0:
83
+ log(data_dir, f"fetch failed: {r.stderr.strip()}")
84
+ return False
85
+ with Lock(data_dir, timeout=30) as locked:
86
+ if not locked:
87
+ log(data_dir, "sync skipped, lock busy")
88
+ return False
89
+ behind = git(data_dir, "rev-list", "--count", "HEAD..@{u}")
90
+ if behind.returncode == 0 and behind.stdout.strip() != "0":
91
+ r = git(data_dir, "-c", "commit.gpgsign=false", "rebase", "-q", "--autostash", "@{u}")
92
+ if r.returncode != 0:
93
+ log(data_dir, f"rebase failed, aborted: {r.stderr.strip()}")
94
+ if _rebasing(data_dir):
95
+ git(data_dir, "rebase", "--abort")
96
+ return False
97
+ if push:
98
+ ahead = git(data_dir, "rev-list", "--count", "@{u}..HEAD")
99
+ if ahead.returncode != 0 or ahead.stdout.strip() != "0":
100
+ r = git(data_dir, "push", "-q", timeout=60)
101
+ if r.returncode != 0:
102
+ log(data_dir, f"push failed: {r.stderr.strip()}")
103
+ return False
104
+ return True
105
+
106
+
107
+ def spawn_sync(data_dir, push):
108
+ """Run `mem sync` detached so hooks return immediately."""
109
+ args = mem_command() + ["sync", "--quiet"]
110
+ if not push:
111
+ args.append("--pull-only")
112
+ log_path = Path(data_dir) / ".cache" / "sync.out"
113
+ try:
114
+ with open(log_path, "a") as out:
115
+ subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=out, stderr=out,
116
+ start_new_session=True,
117
+ env={**os.environ, "AGENT_MEMORY_DATA": str(data_dir)})
118
+ except OSError as e:
119
+ log(data_dir, f"spawn sync failed: {e}")
agent_memory/hooks.py ADDED
@@ -0,0 +1,95 @@
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, 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(sdir, {
40
+ "tool": tool,
41
+ "session_id": sid,
42
+ "session_title": payload.get("session_title"),
43
+ "model": payload.get("model"),
44
+ "transcript_path": payload.get("transcript_path"),
45
+ "ctx": ctx,
46
+ })
47
+
48
+ recovered = capture.recover_orphans(data_dir, keep=sdir)
49
+ stamp = Path(data_dir) / ".cache" / "last-pull"
50
+ if gitsync.has_remote(data_dir):
51
+ last = stamp.stat().st_mtime if stamp.exists() else 0
52
+ if recovered or time.time() - last > PULL_INTERVAL:
53
+ stamp.parent.mkdir(parents=True, exist_ok=True)
54
+ stamp.touch()
55
+ gitsync.spawn_sync(data_dir, push=recovered)
56
+
57
+ paths = {
58
+ "memories": str(store.memories_dir(data_dir)),
59
+ "staging": str(sdir),
60
+ "mem": str(config.BIN),
61
+ }
62
+ return select.render(store.load_all(data_dir), ctx, paths,
63
+ throwaway=workdir.is_throwaway(ctx.get("cwd")))
64
+
65
+
66
+ def flush(payload, tool, data_dir, final):
67
+ sdir = capture.session_dir(data_dir, tool, _session_id(payload))
68
+ if not sdir.is_dir():
69
+ return
70
+ if payload.get("transcript_path"):
71
+ capture.write_session(sdir, {"transcript_path": payload["transcript_path"]})
72
+ committed = capture.flush(data_dir, sdir, final=final, lock_timeout=1.0 if final else 5.0)
73
+ if committed and gitsync.has_remote(data_dir):
74
+ gitsync.spawn_sync(data_dir, push=True)
75
+
76
+
77
+ def main(event, tool):
78
+ payload = _payload()
79
+ data_dir = config.data_dir()
80
+ if not data_dir or not (data_dir / ".git").exists():
81
+ return 0
82
+ try:
83
+ if event == "session-start":
84
+ text = session_start(payload, tool, data_dir)
85
+ print(json.dumps({"hookSpecificOutput": {
86
+ "hookEventName": "SessionStart",
87
+ "additionalContext": text,
88
+ }}, ensure_ascii=False))
89
+ elif event in ("stop", "session-end"):
90
+ flush(payload, tool, data_dir, final=event == "session-end")
91
+ else:
92
+ log(data_dir, f"unknown hook event {event!r}")
93
+ except Exception:
94
+ log_exc(data_dir, f"hook {event} ({tool})")
95
+ return 0