| description | Change awareness over an agent's memory via git-backed snapshots and tree diffs. |
|---|
The diff layer gives an agent change awareness over its own memory: after a source syncs, what did its world view gain, lose, or revise? TinyCortex answers this by snapshotting a source's already-ingested items into a git repository and diffing those snapshots with git's native tree-diff machinery.
The module is compiled only when the crate's git-diff Cargo feature is
enabled (it is off by default and pulls in git2/libgit2):
cargo test --features git-diffThe module lives under src/memory/diff/. The public entry point is
DiffEngine, generic over an injected
SnapshotItemSource. Domain types
(Snapshot, ItemChange, DiffResult, Checkpoint, CrossSourceDiff, …)
are in src/memory/diff/types.rs; the git persistence ("the ledger") is in
src/memory/diff/ledger.rs.
Change tracking is a problem git already solves: content-addressed blobs, tree-to-tree diffs, named baselines (tags), and lightweight pointers (refs). The diff layer maps its domain directly onto these primitives instead of reimplementing them.
| Diff concept | Git primitive | Identifier |
|---|---|---|
| Snapshot | commit | Snapshot.id is the commit SHA |
| Checkpoint | annotated tag at HEAD | ckpt_<uuid> |
| Read marker | ref | refs/openhuman/read/<encoded_source_id> → commit SHA |
| Diff | git diff <from-tree>..<to-tree> scoped to one source path |
— |
The ledger is a libgit2 repository at <workspace>/memory_diff/repo
(Ledger::open creates it on first use, mirroring MemoryConfig::workspace).
The diff ledger is not a source of truth. The chunk store (whatever backs the injected item source) stays authoritative for memory content; the ledger is a derived, rebuildable git view used purely for change tracking. This honours the core TinyCortex invariant that derived indexes can be discarded and rebuilt.
Two consequences:
- Snapshots are built from already-ingested data supplied by the item source — never by re-calling upstream source readers. Diffs therefore cost zero upstream API calls.
- The diff engine is decoupled from
chunksandsources. It takes aSnapshotItemSourceby injection (InMemoryItemSourceis the reference/test backend) and callers pass aSourceDescriptor(id/kind/label) they already hold, rather than the engine reaching into a source registry.
pub struct DiffEngine<S: SnapshotItemSource> {
workspace: PathBuf, // ledger lives at <workspace>/memory_diff/repo
items: S, // injected, yields a source's already-ingested items
}
DiffEngine::new(workspace, items) -> Self
All operations are synchronous. Every git mutation (commit, tag, ref update)
serialises through a process-global WRITE_LOCK inside the Ledger, because
libgit2's HEAD/parent resolution is read-modify-write and concurrent commits
could otherwise fork history or lose a snapshot.
| Method | Purpose |
|---|---|
take_snapshot(source, trigger) |
Capture one source's current items as a commit |
auto_snapshot_after_sync(source) |
take_snapshot with SnapshotTrigger::Auto |
list_snapshots(source_id, limit) |
Snapshots newest-first, optionally filtered |
compute_diff(from, to, include_text_diff) |
Diff an explicit snapshot pair (same source) |
diff_since_last(source_id, …) |
Latest snapshot vs the previous one |
diff_since_read(source_id, …, commit) |
Latest vs the source's read marker, optionally advancing it |
mark_read(source_ids) |
Advance read markers to current heads |
create_checkpoint / list_checkpoints |
Named cross-source baselines (tags) |
diff_since_checkpoint |
Aggregate diff across sources since a checkpoint |
cleanup |
Prune old checkpoint tags |
Descriptor-keyed conveniences diff_since_last_for and diff_since_read_for
take a SourceDescriptor for callers that already hold one.
A Snapshot is a point-in-time capture of one source's ingested items:
Snapshot {
id: String, // commit SHA — the snapshot's stable id
source_id: String,
source_kind: String, // wire string, e.g. "folder", "composio"
label: String,
trigger: SnapshotTrigger,// auto | manual
item_count: u32,
taken_at_ms: i64,
}
take_snapshot reads items_for_source(&source.id) from the injected source
(grouped by item id and ordered), then Ledger::commit_snapshot writes one
git blob per item into a subtree named for the source, grafts that subtree
into the root tree carried forward from the parent commit (so HEAD always
reflects the whole world across every source), and commits to HEAD. If the
item list is empty, the source's subtree is removed instead.
Metadata with no natural git home rides in the commit message as Key: value
trailers (Source-Id, Source-Kind, Source-Label, Trigger,
Item-Count, Taken-At-Ms). snapshot_from_commit reconstructs the
Snapshot by parsing those trailers, falling back to the commit time when the
millisecond trailer is missing and defaulting an unrecognised trigger to
Auto. Label values are sanitised to a single line so a multi-line label
cannot corrupt the trailer block.
| Variant | Wire string | When |
|---|---|---|
Auto |
auto |
Captured automatically after a successful source sync |
Manual |
manual |
Captured on explicit user/agent request, or as checkpoint baselining |
auto_snapshot_after_sync(source) is the hook a host calls right after it has
finished ingesting a sync. Because TinyCortex does not own when to sync
(OpenHuman or the host decides that — see Sources), this is the
boundary call that records "the sync landed; here is the new state." It is
exactly take_snapshot(source, SnapshotTrigger::Auto).
Ids are turned into git-safe path components by encode_item_id /
encode_source_id (encode_source_id is the same encoding, kept as a named
helper so the source-vs-item boundary is explicit at call sites):
- An
i_prefix keeps the result clear of the reserved names.,.., and empty. - Each byte outside
[A-Za-z0-9._-]becomes%XX(uppercase hex).
So an item id notes/2026.md encodes to i_notes%2F2026.md.
decode_item_id is the exact inverse and recovers the original id from a
ledger path component. The tree layout is therefore flat per source:
<repo>/
i_<encoded_source_id>/
i_<encoded_item_id> # one blob = full concatenated item content
i_<encoded_item_id>
i_<encoded_other_source>/
...
Item identity is the file name, never the title. A content change keeps the
same blob name → Modified; renaming an item id is reported as Removed plus
Added, matching the id-keyed semantics.
compute_diff resolves both snapshots, rejects cross-source pairs (from
and to must share a source_id), and delegates to Ledger::compute_changes,
which runs diff_tree_to_tree with a pathspec scoped to the encoded source id
and 3 context lines. The pathspec match is re-checked with a trailing-slash
prefix (<encoded_source_id>/) to guard against prefix overreach (so src_a
does not match src_abc/...).
Each git delta maps to a ChangeKind:
git2::Delta |
ChangeKind |
|---|---|
Added / Copied / Untracked |
Added |
Deleted |
Removed |
Modified / Renamed / Typechange |
Modified |
| unmodified / ignored / conflicted | (skipped) |
Each surviving delta becomes an ItemChange:
ItemChange {
item_id: String, // decoded from the ledger path
title: String, // first non-empty content line, '#' stripped, ≤120 chars; id fallback
kind: ChangeKind, // added | removed | modified
old_content_hash: Option<String>,// blob oid on the `from` side (None when added)
new_content_hash: Option<String>,// blob oid on the `to` side (None when removed)
text_diff: Option<String>, // bounded unified patch, modifications only, on request
}
Content hashes are the git blob oids (the zero oid on an absent side decodes as
None). When include_text_diff is set, a Modified item also carries a
unified patch rendered from the delta and truncated to MAX_TEXT_DIFF_CHARS
(2000 chars, with a …(truncated) marker).
The DiffSummary aggregates counts. Git only reports changed entries, so
unchanged is derived as to_item_count − added − modified (saturating):
DiffResult {
source_id, source_kind, source_label,
from_snapshot_id: Option<String>, // None = first-ever diff (all added)
to_snapshot_id: String,
summary: DiffSummary { added, removed, modified, unchanged },
changes: Vec<ItemChange>,
}
diff_since_last(source_id, …)— fetches the two latest snapshots for the source. Two → diff previous vs latest; one → the whole source is reported as added (from = None); zero → error.diff_since_read(source_id, …, commit)— diffs the latest snapshot against the source's read marker, i.e. everything that changed since the agent last read this source's diff. Withcommit = truethe marker (a git ref) is advanced to the head snapshot after the diff is computed, so the next call returns only newer changes;commit = falsepreviews without acknowledging. A marker pointing at a commit that no longer resolves is treated as unread (a full diff).
A read marker is a git ref refs/openhuman/read/<encoded_source_id> pointing at
the last snapshot the agent acknowledged for that source.
get_read_marker / set_read_marker read and advance it.
mark_read(source_ids) advances each listed source's marker to its current head
snapshot, skipping sources with no snapshots, and returns how many markers were
set. The caller supplies the ids explicitly — the diff layer does not own the
source registry, so to "mark all read" you pass every enabled source's id.
A Checkpoint is a named, cross-source baseline: the latest snapshot per
source at one moment, recorded as an annotated git tag ckpt_<uuid> at HEAD.
Checkpoint {
id: String, // tag name ckpt_<uuid>
label: String,
created_at_ms: i64,
snapshot_ids: Vec<String>, // per-source head snapshot ids at checkpoint time
}
The label and per-source head ids ride in the tag message as JSON
(checkpoint_message / checkpoint_from_message). create_checkpoint
requires at least one snapshot (HEAD must exist). list_checkpoints enumerates
ckpt_* tags newest-first.
diff_since_checkpoint produces a CrossSourceDiff — a per-source diff
aggregated across every source that changed since the checkpoint (unchanged
sources are omitted) plus a summed DiffSummary.
cleanup (cleanup_checkpoints) deletes checkpoint tags created before a
cutoff and returns the count removed. Snapshot commits are retained — git
history is the ledger — so cleanup only prunes named baselines, never the
underlying change record.
{% hint style="info" %}
The ledger is fully rebuildable: because snapshots derive from already-ingested
items and never from upstream reads, you can delete <workspace>/memory_diff/repo
and regenerate it without touching authoritative memory content.
{% endhint %}