memtrail 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.
- memtrail/__init__.py +7 -0
- memtrail/api.py +307 -0
- memtrail/cli.py +186 -0
- memtrail/store.py +188 -0
- memtrail/tools.py +242 -0
- memtrail/types.py +125 -0
- memtrail-0.1.0.dist-info/METADATA +111 -0
- memtrail-0.1.0.dist-info/RECORD +11 -0
- memtrail-0.1.0.dist-info/WHEEL +4 -0
- memtrail-0.1.0.dist-info/entry_points.txt +2 -0
- memtrail-0.1.0.dist-info/licenses/LICENSE +21 -0
memtrail/__init__.py
ADDED
memtrail/api.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""High-level memtrail API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Optional, Union
|
|
8
|
+
|
|
9
|
+
from memtrail.store import Store, default_db_path
|
|
10
|
+
from memtrail.types import (
|
|
11
|
+
Commit,
|
|
12
|
+
Diff,
|
|
13
|
+
Fact,
|
|
14
|
+
Snapshot,
|
|
15
|
+
compute_commit_hash,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HEAD_REF = "HEAD"
|
|
20
|
+
DEFAULT_BRANCH = "main"
|
|
21
|
+
|
|
22
|
+
FactInput = Union[Fact, dict]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _coerce_fact(x: FactInput) -> Fact:
|
|
26
|
+
if isinstance(x, Fact):
|
|
27
|
+
return x
|
|
28
|
+
if not isinstance(x, dict):
|
|
29
|
+
raise TypeError(f"expected Fact or dict, got {type(x).__name__}")
|
|
30
|
+
if "id" not in x or "content" not in x:
|
|
31
|
+
raise ValueError("fact dict requires 'id' and 'content' keys")
|
|
32
|
+
return Fact(id=x["id"], content=x["content"], metadata=dict(x.get("metadata") or {}))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _utcnow_iso() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MemTrail:
|
|
40
|
+
"""A versioned memory repo.
|
|
41
|
+
|
|
42
|
+
>>> mt = MemTrail("my-agent")
|
|
43
|
+
>>> c = mt.commit(facts=[{"id": "user.tz", "content": "PST"}],
|
|
44
|
+
... message="...", author="agent")
|
|
45
|
+
>>> mt.blame("user.tz") == c
|
|
46
|
+
True
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, repo_name: str, db_path: Optional[Union[str, Path]] = None):
|
|
50
|
+
self.repo_name = repo_name
|
|
51
|
+
self.db_path = Path(db_path) if db_path else default_db_path(repo_name)
|
|
52
|
+
self._store = Store(self.db_path)
|
|
53
|
+
|
|
54
|
+
# ---- write ----
|
|
55
|
+
|
|
56
|
+
def commit(
|
|
57
|
+
self,
|
|
58
|
+
facts: Optional[Iterable[FactInput]] = None,
|
|
59
|
+
*,
|
|
60
|
+
remove: Optional[Iterable[str]] = None,
|
|
61
|
+
message: str = "",
|
|
62
|
+
author: str = "unknown",
|
|
63
|
+
timestamp: Optional[str] = None,
|
|
64
|
+
) -> Commit:
|
|
65
|
+
"""Create a new commit.
|
|
66
|
+
|
|
67
|
+
`facts` are upserted onto the parent snapshot (matched by id).
|
|
68
|
+
`remove` is an iterable of fact ids to drop.
|
|
69
|
+
If both are None / empty, the snapshot is unchanged but a commit is still recorded.
|
|
70
|
+
"""
|
|
71
|
+
parent = self.head()
|
|
72
|
+
base = self._store.get_snapshot(parent.snapshot_hash) if parent else Snapshot.empty()
|
|
73
|
+
new_snapshot = self._apply_patch(base, facts or [], remove or [])
|
|
74
|
+
return self._record_commit(
|
|
75
|
+
parent=parent,
|
|
76
|
+
snapshot=new_snapshot,
|
|
77
|
+
message=message,
|
|
78
|
+
author=author,
|
|
79
|
+
timestamp=timestamp,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def commit_snapshot(
|
|
83
|
+
self,
|
|
84
|
+
facts: Iterable[FactInput],
|
|
85
|
+
*,
|
|
86
|
+
message: str = "",
|
|
87
|
+
author: str = "unknown",
|
|
88
|
+
timestamp: Optional[str] = None,
|
|
89
|
+
) -> Commit:
|
|
90
|
+
"""Create a commit whose snapshot is exactly the given facts (full replace)."""
|
|
91
|
+
snapshot = Snapshot(facts=tuple(_coerce_fact(f) for f in facts))
|
|
92
|
+
return self._record_commit(
|
|
93
|
+
parent=self.head(),
|
|
94
|
+
snapshot=snapshot,
|
|
95
|
+
message=message,
|
|
96
|
+
author=author,
|
|
97
|
+
timestamp=timestamp,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def revert(
|
|
101
|
+
self,
|
|
102
|
+
commit_ref: str,
|
|
103
|
+
*,
|
|
104
|
+
message: Optional[str] = None,
|
|
105
|
+
author: str = "unknown",
|
|
106
|
+
) -> Commit:
|
|
107
|
+
"""Create a new commit restoring the snapshot at `commit_ref`."""
|
|
108
|
+
target = self._resolve(commit_ref)
|
|
109
|
+
if target is None:
|
|
110
|
+
raise ValueError(f"unknown commit: {commit_ref}")
|
|
111
|
+
snapshot = self._store.get_snapshot(target.snapshot_hash)
|
|
112
|
+
if snapshot is None:
|
|
113
|
+
raise RuntimeError(f"snapshot missing for commit {target.short()}")
|
|
114
|
+
return self._record_commit(
|
|
115
|
+
parent=self.head(),
|
|
116
|
+
snapshot=snapshot,
|
|
117
|
+
message=message or f"revert to {target.short()}: {target.message}",
|
|
118
|
+
author=author,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ---- read ----
|
|
122
|
+
|
|
123
|
+
def head(self) -> Optional[Commit]:
|
|
124
|
+
h = self._store.get_ref(HEAD_REF)
|
|
125
|
+
return self._store.get_commit(h) if h else None
|
|
126
|
+
|
|
127
|
+
def log(self, limit: Optional[int] = 50, *, branch: Optional[str] = None) -> list[Commit]:
|
|
128
|
+
"""Return commits along the ancestry of `branch` (default HEAD), newest first."""
|
|
129
|
+
start_hash = self._store.get_ref(branch) if branch else self._store.get_ref(HEAD_REF)
|
|
130
|
+
if not start_hash:
|
|
131
|
+
return []
|
|
132
|
+
out: list[Commit] = []
|
|
133
|
+
for c in self._store.walk_ancestors(start_hash):
|
|
134
|
+
out.append(c)
|
|
135
|
+
if limit is not None and len(out) >= limit:
|
|
136
|
+
break
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
def show(self, commit_ref: str) -> Optional[Snapshot]:
|
|
140
|
+
c = self._resolve(commit_ref)
|
|
141
|
+
if not c:
|
|
142
|
+
return None
|
|
143
|
+
return self._store.get_snapshot(c.snapshot_hash)
|
|
144
|
+
|
|
145
|
+
def get_commit(self, commit_ref: str) -> Optional[Commit]:
|
|
146
|
+
return self._resolve(commit_ref)
|
|
147
|
+
|
|
148
|
+
def blame(
|
|
149
|
+
self,
|
|
150
|
+
fact_id: str,
|
|
151
|
+
*,
|
|
152
|
+
origin: bool = False,
|
|
153
|
+
at: Optional[str] = None,
|
|
154
|
+
) -> Optional[Commit]:
|
|
155
|
+
"""Find the commit that introduced or last touched `fact_id`.
|
|
156
|
+
|
|
157
|
+
- If `origin` is True, return the commit where the fact first appeared.
|
|
158
|
+
- Otherwise, return the most recent commit whose snapshot changed the
|
|
159
|
+
fact's content (or introduced it). This matches `git blame` semantics.
|
|
160
|
+
- If `at` is given, blame is computed relative to that commit's history,
|
|
161
|
+
not HEAD.
|
|
162
|
+
"""
|
|
163
|
+
start = self._resolve(at) if at else self.head()
|
|
164
|
+
if start is None:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Walk newest -> oldest. The first commit (newest) where the fact's
|
|
168
|
+
# content differs from its parent's value is the "last touch."
|
|
169
|
+
prev_content_marker = object()
|
|
170
|
+
last_touch: Optional[Commit] = None
|
|
171
|
+
introducing: Optional[Commit] = None
|
|
172
|
+
|
|
173
|
+
# Collect ancestry oldest -> newest for clean comparison.
|
|
174
|
+
ancestry: list[Commit] = list(self._store.walk_ancestors(start.hash))
|
|
175
|
+
ancestry.reverse()
|
|
176
|
+
|
|
177
|
+
prev_value = prev_content_marker # sentinel meaning "no prior snapshot"
|
|
178
|
+
for c in ancestry:
|
|
179
|
+
snap = self._store.get_snapshot(c.snapshot_hash)
|
|
180
|
+
cur_fact = snap.get(fact_id) if snap else None
|
|
181
|
+
cur_value = cur_fact.content if cur_fact is not None else None
|
|
182
|
+
cur_present = cur_fact is not None
|
|
183
|
+
|
|
184
|
+
if cur_present and prev_value is prev_content_marker:
|
|
185
|
+
introducing = c
|
|
186
|
+
last_touch = c
|
|
187
|
+
elif cur_present and not _content_eq(prev_value, cur_value):
|
|
188
|
+
if introducing is None:
|
|
189
|
+
introducing = c
|
|
190
|
+
last_touch = c
|
|
191
|
+
# if removed (prev_present, not cur_present), we don't update
|
|
192
|
+
# last_touch — `blame` answers "when was this value put here."
|
|
193
|
+
|
|
194
|
+
if cur_present:
|
|
195
|
+
prev_value = cur_value
|
|
196
|
+
else:
|
|
197
|
+
prev_value = prev_content_marker # gap: a re-introduction will be a new origin
|
|
198
|
+
|
|
199
|
+
return introducing if origin else last_touch
|
|
200
|
+
|
|
201
|
+
def diff(self, a_ref: str, b_ref: str) -> Diff:
|
|
202
|
+
a = self.show(a_ref) or Snapshot.empty()
|
|
203
|
+
b = self.show(b_ref) or Snapshot.empty()
|
|
204
|
+
return _diff_snapshots(a, b)
|
|
205
|
+
|
|
206
|
+
def list_facts(self, commit_ref: Optional[str] = None) -> list[Fact]:
|
|
207
|
+
"""Return facts in the snapshot at `commit_ref` (default HEAD)."""
|
|
208
|
+
target = self._resolve(commit_ref) if commit_ref else self.head()
|
|
209
|
+
if not target:
|
|
210
|
+
return []
|
|
211
|
+
snap = self._store.get_snapshot(target.snapshot_hash)
|
|
212
|
+
return list(snap.facts) if snap else []
|
|
213
|
+
|
|
214
|
+
# ---- internal ----
|
|
215
|
+
|
|
216
|
+
def _resolve(self, ref: str) -> Optional[Commit]:
|
|
217
|
+
"""Resolve a ref string to a Commit. Accepts: full hash, short hash, ref name."""
|
|
218
|
+
if not ref:
|
|
219
|
+
return None
|
|
220
|
+
if ref == HEAD_REF:
|
|
221
|
+
return self.head()
|
|
222
|
+
# Try as ref name first.
|
|
223
|
+
named = self._store.get_ref(ref)
|
|
224
|
+
if named:
|
|
225
|
+
return self._store.get_commit(named)
|
|
226
|
+
# Otherwise assume it's a (possibly short) hash.
|
|
227
|
+
return self._store.get_commit(ref)
|
|
228
|
+
|
|
229
|
+
def _record_commit(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
parent: Optional[Commit],
|
|
233
|
+
snapshot: Snapshot,
|
|
234
|
+
message: str,
|
|
235
|
+
author: str,
|
|
236
|
+
timestamp: Optional[str] = None,
|
|
237
|
+
) -> Commit:
|
|
238
|
+
ts = timestamp or _utcnow_iso()
|
|
239
|
+
snap_hash = self._store.put_snapshot(snapshot)
|
|
240
|
+
commit_hash = compute_commit_hash(
|
|
241
|
+
parent_hash=parent.hash if parent else None,
|
|
242
|
+
snapshot_hash=snap_hash,
|
|
243
|
+
author=author,
|
|
244
|
+
message=message,
|
|
245
|
+
timestamp=ts,
|
|
246
|
+
)
|
|
247
|
+
commit = Commit(
|
|
248
|
+
hash=commit_hash,
|
|
249
|
+
parent_hash=parent.hash if parent else None,
|
|
250
|
+
snapshot_hash=snap_hash,
|
|
251
|
+
author=author,
|
|
252
|
+
message=message,
|
|
253
|
+
timestamp=ts,
|
|
254
|
+
)
|
|
255
|
+
self._store.put_commit(commit)
|
|
256
|
+
self._store.set_ref(HEAD_REF, commit.hash)
|
|
257
|
+
if parent is None:
|
|
258
|
+
self._store.set_ref(DEFAULT_BRANCH, commit.hash)
|
|
259
|
+
else:
|
|
260
|
+
current_branch_head = self._store.get_ref(DEFAULT_BRANCH)
|
|
261
|
+
if current_branch_head == parent.hash:
|
|
262
|
+
self._store.set_ref(DEFAULT_BRANCH, commit.hash)
|
|
263
|
+
return commit
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _apply_patch(
|
|
267
|
+
base: Snapshot,
|
|
268
|
+
upserts: Iterable[FactInput],
|
|
269
|
+
removes: Iterable[str],
|
|
270
|
+
) -> Snapshot:
|
|
271
|
+
by_id: dict[str, Fact] = {f.id: f for f in base.facts}
|
|
272
|
+
for u in upserts:
|
|
273
|
+
f = _coerce_fact(u)
|
|
274
|
+
by_id[f.id] = f
|
|
275
|
+
for rid in removes:
|
|
276
|
+
by_id.pop(rid, None)
|
|
277
|
+
return Snapshot(facts=tuple(by_id.values()))
|
|
278
|
+
|
|
279
|
+
def close(self) -> None:
|
|
280
|
+
self._store.close()
|
|
281
|
+
|
|
282
|
+
def __enter__(self) -> "MemTrail":
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
def __exit__(self, *exc) -> None:
|
|
286
|
+
self.close()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _content_eq(a, b) -> bool:
|
|
290
|
+
"""Compare fact contents structurally."""
|
|
291
|
+
return a == b
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _diff_snapshots(a: Snapshot, b: Snapshot) -> Diff:
|
|
295
|
+
a_by_id = {f.id: f for f in a.facts}
|
|
296
|
+
b_by_id = {f.id: f for f in b.facts}
|
|
297
|
+
a_ids = set(a_by_id)
|
|
298
|
+
b_ids = set(b_by_id)
|
|
299
|
+
added = tuple(b_by_id[i] for i in sorted(b_ids - a_ids))
|
|
300
|
+
removed = tuple(a_by_id[i] for i in sorted(a_ids - b_ids))
|
|
301
|
+
changed = tuple(
|
|
302
|
+
(a_by_id[i], b_by_id[i])
|
|
303
|
+
for i in sorted(a_ids & b_ids)
|
|
304
|
+
if not _content_eq(a_by_id[i].content, b_by_id[i].content)
|
|
305
|
+
or a_by_id[i].metadata != b_by_id[i].metadata
|
|
306
|
+
)
|
|
307
|
+
return Diff(added=added, removed=removed, changed=changed)
|
memtrail/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""memtrail CLI.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
memtrail --repo <name> log [--limit N]
|
|
5
|
+
memtrail --repo <name> show <commit>
|
|
6
|
+
memtrail --repo <name> blame <fact_id> [--origin]
|
|
7
|
+
memtrail --repo <name> diff <a> <b>
|
|
8
|
+
memtrail --repo <name> revert <commit> [--message MSG]
|
|
9
|
+
memtrail --repo <name> facts [<commit>]
|
|
10
|
+
memtrail --repo <name> commit --fact id=value [...] [--remove id ...] -m MSG [--author A]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from memtrail import MemTrail, __version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_repo_parent() -> argparse.ArgumentParser:
|
|
24
|
+
"""A parent parser exposing --repo / --db. Subcommands inherit it via `parents=`."""
|
|
25
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
26
|
+
parent.add_argument(
|
|
27
|
+
"--repo",
|
|
28
|
+
default="default",
|
|
29
|
+
help="Repo name (default: 'default'). Stored at ~/.memtrail/<repo>.db",
|
|
30
|
+
)
|
|
31
|
+
parent.add_argument(
|
|
32
|
+
"--db",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Override the database path entirely.",
|
|
35
|
+
)
|
|
36
|
+
return parent
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _open(args) -> MemTrail:
|
|
40
|
+
return MemTrail(args.repo, db_path=args.db)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _print_commit_line(c) -> None:
|
|
44
|
+
print(f"{c.short()} {c.timestamp} {c.author:<24} {c.message}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_fact_arg(s: str) -> dict:
|
|
48
|
+
"""Parse `id=value` or `id=@file.json`. Plain values are stored as strings."""
|
|
49
|
+
if "=" not in s:
|
|
50
|
+
raise SystemExit(f"--fact must be id=value, got: {s!r}")
|
|
51
|
+
fid, _, raw = s.partition("=")
|
|
52
|
+
fid = fid.strip()
|
|
53
|
+
if not fid:
|
|
54
|
+
raise SystemExit(f"--fact id is empty in: {s!r}")
|
|
55
|
+
if raw.startswith("@"):
|
|
56
|
+
with open(raw[1:], "r", encoding="utf-8") as fh:
|
|
57
|
+
content = json.load(fh)
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
content = json.loads(raw)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
content = raw
|
|
63
|
+
return {"id": fid, "content": content}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_log(args) -> int:
|
|
67
|
+
mt = _open(args)
|
|
68
|
+
commits = mt.log(limit=args.limit)
|
|
69
|
+
if not commits:
|
|
70
|
+
print("(no commits yet)")
|
|
71
|
+
return 0
|
|
72
|
+
for c in commits:
|
|
73
|
+
_print_commit_line(c)
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_show(args) -> int:
|
|
78
|
+
mt = _open(args)
|
|
79
|
+
snap = mt.show(args.commit)
|
|
80
|
+
if snap is None:
|
|
81
|
+
print(f"unknown commit: {args.commit}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
print(json.dumps(snap.to_dict(), indent=2, ensure_ascii=False))
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_blame(args) -> int:
|
|
88
|
+
mt = _open(args)
|
|
89
|
+
c = mt.blame(args.fact_id, origin=args.origin)
|
|
90
|
+
if c is None:
|
|
91
|
+
print(f"no commit touches fact_id: {args.fact_id}", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
_print_commit_line(c)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_diff(args) -> int:
|
|
98
|
+
mt = _open(args)
|
|
99
|
+
d = mt.diff(args.a, args.b)
|
|
100
|
+
print(json.dumps(d.to_dict(), indent=2, ensure_ascii=False))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_revert(args) -> int:
|
|
105
|
+
mt = _open(args)
|
|
106
|
+
c = mt.revert(args.commit, message=args.message, author=args.author)
|
|
107
|
+
_print_commit_line(c)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_facts(args) -> int:
|
|
112
|
+
mt = _open(args)
|
|
113
|
+
facts = mt.list_facts(commit_ref=args.commit)
|
|
114
|
+
if not facts:
|
|
115
|
+
print("(no facts)")
|
|
116
|
+
return 0
|
|
117
|
+
for f in sorted(facts, key=lambda x: x.id):
|
|
118
|
+
print(f"{f.id}\t{json.dumps(f.content, ensure_ascii=False)}")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_commit(args) -> int:
|
|
123
|
+
mt = _open(args)
|
|
124
|
+
facts = [_parse_fact_arg(s) for s in (args.fact or [])]
|
|
125
|
+
c = mt.commit(
|
|
126
|
+
facts=facts,
|
|
127
|
+
remove=args.remove or [],
|
|
128
|
+
message=args.message,
|
|
129
|
+
author=args.author,
|
|
130
|
+
)
|
|
131
|
+
_print_commit_line(c)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
136
|
+
p = argparse.ArgumentParser(prog="memtrail", description="Audit and time-travel for AI memory.")
|
|
137
|
+
p.add_argument("--version", action="version", version=f"memtrail {__version__}")
|
|
138
|
+
repo_parent = _make_repo_parent()
|
|
139
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
140
|
+
|
|
141
|
+
pl = sub.add_parser("log", parents=[repo_parent], help="List recent commits.")
|
|
142
|
+
pl.add_argument("--limit", type=int, default=20)
|
|
143
|
+
pl.set_defaults(func=cmd_log)
|
|
144
|
+
|
|
145
|
+
ps = sub.add_parser("show", parents=[repo_parent], help="Show snapshot at commit.")
|
|
146
|
+
ps.add_argument("commit")
|
|
147
|
+
ps.set_defaults(func=cmd_show)
|
|
148
|
+
|
|
149
|
+
pb = sub.add_parser("blame", parents=[repo_parent], help="Find commit that introduced/touched a fact.")
|
|
150
|
+
pb.add_argument("fact_id")
|
|
151
|
+
pb.add_argument("--origin", action="store_true", help="Return the introducing commit.")
|
|
152
|
+
pb.set_defaults(func=cmd_blame)
|
|
153
|
+
|
|
154
|
+
pd = sub.add_parser("diff", parents=[repo_parent], help="Diff two commits.")
|
|
155
|
+
pd.add_argument("a")
|
|
156
|
+
pd.add_argument("b")
|
|
157
|
+
pd.set_defaults(func=cmd_diff)
|
|
158
|
+
|
|
159
|
+
pr = sub.add_parser("revert", parents=[repo_parent], help="Revert to a prior commit (creates a new commit).")
|
|
160
|
+
pr.add_argument("commit")
|
|
161
|
+
pr.add_argument("-m", "--message", default=None)
|
|
162
|
+
pr.add_argument("--author", default="cli")
|
|
163
|
+
pr.set_defaults(func=cmd_revert)
|
|
164
|
+
|
|
165
|
+
pf = sub.add_parser("facts", parents=[repo_parent], help="List facts at a commit (default HEAD).")
|
|
166
|
+
pf.add_argument("commit", nargs="?", default=None)
|
|
167
|
+
pf.set_defaults(func=cmd_facts)
|
|
168
|
+
|
|
169
|
+
pc = sub.add_parser("commit", parents=[repo_parent], help="Create a commit from CLI input.")
|
|
170
|
+
pc.add_argument("--fact", action="append", help="Repeatable: id=value or id=@file.json")
|
|
171
|
+
pc.add_argument("--remove", action="append", help="Repeatable: fact id to remove.")
|
|
172
|
+
pc.add_argument("-m", "--message", required=True)
|
|
173
|
+
pc.add_argument("--author", default="cli")
|
|
174
|
+
pc.set_defaults(func=cmd_commit)
|
|
175
|
+
|
|
176
|
+
return p
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
180
|
+
parser = build_parser()
|
|
181
|
+
args = parser.parse_args(argv)
|
|
182
|
+
return args.func(args)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
raise SystemExit(main())
|
memtrail/store.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""SQLite-backed content-addressed store for memtrail."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator, Optional
|
|
10
|
+
|
|
11
|
+
from memtrail.types import Commit, Snapshot, canonical_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
16
|
+
hash TEXT PRIMARY KEY,
|
|
17
|
+
content TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS commits (
|
|
21
|
+
hash TEXT PRIMARY KEY,
|
|
22
|
+
parent_hash TEXT,
|
|
23
|
+
snapshot_hash TEXT NOT NULL,
|
|
24
|
+
author TEXT NOT NULL,
|
|
25
|
+
message TEXT NOT NULL,
|
|
26
|
+
timestamp TEXT NOT NULL,
|
|
27
|
+
FOREIGN KEY (snapshot_hash) REFERENCES snapshots(hash),
|
|
28
|
+
FOREIGN KEY (parent_hash) REFERENCES commits(hash)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_commits_parent ON commits(parent_hash);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_commits_timestamp ON commits(timestamp);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS refs (
|
|
35
|
+
name TEXT PRIMARY KEY,
|
|
36
|
+
commit_hash TEXT NOT NULL,
|
|
37
|
+
FOREIGN KEY (commit_hash) REFERENCES commits(hash)
|
|
38
|
+
);
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_db_path(repo_name: str) -> Path:
|
|
43
|
+
base = Path(os.environ.get("MEMTRAIL_HOME", Path.home() / ".memtrail"))
|
|
44
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in repo_name)
|
|
46
|
+
return base / f"{safe}.db"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Store:
|
|
50
|
+
"""Thin SQLite wrapper. Thread-safe via a per-instance lock."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, db_path: Path | str):
|
|
53
|
+
self.db_path = Path(db_path)
|
|
54
|
+
self._lock = threading.RLock()
|
|
55
|
+
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
56
|
+
self._conn.row_factory = sqlite3.Row
|
|
57
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
58
|
+
self._conn.executescript(SCHEMA)
|
|
59
|
+
self._conn.commit()
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._conn.close()
|
|
64
|
+
|
|
65
|
+
# ---- snapshots ----
|
|
66
|
+
|
|
67
|
+
def put_snapshot(self, snapshot: Snapshot) -> str:
|
|
68
|
+
h = snapshot.hash
|
|
69
|
+
content = canonical_json(snapshot.to_dict())
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._conn.execute(
|
|
72
|
+
"INSERT OR IGNORE INTO snapshots (hash, content) VALUES (?, ?)",
|
|
73
|
+
(h, content),
|
|
74
|
+
)
|
|
75
|
+
self._conn.commit()
|
|
76
|
+
return h
|
|
77
|
+
|
|
78
|
+
def get_snapshot(self, snapshot_hash: str) -> Optional[Snapshot]:
|
|
79
|
+
with self._lock:
|
|
80
|
+
row = self._conn.execute(
|
|
81
|
+
"SELECT content FROM snapshots WHERE hash = ?", (snapshot_hash,)
|
|
82
|
+
).fetchone()
|
|
83
|
+
if not row:
|
|
84
|
+
return None
|
|
85
|
+
import json
|
|
86
|
+
|
|
87
|
+
return Snapshot.from_dict(json.loads(row["content"]))
|
|
88
|
+
|
|
89
|
+
# ---- commits ----
|
|
90
|
+
|
|
91
|
+
def put_commit(self, commit: Commit) -> None:
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._conn.execute(
|
|
94
|
+
"""
|
|
95
|
+
INSERT OR IGNORE INTO commits
|
|
96
|
+
(hash, parent_hash, snapshot_hash, author, message, timestamp)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
98
|
+
""",
|
|
99
|
+
(
|
|
100
|
+
commit.hash,
|
|
101
|
+
commit.parent_hash,
|
|
102
|
+
commit.snapshot_hash,
|
|
103
|
+
commit.author,
|
|
104
|
+
commit.message,
|
|
105
|
+
commit.timestamp,
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
self._conn.commit()
|
|
109
|
+
|
|
110
|
+
def get_commit(self, commit_hash: str) -> Optional[Commit]:
|
|
111
|
+
# Support short-hash prefix lookup (≥4 chars).
|
|
112
|
+
if len(commit_hash) < 4:
|
|
113
|
+
return None
|
|
114
|
+
with self._lock:
|
|
115
|
+
if len(commit_hash) == 64:
|
|
116
|
+
row = self._conn.execute(
|
|
117
|
+
"SELECT * FROM commits WHERE hash = ?", (commit_hash,)
|
|
118
|
+
).fetchone()
|
|
119
|
+
else:
|
|
120
|
+
rows = self._conn.execute(
|
|
121
|
+
"SELECT * FROM commits WHERE hash LIKE ? LIMIT 2",
|
|
122
|
+
(commit_hash + "%",),
|
|
123
|
+
).fetchall()
|
|
124
|
+
if len(rows) != 1:
|
|
125
|
+
return None
|
|
126
|
+
row = rows[0]
|
|
127
|
+
if not row:
|
|
128
|
+
return None
|
|
129
|
+
return self._row_to_commit(row)
|
|
130
|
+
|
|
131
|
+
def iter_commits_newest_first(self, limit: Optional[int] = None) -> Iterator[Commit]:
|
|
132
|
+
sql = "SELECT * FROM commits ORDER BY timestamp DESC, hash DESC"
|
|
133
|
+
if limit is not None:
|
|
134
|
+
sql += f" LIMIT {int(limit)}"
|
|
135
|
+
with self._lock:
|
|
136
|
+
rows = self._conn.execute(sql).fetchall()
|
|
137
|
+
for row in rows:
|
|
138
|
+
yield self._row_to_commit(row)
|
|
139
|
+
|
|
140
|
+
def iter_commits_oldest_first(self) -> Iterator[Commit]:
|
|
141
|
+
sql = "SELECT * FROM commits ORDER BY timestamp ASC, hash ASC"
|
|
142
|
+
with self._lock:
|
|
143
|
+
rows = self._conn.execute(sql).fetchall()
|
|
144
|
+
for row in rows:
|
|
145
|
+
yield self._row_to_commit(row)
|
|
146
|
+
|
|
147
|
+
def walk_ancestors(self, commit_hash: str) -> Iterator[Commit]:
|
|
148
|
+
"""Yield commit and its ancestors via parent pointers, newest first."""
|
|
149
|
+
cur = self.get_commit(commit_hash)
|
|
150
|
+
while cur is not None:
|
|
151
|
+
yield cur
|
|
152
|
+
if cur.parent_hash is None:
|
|
153
|
+
break
|
|
154
|
+
cur = self.get_commit(cur.parent_hash)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _row_to_commit(row: sqlite3.Row) -> Commit:
|
|
158
|
+
return Commit(
|
|
159
|
+
hash=row["hash"],
|
|
160
|
+
parent_hash=row["parent_hash"],
|
|
161
|
+
snapshot_hash=row["snapshot_hash"],
|
|
162
|
+
author=row["author"],
|
|
163
|
+
message=row["message"],
|
|
164
|
+
timestamp=row["timestamp"],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ---- refs ----
|
|
168
|
+
|
|
169
|
+
def set_ref(self, name: str, commit_hash: str) -> None:
|
|
170
|
+
with self._lock:
|
|
171
|
+
self._conn.execute(
|
|
172
|
+
"INSERT INTO refs (name, commit_hash) VALUES (?, ?) "
|
|
173
|
+
"ON CONFLICT(name) DO UPDATE SET commit_hash = excluded.commit_hash",
|
|
174
|
+
(name, commit_hash),
|
|
175
|
+
)
|
|
176
|
+
self._conn.commit()
|
|
177
|
+
|
|
178
|
+
def get_ref(self, name: str) -> Optional[str]:
|
|
179
|
+
with self._lock:
|
|
180
|
+
row = self._conn.execute(
|
|
181
|
+
"SELECT commit_hash FROM refs WHERE name = ?", (name,)
|
|
182
|
+
).fetchone()
|
|
183
|
+
return row["commit_hash"] if row else None
|
|
184
|
+
|
|
185
|
+
def list_refs(self) -> dict[str, str]:
|
|
186
|
+
with self._lock:
|
|
187
|
+
rows = self._conn.execute("SELECT name, commit_hash FROM refs").fetchall()
|
|
188
|
+
return {row["name"]: row["commit_hash"] for row in rows}
|
memtrail/tools.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Agent-callable tool adapters for memtrail.
|
|
2
|
+
|
|
3
|
+
Returns tool schemas in provider-native shapes plus a `dispatch` callable that
|
|
4
|
+
routes a tool invocation to the right MemTrail method.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from memtrail.api import MemTrail
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ToolSchema = dict[str, Any]
|
|
15
|
+
Dispatcher = Callable[[str, dict], Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tool_definitions() -> list[ToolSchema]:
|
|
19
|
+
"""Provider-neutral tool definitions. Adapters re-shape these."""
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
"name": "memtrail_commit",
|
|
23
|
+
"description": (
|
|
24
|
+
"Persist a memory change as a versioned commit. Use this when the "
|
|
25
|
+
"agent learns a new fact, updates an existing one, or wants to "
|
|
26
|
+
"make a memory state durable and auditable."
|
|
27
|
+
),
|
|
28
|
+
"parameters": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"facts": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"description": (
|
|
34
|
+
"Facts to add or update. Each fact has a stable id, content, "
|
|
35
|
+
"and optional metadata. Same-id facts overwrite previous values."
|
|
36
|
+
),
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"id": {"type": "string"},
|
|
41
|
+
"content": {},
|
|
42
|
+
"metadata": {"type": "object"},
|
|
43
|
+
},
|
|
44
|
+
"required": ["id", "content"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"remove": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"description": "Fact ids to remove from the memory.",
|
|
50
|
+
"items": {"type": "string"},
|
|
51
|
+
},
|
|
52
|
+
"message": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "Why this change is being committed.",
|
|
55
|
+
},
|
|
56
|
+
"author": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "Identifier of the author (model name, user, etc.).",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
"required": ["message"],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "memtrail_log",
|
|
66
|
+
"description": (
|
|
67
|
+
"List recent memory commits, newest first. Use to understand "
|
|
68
|
+
"how the memory has evolved over time."
|
|
69
|
+
),
|
|
70
|
+
"parameters": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"limit": {"type": "integer", "default": 20},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "memtrail_show",
|
|
79
|
+
"description": (
|
|
80
|
+
"Return all facts in the memory at a specific commit (full snapshot). "
|
|
81
|
+
"Use to inspect past states."
|
|
82
|
+
),
|
|
83
|
+
"parameters": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"commit": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"description": "Commit hash (full or short) or ref name.",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
"required": ["commit"],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"name": "memtrail_blame",
|
|
96
|
+
"description": (
|
|
97
|
+
"Return the commit that introduced or last touched a given fact id. "
|
|
98
|
+
"Use to investigate WHY the agent believes a particular thing."
|
|
99
|
+
),
|
|
100
|
+
"parameters": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"fact_id": {"type": "string"},
|
|
104
|
+
"origin": {
|
|
105
|
+
"type": "boolean",
|
|
106
|
+
"default": False,
|
|
107
|
+
"description": (
|
|
108
|
+
"If true, return the FIRST commit that introduced the fact. "
|
|
109
|
+
"Default returns the most recent commit that changed it."
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
"required": ["fact_id"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "memtrail_diff",
|
|
118
|
+
"description": (
|
|
119
|
+
"Compare two commits and return added / removed / changed facts."
|
|
120
|
+
),
|
|
121
|
+
"parameters": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {
|
|
124
|
+
"a": {"type": "string", "description": "Earlier commit hash or ref."},
|
|
125
|
+
"b": {"type": "string", "description": "Later commit hash or ref."},
|
|
126
|
+
},
|
|
127
|
+
"required": ["a", "b"],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "memtrail_revert",
|
|
132
|
+
"description": (
|
|
133
|
+
"Create a new commit restoring the memory state at a given commit. "
|
|
134
|
+
"Use to roll back after discovering a bad fact."
|
|
135
|
+
),
|
|
136
|
+
"parameters": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {
|
|
139
|
+
"commit": {"type": "string"},
|
|
140
|
+
"message": {"type": "string"},
|
|
141
|
+
"author": {"type": "string"},
|
|
142
|
+
},
|
|
143
|
+
"required": ["commit"],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _make_dispatcher(mt: MemTrail) -> Dispatcher:
|
|
150
|
+
def dispatch(name: str, args: dict) -> Any:
|
|
151
|
+
args = args or {}
|
|
152
|
+
if name == "memtrail_commit":
|
|
153
|
+
commit = mt.commit(
|
|
154
|
+
facts=args.get("facts") or [],
|
|
155
|
+
remove=args.get("remove") or [],
|
|
156
|
+
message=args.get("message", ""),
|
|
157
|
+
author=args.get("author", "agent"),
|
|
158
|
+
)
|
|
159
|
+
return {
|
|
160
|
+
"hash": commit.hash,
|
|
161
|
+
"short": commit.short(),
|
|
162
|
+
"parent": commit.parent_hash,
|
|
163
|
+
"timestamp": commit.timestamp,
|
|
164
|
+
"message": commit.message,
|
|
165
|
+
"author": commit.author,
|
|
166
|
+
}
|
|
167
|
+
if name == "memtrail_log":
|
|
168
|
+
limit = int(args.get("limit", 20))
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
"hash": c.hash,
|
|
172
|
+
"short": c.short(),
|
|
173
|
+
"parent": c.parent_hash,
|
|
174
|
+
"timestamp": c.timestamp,
|
|
175
|
+
"author": c.author,
|
|
176
|
+
"message": c.message,
|
|
177
|
+
}
|
|
178
|
+
for c in mt.log(limit=limit)
|
|
179
|
+
]
|
|
180
|
+
if name == "memtrail_show":
|
|
181
|
+
snap = mt.show(args["commit"])
|
|
182
|
+
if snap is None:
|
|
183
|
+
return {"error": f"unknown commit: {args['commit']}"}
|
|
184
|
+
return snap.to_dict()
|
|
185
|
+
if name == "memtrail_blame":
|
|
186
|
+
commit = mt.blame(args["fact_id"], origin=bool(args.get("origin", False)))
|
|
187
|
+
if commit is None:
|
|
188
|
+
return {"error": f"no commit found for fact_id: {args['fact_id']}"}
|
|
189
|
+
return {
|
|
190
|
+
"hash": commit.hash,
|
|
191
|
+
"short": commit.short(),
|
|
192
|
+
"timestamp": commit.timestamp,
|
|
193
|
+
"author": commit.author,
|
|
194
|
+
"message": commit.message,
|
|
195
|
+
}
|
|
196
|
+
if name == "memtrail_diff":
|
|
197
|
+
return mt.diff(args["a"], args["b"]).to_dict()
|
|
198
|
+
if name == "memtrail_revert":
|
|
199
|
+
commit = mt.revert(
|
|
200
|
+
args["commit"],
|
|
201
|
+
message=args.get("message"),
|
|
202
|
+
author=args.get("author", "agent"),
|
|
203
|
+
)
|
|
204
|
+
return {
|
|
205
|
+
"hash": commit.hash,
|
|
206
|
+
"short": commit.short(),
|
|
207
|
+
"message": commit.message,
|
|
208
|
+
}
|
|
209
|
+
raise ValueError(f"unknown tool: {name}")
|
|
210
|
+
|
|
211
|
+
return dispatch
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def anthropic_tools(mt: MemTrail) -> tuple[list[ToolSchema], Dispatcher]:
|
|
215
|
+
"""Return (tools, dispatch) shaped for Anthropic's `messages.create(tools=...)`."""
|
|
216
|
+
out: list[ToolSchema] = []
|
|
217
|
+
for t in _tool_definitions():
|
|
218
|
+
out.append(
|
|
219
|
+
{
|
|
220
|
+
"name": t["name"],
|
|
221
|
+
"description": t["description"],
|
|
222
|
+
"input_schema": t["parameters"],
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
return out, _make_dispatcher(mt)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def openai_tools(mt: MemTrail) -> tuple[list[ToolSchema], Dispatcher]:
|
|
229
|
+
"""Return (tools, dispatch) shaped for OpenAI's `chat.completions(tools=...)`."""
|
|
230
|
+
out: list[ToolSchema] = []
|
|
231
|
+
for t in _tool_definitions():
|
|
232
|
+
out.append(
|
|
233
|
+
{
|
|
234
|
+
"type": "function",
|
|
235
|
+
"function": {
|
|
236
|
+
"name": t["name"],
|
|
237
|
+
"description": t["description"],
|
|
238
|
+
"parameters": t["parameters"],
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return out, _make_dispatcher(mt)
|
memtrail/types.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Core data types for memtrail."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def canonical_json(obj: Any) -> str:
|
|
12
|
+
"""Stable JSON serialization for content-addressing."""
|
|
13
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def content_hash(obj: Any) -> str:
|
|
17
|
+
"""SHA-256 of canonical JSON. Returned as a 64-char hex string."""
|
|
18
|
+
return hashlib.sha256(canonical_json(obj).encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Fact:
|
|
23
|
+
"""An atomic memory item. Caller-provided `id` enables targeted blame."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
content: Any
|
|
27
|
+
metadata: dict = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {"id": self.id, "content": self.content, "metadata": dict(self.metadata)}
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, d: dict) -> "Fact":
|
|
34
|
+
return cls(id=d["id"], content=d["content"], metadata=dict(d.get("metadata") or {}))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Snapshot:
|
|
39
|
+
"""A set of facts at a point in time. Hashed by canonical JSON of sorted facts."""
|
|
40
|
+
|
|
41
|
+
facts: tuple[Fact, ...]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def hash(self) -> str:
|
|
45
|
+
return content_hash([f.to_dict() for f in self._sorted()])
|
|
46
|
+
|
|
47
|
+
def _sorted(self) -> list[Fact]:
|
|
48
|
+
return sorted(self.facts, key=lambda f: f.id)
|
|
49
|
+
|
|
50
|
+
def get(self, fact_id: str) -> Optional[Fact]:
|
|
51
|
+
for f in self.facts:
|
|
52
|
+
if f.id == fact_id:
|
|
53
|
+
return f
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def ids(self) -> set[str]:
|
|
57
|
+
return {f.id for f in self.facts}
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return {"facts": [f.to_dict() for f in self._sorted()]}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, d: dict) -> "Snapshot":
|
|
64
|
+
return cls(facts=tuple(Fact.from_dict(x) for x in d.get("facts", [])))
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def empty(cls) -> "Snapshot":
|
|
68
|
+
return cls(facts=())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Commit:
|
|
73
|
+
"""A versioned snapshot. `hash` is content-addressed over the commit metadata + snapshot."""
|
|
74
|
+
|
|
75
|
+
hash: str
|
|
76
|
+
parent_hash: Optional[str]
|
|
77
|
+
snapshot_hash: str
|
|
78
|
+
author: str
|
|
79
|
+
message: str
|
|
80
|
+
timestamp: str # ISO-8601 UTC
|
|
81
|
+
|
|
82
|
+
def short(self) -> str:
|
|
83
|
+
return self.hash[:12]
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict:
|
|
86
|
+
return asdict(self)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Diff:
|
|
91
|
+
"""Difference between two snapshots."""
|
|
92
|
+
|
|
93
|
+
added: tuple[Fact, ...] # facts present in B, not A
|
|
94
|
+
removed: tuple[Fact, ...] # facts present in A, not B
|
|
95
|
+
changed: tuple[tuple[Fact, Fact], ...] # (before, after) for facts whose content changed
|
|
96
|
+
|
|
97
|
+
def is_empty(self) -> bool:
|
|
98
|
+
return not (self.added or self.removed or self.changed)
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict:
|
|
101
|
+
return {
|
|
102
|
+
"added": [f.to_dict() for f in self.added],
|
|
103
|
+
"removed": [f.to_dict() for f in self.removed],
|
|
104
|
+
"changed": [
|
|
105
|
+
{"before": a.to_dict(), "after": b.to_dict()} for a, b in self.changed
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compute_commit_hash(
|
|
111
|
+
parent_hash: Optional[str],
|
|
112
|
+
snapshot_hash: str,
|
|
113
|
+
author: str,
|
|
114
|
+
message: str,
|
|
115
|
+
timestamp: str,
|
|
116
|
+
) -> str:
|
|
117
|
+
return content_hash(
|
|
118
|
+
{
|
|
119
|
+
"parent_hash": parent_hash,
|
|
120
|
+
"snapshot_hash": snapshot_hash,
|
|
121
|
+
"author": author,
|
|
122
|
+
"message": message,
|
|
123
|
+
"timestamp": timestamp,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memtrail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit and time-travel for AI agent memory. log, show, blame, diff, revert — callable from inside the agent's reasoning loop.
|
|
5
|
+
Author-email: Rahul Karda <rahulkarda2002@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agents,ai,audit,llm,memory,versioning
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Provides-Extra: cli
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# memtrail
|
|
28
|
+
|
|
29
|
+
> **Audit and time-travel for AI agent memory.** A drop-in versioned backend with `log`, `show`, `blame`, `diff`, and `revert` — callable from inside the agent's reasoning loop.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install memtrail
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from memtrail import MemTrail
|
|
41
|
+
|
|
42
|
+
mt = MemTrail("my-agent")
|
|
43
|
+
|
|
44
|
+
# Commit a fact the agent learned
|
|
45
|
+
c1 = mt.commit(
|
|
46
|
+
facts=[{"id": "user.tz", "content": "America/Los_Angeles"}],
|
|
47
|
+
message="learned user timezone from greeting",
|
|
48
|
+
author="agent",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Later, investigate the memory
|
|
52
|
+
mt.blame("user.tz") # → which commit introduced this fact?
|
|
53
|
+
mt.show(c1.hash) # → full snapshot at that point
|
|
54
|
+
mt.log(limit=10) # → recent history
|
|
55
|
+
mt.revert(c1.hash) # → roll back to before a bad change
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Use as agent tools
|
|
59
|
+
|
|
60
|
+
memtrail ships tool schemas in two shapes. The schemas describe the same six
|
|
61
|
+
operations; pick whichever your model provider expects.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from memtrail import MemTrail
|
|
65
|
+
from memtrail.tools import anthropic_tools, openai_tools
|
|
66
|
+
|
|
67
|
+
mt = MemTrail("my-agent")
|
|
68
|
+
|
|
69
|
+
tools, dispatch = anthropic_tools(mt) # input_schema shape
|
|
70
|
+
# or:
|
|
71
|
+
tools, dispatch = openai_tools(mt) # function-tool shape
|
|
72
|
+
|
|
73
|
+
# When the model emits a tool call, route it:
|
|
74
|
+
result = dispatch(tool_name, tool_input)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The six tools exposed: `memtrail_commit`, `memtrail_log`, `memtrail_show`,
|
|
78
|
+
`memtrail_blame`, `memtrail_diff`, `memtrail_revert`.
|
|
79
|
+
|
|
80
|
+
## CLI
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
memtrail commit --repo my-agent --fact 'user.tz="PST"' -m "learned tz"
|
|
84
|
+
memtrail log --repo my-agent
|
|
85
|
+
memtrail show --repo my-agent <commit>
|
|
86
|
+
memtrail blame --repo my-agent <fact_id>
|
|
87
|
+
memtrail diff --repo my-agent <commit_a> <commit_b>
|
|
88
|
+
memtrail revert --repo my-agent <commit>
|
|
89
|
+
memtrail facts --repo my-agent # list facts at HEAD
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Repo state lives at `~/.memtrail/<repo>.db` by default. Override with
|
|
93
|
+
`--db /path/to.db` or the `MEMTRAIL_HOME` environment variable.
|
|
94
|
+
|
|
95
|
+
## Data model
|
|
96
|
+
|
|
97
|
+
- **Fact**: `{id, content, metadata}` — the atomic memory unit. Caller-provided `id`s enable targeted blame.
|
|
98
|
+
- **Snapshot**: a set of facts at a point in time, content-addressed by SHA-256 of canonical JSON.
|
|
99
|
+
- **Commit**: `{hash, parent_hash, snapshot_hash, author, message, timestamp}` — forms an append-only DAG.
|
|
100
|
+
|
|
101
|
+
`commit(facts=...)` upserts onto the parent snapshot (matched by `id`). Use
|
|
102
|
+
`commit(remove=[...])` to drop facts, or `commit_snapshot(facts=...)` for full
|
|
103
|
+
replace.
|
|
104
|
+
|
|
105
|
+
## Status
|
|
106
|
+
|
|
107
|
+
`v0.1` — alpha. Zero runtime dependencies. Python ≥ 3.9.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
memtrail/__init__.py,sha256=2SwZtdvd0T3DxonSmGUOTTeMCpIt3bNx8UDBCAzzxH4,252
|
|
2
|
+
memtrail/api.py,sha256=ze7rFTitc7GrHQxlszdzIZdxYxX75d0xVRy1Hh2-iU0,10388
|
|
3
|
+
memtrail/cli.py,sha256=XodgQRl5LIlQtYoqOnp9rFxdmBvvf44a1NTeMWUgKLo,5704
|
|
4
|
+
memtrail/store.py,sha256=1eaWG7zpfX_oF5-h2W5G5own4IAHi_cIgxDorkKHkuY,6208
|
|
5
|
+
memtrail/tools.py,sha256=yFNUKF8WqOcq4BvBtmHBsdpVmfHw8T9n3slG8z2vvrU,8716
|
|
6
|
+
memtrail/types.py,sha256=TJUZ73zaWqpm87v2yPsQunU3eQdY-BghcCyMRliFUcE,3404
|
|
7
|
+
memtrail-0.1.0.dist-info/METADATA,sha256=J-8hCszMTsswP-P3jrHmaMQMQFDXG9_C-Uz4p59-bTY,3581
|
|
8
|
+
memtrail-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
memtrail-0.1.0.dist-info/entry_points.txt,sha256=6v8-8ytJXkpiIOgycEpmMLqf83BNPLJbkpcK0ymuhsc,47
|
|
10
|
+
memtrail-0.1.0.dist-info/licenses/LICENSE,sha256=iHR4fQx3If46fsGTjlhYxqY6rodQDjlQN9S9b3PpyjM,1068
|
|
11
|
+
memtrail-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rahul Karda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|