Write through a session
memhtml session start --id s1memhtml session put --id s1 --file ops.jsonlmemhtml session exec --id s1 <<'SH'grep -rl 'checkout-api' /mnt/memhtml/projectsSHmemhtml session status --id s1memhtml session commit --id s1 --message "two facts about the checkout api"memhtml session rebase --id s1 # only after a rebase-needed answermemhtml session prune --older-than 7d --dry-runA session is a small log of operations that sits on top of one version of the corpus. Each session put or session exec adds operations to the log. Nothing reaches git until session commit lands the whole log as one commit. The store’s write bar judges every record when you add it and again when you commit it.
memhtml write and memhtml apply still commit straight to the store, with no write bar. Inside an agent run that exports MEMHTML_SESSION, the store’s default policy closes them and points you at the session commands instead. Bind a whole run covers that case.
Start a session
Section titled “Start a session”memhtml session start --id s1{ "apiVersion": "1", "type": "session.started", "data": { "id": "s1", "baseSha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "ref": "refs/heads/main", "ops": 0, "head": { "sha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "records": 3, "skipped": 1, "loadMs": 44, "source": "git", "snapshotPath": null, "ancestorSha": null, "reparsed": null } }}The id is one path segment of letters, digits, ., _, and -, starting with a letter or digit. The session’s log lives at .memhtml/sessions/<id>.json, which git ignores. baseSha is the commit the session sees, and ref is where its commit will land. The ref is refs/heads/main whatever branch the checkout has open, unless you pass --ref, so a store on master passes --ref master.
Starting an id that already has a log is refused with ERR_STORAGE (session.exists), so a retried start can’t throw away work in progress. --force replaces the log on purpose.
head says how the corpus was loaded. git is a full read of the tree. snapshot means a cached copy of that exact commit was read from .memhtml/snapshots/, and snapshot+advance means an older snapshot was read and brought forward over the files that changed. A session commit and curate merge write a snapshot of the commit they land, so once a session has committed, most loads are fast. A memhtml write or a hand git commit writes none, so the next load advances an older snapshot, or reads git when there is none.
Add records with session put
Section titled “Add records with session put”session put reads JSONL, one operation per line. A write line takes the same fields as a memhtml apply line:
cat > ops.jsonl <<'EOF'{"op":"write","title":"The checkout api retries twice","type":"semantic","body":"The checkout api retries a failed charge twice before it gives up.","entity":"service:checkout-api"}EOFmemhtml session put --id s1 --file ops.jsonl{ "apiVersion": "1", "type": "session.appended", "data": { "id": "s1", "started": false, "baseSha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "appended": 1, "ops": 1, "paths": ["areas/inbox/the-checkout-api-retries-twice.html"], "labels": [], "neighbors": [ { "path": "areas/inbox/the-checkout-api-retries-twice.html", "hits": [ { "path": "resources/runbook/drain-the-vip-before-reverting-a-deploy.html", "score": 0.01639344262295082, "arms": ["lexical"], "claim": "If a prod rollback is issued, drain the VIP before reverting the deploy." } ] } ], "vector": { "used": false, "reason": "embedder-off", "detail": null, "cached": null, "coverage": null, "loadMs": null, "embedMs": null }, "violations": [], "head": { "sha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "records": 3, "skipped": 1, "loadMs": 39, "source": "git", "snapshotPath": null, "ancestorSha": null, "reparsed": null } }}The store renders each line into the file it would write and picks the path it would pick. neighbors lists up to three records nearest each new one, ranked the way memhtml head search ranks. Read them before you commit. A neighbor that already states your fact means you can drop the line, and a related one is worth a link. vector says whether the vector arm ranked the neighbors. When it can’t run, the neighbors come from the lexical arm alone and the put still succeeds.
Two more line shapes edit an existing record’s entities without touching its article, so the record keeps its content hash:
{"op":"label","path":"areas/inbox/the-checkout-api-retries-twice.html","entity":"system:payments"}{"op":"unlabel","path":"areas/inbox/the-checkout-api-retries-twice.html","entity":"system:payments"}A label value is a lowercase type:name the record doesn’t carry yet. An unlabel names a value the record carries, spelled exactly as the record spells it.
--file - reads stdin, which suits a one-line put:
printf '%s\n' '{"op":"write","title":"Refunds settle within five days","type":"semantic","body":"A refund on the checkout api settles within five business days.","entity":"service:checkout-api"}' \ | memhtml session put --id s1 --file -Write files with session exec
Section titled “Write files with session exec”session exec runs a script over the session’s view of the corpus and turns what the script changed into operations. The corpus is mounted writable at /mnt/memhtml, and the script is bash unless you pass --lang js. A quoted heredoc opener (<<'SH') keeps your own shell from expanding anything, so the script arrives byte for byte:
memhtml session exec --id s1 <<'SH'mkdir -p /mnt/memhtml/projects/checkoutcat > /mnt/memhtml/projects/checkout/charges-settle-within-a-day.html <<'HTML'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Charges settle within a day</title><meta name="memhtml-type" content="semantic"><meta name="memhtml-status" content="active"><meta name="memhtml-created" content="2026-10-18T12:00:00Z"><meta name="memhtml-updated" content="2026-10-18T12:00:00Z"><meta name="memhtml-entity" content="service:checkout-api"></head><body><article><p><mark>A card charge on the checkout api settles within one business day.</mark> Refunds take longer.</p></article></body></html>HTMLSH{ "apiVersion": "1", "type": "session.exec.report", "data": { "id": "s1", "baseSha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "lang": "bash", "corpusMount": "/mnt/memhtml", "sha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "exitCode": 0, "stdout": "", "stderr": "", "durationMs": 23, "timeoutMs": 30000, "timedOut": false, "ops": 1, "appended": 1, "opsTotal": 2, "harvested": [{ "kind": "put", "path": "projects/checkout/charges-settle-within-a-day.html" }], "rejected": [], "violations": [], "blocking": [], "started": false, "head": { "sha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "records": 3, "skipped": 1, "loadMs": 41, "source": "git", "snapshotPath": null, "ancestorSha": null, "reparsed": null }, "server": { "used": false, "base": null, "reason": "no-server", "detail": "ENOENT" } }}After the script exits, the harvester compares every file with what it seeded and reads each change as one operation:
| What the script did | What the log gets |
|---|---|
Wrote a new .html file |
a put |
Added or removed a <link rel="memhtml-..."> line in an existing record’s head |
a link or an unlink |
Added or removed a <meta name="memhtml-entity"> line in an existing record’s head |
a label or an unlabel |
Moved a record to its archive/<YYYY>/<path> twin |
an archive |
| Moved a record, bytes unchanged, to exactly one new live path | a move |
| Deleted a record with no copy, or edited its head in any other way | a rejected entry naming the reason |
| Changed an existing record’s article | a put the commit refuses (claim-edit) |
An archive takes its year’s first free slot. When a record archived earlier in the year already holds archive/<YYYY>/<path>, move the file to archive/<YYYY>.2/<path> instead, then .3, and so on. A script that moves a file onto a held slot gets an archive the harvest is blocked on, and its violation names the free slot.
A move keeps the record’s bytes, so its content hash and its history come with it. Every live record that links to the old path has to drop that edge in the same script: cut the old <link> line, and add one with the new path to keep the edge. Otherwise the harvest is blocked and names the edge to fix.
The harvest is added to the log only when the script exits 0 and no harvested operation breaks a rule a rebase can’t fix. blocking lists those violations, and appended is 0 when it lists any. A non-zero exitCode means your script failed, and memhtml itself still exits 0.
The sandbox has no network, and curl isn’t a command. --timeout-ms bounds the run (30000 by default, 600000 at most). A run past it reports exitCode 124 with timedOut: true. The script and its request together can’t exceed 1 MiB, so a bigger batch is several calls.
Check the session
Section titled “Check the session”memhtml session status --id s1{ "apiVersion": "1", "type": "session.status", "data": { "id": "s1", "ref": "refs/heads/main", "baseSha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "refSha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "behind": false, "ops": 2, "puts": 2, "archives": 0, "links": 0, "unlinks": 0, "labels": 0, "unlabels": 0, "moves": 0, "paths": [ "areas/inbox/the-checkout-api-retries-twice.html", "projects/checkout/charges-settle-within-a-day.html" ], "pending": null }}behind: true means the ref moved past the session’s base, so the session needs a rebase before its commit can land. pending names a commit that a killed session commit built and may have landed. The next commit settles it, as a commit killed halfway describes.
Commit
Section titled “Commit”memhtml session commit --id s1 --message "two facts about the checkout api"{ "apiVersion": "1", "type": "session.committed", "data": { "id": "s1", "ref": "refs/heads/main", "baseSha": "3f8a37e2af22c26d600a68e5f5179145c25318d3", "kind": "committed", "sha": "9b6299d3c2eaac79290a789522aa39a32d2f9282", "paths": [ "areas/inbox/the-checkout-api-retries-twice.html", "projects/checkout/charges-settle-within-a-day.html" ], "contradictions": [], "restatements": [], "worktreeSynced": true, "recovered": false, "snapshot": { "sha": "9b6299d3c2eaac79290a789522aa39a32d2f9282", "path": "/home/you/memhtml/.memhtml/snapshots/9b6299d3c2eaac79290a789522aa39a32d2f9282.arrow", "bytes": 9426, "rows": 5, "ms": 25, "pruned": [] }, "dropped": [], "via": "local", "server": { "used": false, "base": null, "reason": "no-server", "detail": "ENOENT" } }}The commit’s subject is memhtml(session): <message>, and a Memhtml-Session: s1 trailer records which session made it. kind is the field to branch on:
kind |
What happened | What to do |
|---|---|---|
committed |
The log landed as one commit. sha names it, and the log is empty again. |
Nothing. |
rebase-needed |
The ref moved past the session’s base. mainSha is the new tip, and overlapping lists the session’s paths that changed meanwhile. |
memhtml session rebase, then commit again. |
refused |
An operation breaks a rule, and violations names each one. An empty log is refused with no violations. Nothing moved. |
Fix or drop the operation, or pass --drop-refused. |
worktree-dirty |
The ref is checked out, and the checkout holds uncommitted changes at a path this commit writes. Nothing moved. | Commit or stash those changes, then commit again. |
When the ref is the branch your checkout has open, the working tree moves with the commit (worktreeSynced: true), and uncommitted edits at other paths survive.
When a new record’s claim has the same frame key as an active record at another path, both stay live. The new file gets a memhtml-contradicts link to the old one, and contradictions lists the pair. Deciding which claim is right stays with a writer or a person. A record that agrees with the new one (the same value and, for two verdicts, the same title, which carries the ruling), one the new record supersedes, and one the same session archives get no link, and restatements lists each of those pairs with its reason.
index.db doesn’t move with a session commit. When the session’s ref is the branch the checkout has open, memhtml head search and a running head server see the new records at once. memhtml search, memhtml recall, and the MCP tools that read the index see them after the next memhtml index update, which is why that command stays among the upkeep commands.
When the ref moved, rebase
Section titled “When the ref moved, rebase”A commit without --drop-refused never retries on its own. When it answers rebase-needed, move the session’s base to the ref’s new tip and commit again:
memhtml session rebase --id s1memhtml session commit --id s1 --message "two facts about the checkout api"session rebase keeps every operation and judges nothing. The commit after it judges the log against the new tip, so it can answer refused when the ref gained the same fact meanwhile (duplicate) or a new version of a file this session also wrote (claim-edit). The caller owns that loop, because only the caller knows whether a refused line still matters.
The write bar
Section titled “The write bar”The write bar is the rule for what any session may commit as a memory, and it has a fixed half and a configurable half.
The fixed half holds in every store:
format: the file has to parse as a memory, with exactly one claim.duplicate: the article already exists in an active record.claim-edit: a put over an active record with a different article. Write the new fact under its own title with amemhtml-supersedeslink. When the same batch archives the old record, the link names its archive path,/archive/<YYYY>/<its path>.reserved-path:.memhtml/,memhtml.config.json, and anyindex.htmlorsitemap.xmlare never writable.areas/arcs/andresources/people/are writable only by curation.batch-cap: one commit holds at most 200 operations.
The configurable half is the store’s policy, writeBar in memhtml.config.json. With no file, it refuses no type and asks for no anchor. A strict store can refuse whole types (a run narrative, a review verdict) and can require every record to name what it’s about through a memhtml-entity or a projects/<slug>/ path. Set the store policy lists the keys.
The file is read at every put, exec, and commit, so a commit judges the log by the policy in force when the commit runs.
session put refuses the whole call for a format problem, a reserved path, the batch cap, or a write-bar reason, because no rebase can fix those. session exec appends none of its harvest for the same reasons, except a reserved path: that file is only listed under rejected, and the rest still lands. A duplicate or a claim-edit is reported under violations and still added, because a rebase can change the answer, and the commit refuses it. In a run-bound session those two refuse the call too.
Bind a whole run with MEMHTML_SESSION
Section titled “Bind a whole run with MEMHTML_SESSION”An agent runtime runs many short CLI processes in one run. When it exports MEMHTML_SESSION to all of them, the whole run writes through one session:
- Every
sessioncommand takes the variable as its default--id. - The first
session putorsession execstarts the session onrefs/heads/main, so the run needs nosession start(on a store whose branch isn’tmain, runsession start --ref <branch>first). Two subagents starting at once share the one session, but when two puts reach it at the same instant one can fail withERR_STORAGEand append nothing. memhtml write,apply,correct, andtask addrefuse withERR_SESSION_BOUNDat exit 2. The refusal’ssuggestionscarry asession putline with placeholders to fill in.link,archive,task status,reinforce, and every read stay open.duplicateandclaim-editrefuse a put or an exec at once, so the agent sees the problem inside the turn.
export MEMHTML_SESSION=run-42printf '%s\n' '{"op":"write","title":"The cache ttl is five minutes","type":"semantic","body":"The cache ttl is five minutes.","entity":"system:cache"}' \ | memhtml session put --file -The store’s v1Doors setting decides whether the record-creating doors close. The default, closed-in-session, closes them only in a process that carries the variable. See set the store policy.
The runtime commits the session when the run ends:
memhtml session commit --message "run 42" --drop-refusedOn rebase-needed it runs session rebase once and commits again. Through a head server, --drop-refused has already rebased and committed again up to 3 times before it answers rebase-needed. --drop-refused is for this commit, where no agent turn is left to fix a line. It takes out of the log every operation that touches a path a refusal names, and commits the rest. Each dropped operation is appended to .memhtml/sessions/<id>.dropped.jsonl with the time and the reasons, and dropped in the payload lists them. A batch-cap refusal names no path, so it’s never dropped.
An id given with --id must name a session that already started. A typo then fails with ERR_STORAGE (session.read) instead of quietly opening a new session. A blank variable counts as unset, and a value that isn’t a valid id is ERR_INVALID_FLAG naming the variable.
Two writers on one checkout
Section titled “Two writers on one checkout”Every writer that changes the shared git index holds the checkout lock, .memhtml/checkout.lock, while it stages and commits. That covers every memhtml write, apply, correct, link, archive, task status, publish, state export, and doctor --fix, every session commit, and the fast-forward in curate merge. So a session commit and a memhtml write in another process never commit each other’s files.
A writer waits up to 15 seconds for the lock. Past that it fails with ERR_STORAGE (checkout.locked). Every writer but doctor --fix and task status <path> done has then written nothing, so running it again is safe. A lock left by a killed process goes stale after 10 seconds and the next writer takes it.
A commit killed halfway
Section titled “A commit killed halfway”Before anything shared moves, session commit writes the commit it built into the log as pending. The next session commit on that id settles it. If the earlier commit landed, the answer is committed with recovered: true and nothing is committed twice. If it didn’t land, the checkout is put back and the commit is made again.
Make these calls faster with a head server
Section titled “Make these calls faster with a head server”session start, put, exec, commit, and rebase each load the corpus when they start, which takes seconds on a large store. A running head server keeps it loaded, and session put, session exec, session commit, and session rebase ask the server first. The payload then says head.source: "server" or via: "server". --no-server makes any of them work alone.
Clean up with session prune
Section titled “Clean up with session prune”A session leaves files behind under .memhtml/sessions/, and a runtime that gives every run a new id never reads them again. session prune removes the ones nothing reads any more:
memhtml session prune --older-than 7d --dry-run{ "apiVersion": "1", "type": "session.pruned", "data": { "dryRun": true, "cutoff": "2026-10-11T05:51:45.347Z", "olderThanMs": 604800000, "indexes": { "count": 0, "bytes": 0 }, "refs": { "count": 0 }, "locks": { "count": 0 }, "logs": { "count": 0 }, "unlanded": [], "busy": [], "unreadable": [], "olderThan": "7d" }}Past the cutoff it removes logs with no operations left, lock directories a killed writer left behind, and the <id>.idx index files and refs/memhtml/sessions/<id> refs that earlier builds wrote. The .dropped.jsonl trails are kept.
It never removes a log that still holds operations. That log is a run whose writes haven’t landed, so it’s listed under unlanded with its id, operation count, pending, and age. Land it with memhtml session rebase --id <id> and then memhtml session commit --id <id> --message <summary> --drop-refused. An id whose lock is held is listed under busy, and a log that doesn’t decode is kept and listed under unreadable.
--older-than takes a duration with a unit: 7d, 48h, 90m, or 2 weeks. A bare number is ERR_INVALID_FLAG. --dry-run reports the same counts and removes nothing. A weekly cron line keeps the directory small:
0 5 * * 0 cd $HOME && MEMHTML_ROOT=$HOME/memhtml memhtml session prune --older-than 7d --dense >> /var/log/memhtml/prune.jsonl 2>> /var/log/memhtml/prune.errSessions and the commit path explains how a commit validates, builds its tree, and moves the ref.