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 +21 -0
- memtrail-0.1.0/PKG-INFO +111 -0
- memtrail-0.1.0/README.md +85 -0
- memtrail-0.1.0/pyproject.toml +57 -0
- memtrail-0.1.0/src/memtrail/__init__.py +7 -0
- memtrail-0.1.0/src/memtrail/api.py +307 -0
- memtrail-0.1.0/src/memtrail/cli.py +186 -0
- memtrail-0.1.0/src/memtrail/store.py +188 -0
- memtrail-0.1.0/src/memtrail/tools.py +242 -0
- memtrail-0.1.0/src/memtrail/types.py +125 -0
- memtrail-0.1.0/tests/test_basic.py +204 -0
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.
|
memtrail-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
memtrail-0.1.0/README.md
ADDED
|
@@ -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,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)
|