agent-memory-git 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_memory/PROTOCOL.md +80 -0
- agent_memory/__init__.py +1 -0
- agent_memory/__main__.py +7 -0
- agent_memory/capture.py +207 -0
- agent_memory/cli.py +317 -0
- agent_memory/config.py +53 -0
- agent_memory/context.py +73 -0
- agent_memory/embed_worker.py +90 -0
- agent_memory/frontmatter.py +146 -0
- agent_memory/gitsync.py +119 -0
- agent_memory/hooks.py +95 -0
- agent_memory/importers.py +231 -0
- agent_memory/install.py +483 -0
- agent_memory/lint.py +45 -0
- agent_memory/select.py +105 -0
- agent_memory/sessions.py +64 -0
- agent_memory/store.py +97 -0
- agent_memory/ui.py +410 -0
- agent_memory/ui_server.py +371 -0
- agent_memory/util.py +101 -0
- agent_memory/webui/app.css +192 -0
- agent_memory/webui/app.js +635 -0
- agent_memory/webui/index.html +31 -0
- agent_memory/workdir.py +135 -0
- agent_memory_git-0.1.0.dist-info/METADATA +183 -0
- agent_memory_git-0.1.0.dist-info/RECORD +29 -0
- agent_memory_git-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_git-0.1.0.dist-info/entry_points.txt +3 -0
- agent_memory_git-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_memory/context.py
ADDED
|
@@ -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"
|
agent_memory/gitsync.py
ADDED
|
@@ -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
|