Memory you own
obsidian-tc’s memory graph is not a vendor-hosted “memory feature” — it is plain Markdown files in
your vault, versioned by whatever git history your vault already has, queryable with the same
tools that read any other note. This page shows the actual on-disk shape (generated from a real
create_entity + add_observation + link_entities run, pasted verbatim, not invented), names
the real modules every write goes through, and gives a copy-pasteable recipe for reading a small
index at session start and writing back at session close.
What an entity, an observation, and a relation are on disk
Section titled “What an entity, an observation, and a relation are on disk”A memory entity (packages/server/src/memory/entities.ts) is one row in the
memory_entities table (SQLite, the source of truth) plus, when materialize: true (the
default), a regenerable .md projection under the vault’s memory folder — so its [[links]]
resolve in Obsidian’s own graph view. An observation is one fact, stored newline-delimited on
that row (serializeObservations/parseObservations) and rendered as one bullet. A relation
is a typed, directed edge in memory_relations, rendered as a [[wikilink]] under ## Related on
the source entity’s note.
The shape below is a real note — specifically, the output of obsidian-tc memory import --from basic-memory --apply (the importer covered later on this page), which itself calls create_entity,
update_frontmatter (the imported_from/source_path/imported_at keys — the importer is what
adds those, not a bare create_entity call), and link_entities, in that order — run against a
scratch copy of
examples/scratch-vault
and then read back off disk. A note you create with create_entity/add_observation directly (an
MCP client, not the importer) looks the same MINUS the three imported_* keys — those are specific
to notes the importer touched.
---obsidian_tc_id: ent_72d95a75a7a5c176ece73b07entity_type: notestatus: activeimported_from: basic-memorysource_path: notes/coffee-brewing.mdimported_at: 2026-09-25T05:22:47.231Z---# Coffee Brewing Methods
## Observations
- [method] Pour over provides more flavor clarity than French press- [technique] Water temperature at 205F extracts optimal compounds #brewing
## Related
- relates_to [[Tea Brewing Methods]]obsidian_tc_id/entity_type/status are the ONLY frontmatter keys the projection owns
(packages/server/src/memory/materialize.ts’s OWNED_FM_KEYS) — every other key, including the
imported_from/source_path/imported_at provenance keys above, is preserved verbatim across
every future re-materialization (another add_observation, another link_entities). That is how
obsidian-tc memory import (below) can layer provenance onto a note without a special-cased write
path of its own — it merges frontmatter through the same update_frontmatter tool an MCP client
would call, and materialize.ts’s ordinary round-trip discipline keeps it.
Facts change: validity intervals
Section titled “Facts change: validity intervals”A plain add_observation call still just appends — nothing below changes that. Pass an explicit
key (a short slug, ^[a-z0-9][a-z0-9_.-]*$) when you want the NEW fact to replace a specific
OLDER one instead of sitting alongside it: the prior observation with that same key is closed (not
deleted — its text stays on the note, under a new ## Superseded section) and the new text opens.
Two calls against a fresh Priya entity, one key: "team" observation superseding another:
// add_observation { vault, entity_id, observation: "on the platform team", key: "team" } (2025-09-13)// add_observation { vault, entity_id, observation: "on the memory team", key: "team" } (2025-09-25)produce this note (real output, ## Superseded dates are day-granularity — the note is a
human-readable projection, not a full timestamp serialization):
# Priya
## Observations
- [team] on the memory team
## Superseded
- [team] on the platform team (valid 2025-09-13 → 2025-09-25)
## Related
_No relations._An observation added with no key never supersedes anything and is never superseded
automatically — matching is always by this explicit key, never inferred from text similarity. To
retire a keyed fact with nothing replacing it, call add_observation with key + valid_to and
no observation at all.
get_entity and query_entity_graph both take an as_of (epoch ms; default now) and return only
the observations valid at that instant — valid_from <= as_of and (valid_to unset or as_of < valid_to). An as_of in the PAST excludes any observation added after that instant, even one
that is still open today. Querying the same entity above at two points in time (real output):
// get_entity { vault, entity_id, as_of: 1758000000000 } // 2025-09-16, before the change{ "observations": [ { "text": "on the platform team", "key": "team", "valid_from": 1757800000000, "valid_to": 1758800000000, "superseded_by": "75f318492bfae16f1de56c572f4c7dc476f5c38627062843cfda05bec83f191e" } ]}
// get_entity { vault, entity_id, as_of: 1759000000000 } // now, after the change{ "observations": [ { "text": "on the memory team", "key": "team", "valid_from": 1758800000000, "valid_to": null, "superseded_by": null } ]}query_entity_graph filters every node’s observations the same way, so a graph traversal answers
“what did we believe as_of D” identically to a direct get_entity call on each node it passes
through.
The ACL/audit pipeline these writes go through
Section titled “The ACL/audit pipeline these writes go through”Every one of those writes — create_entity, add_observation, link_entities, and
update_frontmatter — is a normal MCP tool (packages/server/src/tools/m5/memory-tools.ts,
packages/server/src/tools/m1/frontmatter-tools.ts), dispatched through
ToolRegistry.dispatch (packages/server/src/mcp/registry.ts). That single call site is where:
- the caller’s scope is checked (
write:memory/write:notes) against the granted set; - the folder ACL is enforced (
packages/server/src/vault/acl-path.ts’senforcePathAcl) — pre-checked BEFORE the SQLite insert, so a denied materialization leaves no orphan row; - an
audit_eventsrow is written (recordOutcome, insidedispatch) — caller, tool name, duration, result size, status, an args hash; never the raw content.
There is no separate “memory write” code path that skips this. The CLI importer described below
builds its own ToolRegistry, registers the real M1 (notes/frontmatter) and M5 (memory) tools, and
calls registry.dispatch(...) for every entity/observation/relation/frontmatter write — the exact
call an MCP client makes, ACL-checked and audited the same way, with no direct file write at
all.
Git provenance
Section titled “Git provenance”obsidian-tc does not auto-commit. Materialized memory notes are ordinary files in your vault, so “who wrote this and when” is answered the same way it is for any other note:
- Plain
git log -p -- memory/note/…on the vault repo, if you keep it under git — every materialization is a normal file write (writeNoteAtomic), so it shows up ingit status/git difflike anything else you’d stage and commit yourself. - The Obsidian Git companion bridge (
git_status/git_diff/git_log/git_commit,packages/server/src/tools/m4/git-tools.ts) lets an agent drive that same repo through the ACL/ audit pipeline above —git_commitrequiresexecute:git, a hardcoded human-confirmation floor, so an agent can stage and propose a commit but never lands one without you approving it. - If your vault’s Obsidian Git plugin has its own autosave/auto-commit interval configured, that is the plugin’s own author/timestamp policy, independent of obsidian-tc.
Either way, the provenance frontmatter above (source_path, imported_at) already answers “where
did this come from” without needing git at all — git only adds “and when did it change since.”
Recall: with and without semantic search
Section titled “Recall: with and without semantic search”Without semantic search — plain full-text, no embeddings provider needed. Real output,
search_text over the same scratch vault:
{ "vault": "main", "mode_used": "text", "items": [ { "path": "memory/note/Coffee Brewing Methods.md", "score": 3.1793160834963974, "line": 13, "col": 56, "snippet": "- [method] Pour over provides more flavor clarity than French press" } ], "total": 1}Graph recall — walk the [[link]] graph from a known entity, also no embeddings needed. Real
output, query_entity_graph seeded on the entity above:
{ "vault": "main", "seed_entity_id": "ent_72d95a75a7a5c176ece73b07", "items": [ { "entity_id": "ent_791ed1d7a083f6cd98476c0d", "type": "note", "name": "Tea Brewing Methods", "status": "active", "distance": 1, "path": [{ "via_entity_id": "ent_72d95a75a7a5c176ece73b07", "via_relation": "relates_to" }] } ], "next_cursor": null, "total_returned": 1}With semantic search — search_semantic embeds the query and ranks chunks by vector
similarity over the same indexed notes (including materialized memory notes, which are indexed
like any other vault content). It needs a configured embeddings provider (see
Configuration); this page does not fabricate a transcript for
a provider it did not actually call — see MCP client compatibility
for this project’s own policy on measured-only rows. The practical difference from the two recall
paths above: full-text and graph recall find what you named or linked; semantic recall also finds
what you meant — a query for “hot drink technique” would rank the coffee note above without the
word “coffee” ever appearing in it, something neither search_text nor a graph walk from an
unrelated seed can do.
Opt-in: episode_stats — memory tools in your own top activity
Section titled “Opt-in: episode_stats — memory tools in your own top activity”episode_stats (read:workspace) aggregates the experiential episode log into counts only —
never episode content — so you can see activity patterns without the fuller admin:workspace read
access work_search/work_episodes require. Real output, group_by: "tool", after a short mixed
session of create_entity/get_entity/add_observation/link_entities/query_entity_graph
calls against the same scratch vault (min_bucket: 2 — buckets smaller than that are withheld
into suppressed, a k-anonymity floor, not a redaction of these results):
{ "available": true, "group_by": "tool", "min_bucket": 2, "buckets": [ { "key": "get_entity", "count": 2 }, { "key": "create_entity", "count": 2 } ], "suppressed": 3, "suppressed_buckets": 3, "total": 7}Memory tools (create_entity, get_entity) sit at the top because this was a memory-heavy
session; run it on your own deployment after ordinary use and it reflects what YOUR agent actually
spends its calls on.
Importing memory you already have
Section titled “Importing memory you already have”obsidian-tc memory import brings notes from two other memory formats into this graph, through the
exact write path above — dry-run by default, --apply to write. <dir> is the source directory;
[path] (or --config <path>) is the same config-path argument every other command here takes —
a bare vault folder, a config file, or omitted to fall back to OBSIDIAN_TC_CONFIG:
obsidian-tc memory import --from basic-memory <dir> [path] --vault <id> [--apply] [--resume]obsidian-tc memory import --from claude-code-memory <dir> [path] --vault <id> [--apply] [--resume]Every write goes through the SAME per-vault ACL an MCP client’s calls would see — a readOnly root
or a writePaths allowlist in the resolved config is enforced, not bypassed, for this command’s
--vault. Before touching anything, the command prints where it landed:
vault: /tmp/mi-redemo/vault cache: /tmp/mi-redemo/cache mode: dry-runno cache yet at /tmp/mi-redemo/cache — first --apply will create it; every entity below would be newly created.A dry run against a cacheDir that does not exist yet opens no database at all (an ephemeral
in-memory one stands in for “nothing exists yet”); against one that already exists, it opens it
read-only — a dry run never creates or writes to the cache.
| source | one entity per… | name / type from | observations from | relations from |
|---|---|---|---|---|
basic-memory |
note | frontmatter title / type |
## Observations bullets (- [category] text) — category becomes the observation’s key (see “Facts change” above) whenever it passes add_observation’s key regex, so re-importing a note whose bullet’s category is unchanged SUPERSEDES the old text instead of appending a duplicate; a category that doesn’t pass the regex is kept as literal text with no key, same as before |
## Relations bullets (- relation_type [[Target]]); a link inside inline code or a fenced block is ignored (example text, not a real relation) |
claude-code-memory |
fact file (the index file, exactly MEMORY.md at <dir>’s root, is skipped — on a case-insensitive filesystem, macOS/Windows by default, a root memory.md IS that same file and is skipped too, since the OS never let a second one coexist there) |
frontmatter name / metadata.type |
the whole body — headings, list markers, and fenced code all flattened — as ONE observation; this adapter has no per-fact key convention to map, so every observation is unkeyed and re-importing an edited fact file appends a new observation rather than superseding the old one | every [[link]] in the body outside code/fences, as a relates_to relation |
Every imported note carries imported_from/source_path/imported_at provenance frontmatter
(merged on, per the round-trip discipline above), and a re-run of --apply on the same directory
is idempotent — it is keyed on source_path, not merely on name, so an entity that already exists
with a different (or unverifiable) source_path is refused as a collision rather than silently
adopted (the same “a path that can overwrite data must be at least as strict as the path that wrote
it” discipline the vault’s own delete paths follow — collisions are reported AND make the command
exit non-zero). --resume relaxes that refusal, but ONLY for an entity with a row and zero
observations — the exact shape a run interrupted between create_entity and its follow-up
update_frontmatter leaves behind — never for one with real content already written. Two source
files whose (type, name) sanitize to the same memory-note path (an outright duplicate title, or
two different pairs that happen to collide after sanitization) are also a collision, reported at
PREVIEW time — the first file by source_path wins. Files are refused, with a reason, if they are
symlinked, dot-prefixed, or if their resolved path escapes the import directory — the same
containment guarantee (resolveVaultPathChecked, packages/server/src/vault/paths.ts) every
vault-relative path write already uses — and a missing, unreadable, or not-a-directory <dir>
fails immediately rather than reporting an empty (and misleadingly successful-looking) import.
Real dry-run output against the basic-memory fixture above (one note references a target that
does not exist in the batch, and one file has deliberately malformed frontmatter) — the banner
lines above are followed by:
obsidian-tc memory import --from basic-memory: DRY RUN (nothing was written; pass --apply to write)
Entities:action type name source_path observations------ ---- ---------------------- ----------------------------- -----------------create note Coffee Brewing Methods notes/coffee-brewing.md +2 observation(s)create note plain-no-frontmatter notes/plain-no-frontmatter.md +1 observation(s)create note Tea Brewing Methods notes/tea-brewing.md +1 observation(s)
Relations:source relation_type target status reason---------------------- -------------- ------------------------- ------- ----------------------------------------------------Coffee Brewing Methods relates_to Tea Brewing Methods plannedCoffee Brewing Methods requires Proper Grinding Technique skipped relation target not found: Proper Grinding TechniqueTea Brewing Methods contrasts_with Coffee Brewing Methods planned
Summary: 3 entity(ies) to create, 0 already present, 0 resumed, 0 collision(s), 0 error(s); 4 observation(s) to add, 0 already present; 2 relation(s) to create, 0 already present, 1 skipped
Skipped files:source_path reason------------------ -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------notes/malformed.md malformed frontmatter: frontmatter is not valid YAML in "notes/malformed.md": Flow sequence in block collection must be sufficiently indented and end with a ] at line 2, column 1:--apply runs the same plan for real (mode: apply in the banner, and create becomes an actual
write — Coffee Brewing Methods relates_to Tea Brewing Methods created instead of planned).
Re-running with --apply a second time reports the same set as already present, not duplicated:
Entities:action type name source_path observations------ ---- ---------------------- ----------------------------- -------------------------exists note Coffee Brewing Methods notes/coffee-brewing.md +0 new, 2 already presentexists note plain-no-frontmatter notes/plain-no-frontmatter.md +0 new, 1 already presentexists note Tea Brewing Methods notes/tea-brewing.md +0 new, 1 already present
Summary: 0 entity(ies) to create, 3 already present, 0 resumed, 0 collision(s), 0 error(s); 0 observation(s) to add, 4 already present; 0 relation(s) to create, 2 already present, 1 skippedSession-bootstrap recipe
Section titled “Session-bootstrap recipe”The pattern above generalizes into an ordinary session workflow: at session start, read a small
index note; load only the domain block the current task needs; write back at session close. It
needs no obsidian-tc feature beyond the tools already covered on this page —
search_text/query_entity_graph/get_entity to read, create_entity/add_observation/
link_entities to write back. Keep the index small on purpose: it is a table of contents, not the
memory itself, so loading it never costs more than a few tool calls regardless of how large the
graph underneath grows.
# Session-bootstrap prompt template
At the start of this session:1. Read the memory index note for this vault (search_text for its known title, or get_entity by a well-known name/type — whatever this vault uses as its index). Keep this to ONE read: the index lists domains and their entities, not their content.2. From the task you were given, identify which domain(s) it touches.3. Load ONLY those domains: query_entity_graph seeded on each relevant entity (depth 1-2), or get_entity for anything the index named directly. Do not load every domain "just in case" — that defeats the point of having an index.4. Proceed with the task using what you loaded, plus ordinary vault search (search_text/search_semantic) for anything the index did not anticipate.
At the close of this session, if the session produced a decision, a correction, or a factworth keeping:1. For a NEW fact: create_entity (type + name that fits this vault's existing taxonomy — check the index first so you do not invent a near-duplicate type) with materialize: true.2. For an EXISTING entity: add_observation with one fact per call — do not pack multiple facts into one observation string; it is rendered as one bullet.3. If the new fact relates to something else already in the graph: link_entities with a relation_type that describes the relationship in one or two words (matching the vocabulary the index/vault already uses, not inventing a new one per session).4. Do NOT write back speculative or unconfirmed information — this index is read at the START of every future session, and a wrong entry costs every session after this one, not just this one.This is deliberately generic — no vault-specific paths, domain names, or entity types. Fill in step
1’s “known title” / “well-known name” with whatever your own vault’s index note is actually called;
everything else composes with vault_context (which already does an automatic, TTL-cached version
of steps 1-3 for the common case) or reflect (for a grounded synthesis instead of a raw walk) if
your deployment has them wired.
Watch it work: two sessions
Section titled “Watch it work: two sessions”A static storyboard for now — docs/demo/memory-two-sessions.tape renders the animated version
(docs/public/demo/memory-two-sessions.gif) once vhs is on your PATH. Every panel above was
captured from the real commands on this page, run against a scratch copy of
examples/scratch-vault,
not invented.