memtrail 0.1.0__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.
memtrail-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,85 @@
1
+ # memtrail
2
+
3
+ > **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.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install memtrail
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from memtrail import MemTrail
15
+
16
+ mt = MemTrail("my-agent")
17
+
18
+ # Commit a fact the agent learned
19
+ c1 = mt.commit(
20
+ facts=[{"id": "user.tz", "content": "America/Los_Angeles"}],
21
+ message="learned user timezone from greeting",
22
+ author="agent",
23
+ )
24
+
25
+ # Later, investigate the memory
26
+ mt.blame("user.tz") # → which commit introduced this fact?
27
+ mt.show(c1.hash) # → full snapshot at that point
28
+ mt.log(limit=10) # → recent history
29
+ mt.revert(c1.hash) # → roll back to before a bad change
30
+ ```
31
+
32
+ ## Use as agent tools
33
+
34
+ memtrail ships tool schemas in two shapes. The schemas describe the same six
35
+ operations; pick whichever your model provider expects.
36
+
37
+ ```python
38
+ from memtrail import MemTrail
39
+ from memtrail.tools import anthropic_tools, openai_tools
40
+
41
+ mt = MemTrail("my-agent")
42
+
43
+ tools, dispatch = anthropic_tools(mt) # input_schema shape
44
+ # or:
45
+ tools, dispatch = openai_tools(mt) # function-tool shape
46
+
47
+ # When the model emits a tool call, route it:
48
+ result = dispatch(tool_name, tool_input)
49
+ ```
50
+
51
+ The six tools exposed: `memtrail_commit`, `memtrail_log`, `memtrail_show`,
52
+ `memtrail_blame`, `memtrail_diff`, `memtrail_revert`.
53
+
54
+ ## CLI
55
+
56
+ ```bash
57
+ memtrail commit --repo my-agent --fact 'user.tz="PST"' -m "learned tz"
58
+ memtrail log --repo my-agent
59
+ memtrail show --repo my-agent <commit>
60
+ memtrail blame --repo my-agent <fact_id>
61
+ memtrail diff --repo my-agent <commit_a> <commit_b>
62
+ memtrail revert --repo my-agent <commit>
63
+ memtrail facts --repo my-agent # list facts at HEAD
64
+ ```
65
+
66
+ Repo state lives at `~/.memtrail/<repo>.db` by default. Override with
67
+ `--db /path/to.db` or the `MEMTRAIL_HOME` environment variable.
68
+
69
+ ## Data model
70
+
71
+ - **Fact**: `{id, content, metadata}` — the atomic memory unit. Caller-provided `id`s enable targeted blame.
72
+ - **Snapshot**: a set of facts at a point in time, content-addressed by SHA-256 of canonical JSON.
73
+ - **Commit**: `{hash, parent_hash, snapshot_hash, author, message, timestamp}` — forms an append-only DAG.
74
+
75
+ `commit(facts=...)` upserts onto the parent snapshot (matched by `id`). Use
76
+ `commit(remove=[...])` to drop facts, or `commit_snapshot(facts=...)` for full
77
+ replace.
78
+
79
+ ## Status
80
+
81
+ `v0.1` — alpha. Zero runtime dependencies. Python ≥ 3.9.
82
+
83
+ ## License
84
+
85
+ MIT.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "memtrail"
7
+ version = "0.1.0"
8
+ description = "Audit and time-travel for AI agent memory. log, show, blame, diff, revert — callable from inside the agent's reasoning loop."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Rahul Karda", email = "rahulkarda2002@gmail.com" }]
13
+ keywords = ["ai", "agents", "memory", "versioning", "audit", "llm"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ cli = []
30
+ dev = ["pytest>=7", "pytest-cov", "ruff"]
31
+
32
+ [project.scripts]
33
+ memtrail = "memtrail.cli:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/memtrail"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ only-include = [
40
+ "src/memtrail",
41
+ "tests",
42
+ "README.md",
43
+ "LICENSE",
44
+ "pyproject.toml",
45
+ ]
46
+ exclude = [
47
+ "**/__pycache__",
48
+ "**/*.pyc",
49
+ ]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ addopts = "-q"
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+ target-version = "py39"
@@ -0,0 +1,7 @@
1
+ """memtrail — audit and time-travel for AI agent memory."""
2
+
3
+ from memtrail.api import MemTrail
4
+ from memtrail.types import Commit, Fact, Snapshot, Diff
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["MemTrail", "Commit", "Fact", "Snapshot", "Diff", "__version__"]
@@ -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)