Skip to content

@memhtml/session

@memhtml/session is a session’s overlay log and the mechanical path that commits it. A session is a base commit, the ref its commit will land on, and an ordered list of operations, kept in one JSON file per id under .memhtml/sessions/. The package depends on @memhtml/contracts, @memhtml/domain and @memhtml/html, plus effect, proper-lockfile and write-file-atomic. It never imports @memhtml/store. It runs git itself, because the store’s git service strips GIT_INDEX_FILE and a commit stages into an index file of its own. The CLI in apps/cli uses most of the package, and @memhtml/curator imports BATCH_CAP and the Violation type. The design is docs/design.md §17.

Session is a session’s state in memory, plus the store root, which is not saved, so a log is always found by root and id. startSession takes a SessionBase and reads only its sha. It checks that the sha names a commit and writes an empty log. Unless force is set, it refuses with session.exists an id that already has a log, so a retried start cannot replace work in progress. The new session’s ref is DEFAULT_REF, refs/heads/main, unless the caller passes one: it does not follow the branch the checkout has open. resumeSession reopens a log. decodeLog turns a log’s text into session state and fails on text that is not JSON, not a log, or another id’s log. A log’s ops decode through OverlayOpSchema, which the head server’s wire protocol uses too. An id must match SESSION_ID, one path segment, so a log cannot leave SESSIONS_DIR. isSessionId tests an id and sessionStateFile names its log.

Every write to a log runs inside withSessionLock, which holds an exclusive proper-lockfile lock directory, <id>.lock, beside the log. It covers one id in one checkout, across processes. A second writer does not wait. It fails at once with session.locked, because the right answer to a command sent twice is a refusal, not a second commit. The holder refreshes the lock’s mtime, and a lock with no refresh for SESSION_LOCK_STALE_MS (30 s) can be taken by the next writer of the same id. SessionLockOptions lets a test shorten the refresh.

A lock alone would still lose an update, because a caller reads the log, works for a while, and writes after. So appendOps, saveSession, replaceOps and commitSession read the log again under the lock and go ahead only when it still holds the ops the caller read. Otherwise each fails with LOG_MOVED (session.moved) and writes nothing. Only the ops are compared, not the base. appendOps adds ops and returns the session to keep using. rebaseSession is pure: it moves baseSha to the version it is given and writes nothing, and saveSession persists the result. replaceOps swaps the whole list, which session commit --drop-refused does to take refused ops out.

pending is a commit that commitSession built and wrote to the log before it touched the checkout. A process that dies before clearing it leaves that record, and the next commitSession or appendOps on that id settles it under the checkout lock. If the commit is reachable from the ref, it landed, and the session moves onto it and drops the ops it carried. If not, the settle tries to undo any checkout update the dead call made, and the ops stay. appendOps settles pending before it adds anything, so new ops never sit beside a commit that may have landed. replaceOps refuses a log that carries one, with a failure that starts with SESSION_PENDING.

commitSession lands the whole log as one commit and calls no model. It holds the session lock for the whole call. It settles pending first, and answers committed with recovered set when that commit had landed. It runs validateOps against the head the caller passes and answers refused on any violation. It answers rebase-needed when the ref names a commit other than that head’s sha, because a duplicate or a frame-key rival can arrive on paths the session never touched. It applies the ops over the head and builds the blobs, tree and commit through a temporary index file under the OS temp directory, so building the commit never touches the shared index. Then it writes the commit to the log as pending, lands it with landOnRef, and saves the log with the commit as its base and no ops. CommitOutcome is the answer: committed, rebase-needed, refused with each Violation, or worktree-dirty.

While commitSession applies the ops, a put gets a contradicts edge to each other active record in the head whose claim shares its frame key, and both stay live. A rival that agrees with the put, that the put supersedes, or that the batch archives gets no edge. That pair comes back under restatements with a RestatementReason, one of RESTATEMENT_REASONS. commitSubject builds the one-line subject memhtml(<scope>): <summary>, with the summary capped and never empty. commitMessage adds a Memhtml-Session: <id> trailer, which is how a session commit names its session. archiveBody stamps memhtml-status archived and a memhtml-archived instant into the HTML head of a record, so the article and its content hash do not change.

landOnRef moves the checkout and then the ref as one section under withCheckoutLock. That lock is the directory .memhtml/checkout.lock in one checkout, and @memhtml/store computes the same path for its writes, so a session commit and a v1 write in that checkout take the same lock. A waiter retries for up to 15 s by default and then fails with checkout.locked. The lock is reentrant within one fiber, and once it is held the section cannot be interrupted. checkoutLockTarget names the lock’s target, .memhtml/checkout under a root.

Inside the section, the ref must still name expected, or the answer is raced and nothing moves. When HEAD is the ref, uncommitted state at a path the commit changes is worktree-dirty; with those paths clean, a two-tree read-tree -m -u moves the shared index and working tree to the new commit and keeps uncommitted edits at other paths. Last, update-ref with the expected old value is a compare-and-swap that git enforces on that one ref, even against a writer that skips the checkout lock. When the swap is lost and HEAD is the ref, the reverse read-tree runs to put the checkout back. The checkout moves first because the other order would leave HEAD ahead of the index on a failure, and git would show the session’s files as staged deletions. Landing is landed, raced or worktree-dirty, a lost swap is raced too, and commitSession answers raced with rebase-needed.

validateOps is a pure function of a HeadView and a list of ops that returns a list of Violation values and does no I/O. The CLI calls it before session put and session exec append and in its curation commands, so they judge ops with the function the commit runs. ValidateOptions carries the scope, the store’s writeBar, and a batchCap that defaults to BATCH_CAP, 200 ops. A CommitScope is session or curate, and it is the caller’s to pass: validateOps and commitSession default to session and do not read it from the ref. isReservedPath says which paths a scope may not write: a path under .memhtml/, memhtml.config.json, any index.html or sitemap.xml, and outside curate, areas/arcs/ and resources/people/. touchedPaths lists the paths one op reads or writes.

writeBarReasons judges one new record against the store’s write bar: a type the store refuses, and, when the bar requires an anchor, a record that has no entity and is not under projects/. UNANCHORED_REASON is the reason that rule gives. The library default refuses nothing, so a caller judging a real store passes that store’s policy. Besides the reserved paths every op is held to, a link op is checked for a live source, a rel in the edge vocabulary, a root-relative href, and a target that is a record in the head or the batch. It is not checked against the endpoint classes that memhtml link enforces in @memhtml/store, so a session link can join a task and a memory with a rel that memhtml link refuses for that pair.

A move or an archive leaves its old path, so a batch repoints the live edges to that path with unlink and link pairs. withRepoints adds those pairs, read from the view’s inbound index, before the batch’s first move or archive. missingRepoints finds the pairs a log already written lacks against the version it lands on, for the caller to append, and names under overflow each archive or move whose pairs would not fit under the cap. afterDroppedVacates works out what a log keeps when session commit --drop-refused drops an archive or a move: the repoint pairs it needed go with it. VacateFollower reports each op it took or rewrote.

pruneSessions removes session state that nothing reads again, each item only when it is older than olderThanMs. That is logs with zero ops, lock directories no live holder refreshes, and the <id>.idx index files and SESSION_REFS_PREFIX refs that older builds wrote. It never removes a log that holds ops, and lists one older than the cutoff under unlanded as an UnlandedSession, for an operator to commit. Outside a dry run it handles each id under that id’s session lock, and either way an id whose lock is held is listed under busy. With dryRun it takes no lock, removes nothing, and reports what it would remove. PruneReport is the answer, with a PruneCount per kind of state.

makePlumbing returns the Plumbing the commit path runs: each method runs a git -C child with a few variables pinned and the GIT_REPO_SELECTION_ENV variables removed, so a GIT_DIR a hook exported cannot point a call at another repository. The four staging methods use the index file the plumbing was made with, so without one they cannot stage into the shared index. PlumbingFactory is how commitSession gets a plumbing for its temporary index, and a test seam. RefEntry is one row of forEachRef. gitPathOf asks git where a file under the git directory lives, because a linked worktree keeps info/exclude in its common directory.

ensureExcluded appends ignore patterns to the clone’s info/exclude, each once, because a store’s committed .gitignore may predate that state and rewriting it would dirty the tree. ensureExcludedQuietly is the best-effort form, which logs a failure and goes on. startSession passes .memhtml/sessions/, and the head server passes HEAD_SOCKET. ExcludeResult names the file and the patterns a call added. GitFailure is the shared class from @memhtml/contracts, re-exported so one _tag covers this package and the store. Its command is the git subcommand alone, never the arguments, because arguments carry memory paths and a failure goes back to an agent.

The package publishes one import path, @memhtml/session, and its index.ts re-exports a chosen set of names from nine of the ten modules below, so the reference on this page is that set. A name index.ts does not re-export is left off the page and appears unlinked where a signature or doc comment names it. The table lists the package’s own, and the types HeadView, OverlayOp, StorageFailure, CheckoutLockOptions and WriteBarPolicy come from @memhtml/contracts.

Module What it holds
checkout-lock.ts checkoutLockTarget and withCheckoutLock.
commit.ts RESTATEMENT_REASONS, RestatementReason, CommitOutcome, commitSubject, commitMessage, archiveBody, PlumbingFactory, Landing, landOnRef and commitSession, and a re-export of CommitScope, plus COMMIT_SUBJECT_MAX and SESSION_TRAILER, which are not exported from @memhtml/session.
errors.ts GitFailure, re-exported from @memhtml/contracts.
exclude.ts HEAD_SOCKET, ExcludeResult, ensureExcluded and ensureExcludedQuietly.
moves.ts withRepoints, missingRepoints, VacateFollower and afterDroppedVacates.
plumbing.ts GIT_REPO_SELECTION_ENV, Plumbing, RefEntry, gitPathOf and makePlumbing.
prune.ts PruneCount, UnlandedSession, PruneReport and pruneSessions.
session.ts Session, DEFAULT_REF, SESSIONS_DIR, SESSION_ID, isSessionId, SESSION_LOCK_STALE_MS, sessionStateFile, SESSION_REFS_PREFIX, OverlayOpSchema, SessionLockOptions, withSessionLock, SessionBase, startSession, decodeLog, resumeSession, LOG_MOVED, SESSION_PENDING, appendOps, rebaseSession, saveSession and replaceOps, plus requireLogUnmoved and saveSessionLocked, which are not exported from @memhtml/session.
settle.ts settlePending, which commitSession and appendOps call and index.ts does not re-export.
validate.ts Violation, BATCH_CAP, CommitScope, ValidateOptions, isReservedPath, UNANCHORED_REASON, writeBarReasons, touchedPaths and validateOps.
index.ts The re-exports above, type-only for the interfaces and type aliases.

A git subprocess exited non-zero, or could not be spawned. command is the subcommand name only, never the full argv, because arguments carry memory paths and commit subjects carry memory titles, and a GitFailure is returned to an agent through a tool response. The stderr text goes to Effect.logError at the spawning boundary instead of into the payload.

Declared here rather than in @memhtml/store because two packages spawn git: the store through its Git service, and @memhtml/session through its own execFile (it needs GIT_INDEX_FILE, which the store scrubs). One class means one _tag, so the CLI’s ERR_GIT mapping and an instanceof check hold for either producer.

  • GitFailure_base
new GitFailure(...args): GitFailure;

…[props: object, options?: MakeOptions]

GitFailure

GitFailure_base.constructor

readonly command: string;

GitFailure_base.command

readonly exitCode: number | null;

The process exit code, or null when the process never started.

GitFailure_base.exitCode

What one call did.

readonly added: readonly string[];

The patterns this call appended; empty when every one was already present.

readonly file: string;

The exclude file, absolute.


commitTree(
tree,
parents,
message
): Effect<string, GitFailure>;

commit-tree, message on stdin; the commit sha.

string

readonly string[]

string

Effect<string, GitFailure>

deleteRefs(refs): Effect<void, GitFailure>;

update-ref --stdin with one delete per ref, as one transaction; nothing when refs is empty.

readonly string[]

Effect<void, GitFailure>

diffTreePaths(from, to): Effect<readonly string[], GitFailure>;

diff-tree -r --name-only --no-renames -z from to.

string

string

Effect<readonly string[], GitFailure>

dirtyPaths(paths): Effect<readonly string[], GitFailure>;

The subset of paths that status --porcelain reports over the shared index and working tree: staged, modified, or untracked. Empty when every named path is clean and absent when untracked. Each path is read as itself, never as a pattern, and an empty one fails before git runs.

readonly string[]

Effect<readonly string[], GitFailure>

forEachRef(prefix): Effect<readonly RefEntry[], GitFailure>;

for-each-ref under prefix (refs/memhtml/sessions/): each ref, the object it names, and the committer time of that commit in epoch seconds (null when it names no commit).

string

Effect<readonly RefEntry[], GitFailure>

hashObjectWrite(bytes): Effect<string, GitFailure>;

hash-object -w --stdin; the blob sha.

Uint8Array

Effect<string, GitFailure>

headRef(): Effect<string | null, GitFailure>;

The branch HEAD points at (refs/heads/...), or null when detached. Shared index.

Effect<string | null, GitFailure>

isAncestor(sha, ref): Effect<boolean, GitFailure>;

merge-base --is-ancestor sha ref: true when sha is reachable from ref.

string

string

Effect<boolean, GitFailure>

readTree(sha): Effect<void, GitFailure>;

read-tree <sha> into the session index file.

string

Effect<void, GitFailure>

readTreeIntoWorktree(from, to): Effect<void, GitFailure>;

read-tree -m -u from to on the SHARED index, so the working tree follows a commit. A two-tree merge: a path unchanged between from and to keeps whatever the index and working tree hold, so uncommitted edits elsewhere survive; a path that changed must be clean or git refuses. The shared index lock is contended by every writer of the checkout, so index.lock being held is retried briefly before it is a failure.

string

string

Effect<void, GitFailure>

revParse(ref): Effect<string | null, GitFailure>;

The commit a ref resolves to, or null when it does not resolve.

string

Effect<string | null, GitFailure>

updateIndexAdd(entries): Effect<void, GitFailure>;

update-index --add --index-info, one child for the whole batch.

readonly object[]

Effect<void, GitFailure>

updateIndexRemove(paths): Effect<void, GitFailure>;

update-index --force-remove -z --stdin, one child for the whole batch.

readonly string[]

Effect<void, GitFailure>

updateRef(
ref,
next,
expected
): Effect<"updated" | "raced", GitFailure>;

update-ref <ref> <next> <expected> as a compare-and-swap. expected: null means the ref must not exist. A non-zero exit whose stderr names the ref as locked or moved is "raced"; any other non-zero exit is a GitFailure.

string

string

string | null

Effect<"updated" | "raced", GitFailure>

writeTree(): Effect<string, GitFailure>;

write-tree over the session index file; the tree sha.

Effect<string, GitFailure>


What one category of state amounts to: removed, or under dryRun, would be.

readonly count: number;

readonly busy: readonly string[];

Ids whose session lock a live writer held: nothing of theirs was read or removed.

readonly cutoff: string;

Everything last written before this instant is a candidate.

readonly dryRun: boolean;
readonly indexes: PruneCount & object;
readonly bytes: number;
readonly locks: PruneCount;
readonly logs: PruneCount;
readonly olderThanMs: number;
readonly refs: PruneCount;
readonly unlanded: readonly UnlandedSession[];

Logs older than the cutoff that still hold ops, oldest first. Kept.

readonly unreadable: readonly string[];

Logs that do not decode as a session log: kept, for an operator to read.


One row of Plumbing.forEachRef.

readonly committedAt: number | null;
readonly ref: string;
readonly sha: string;

A session: a pointer to a base version, an overlay log of its own operations, and nothing else. Its durable twin on disk is one file, <root>/.memhtml/sessions/<id>.json, the log, under .memhtml/, which the store’s scaffold gitignores. A commit stages through a temporary index file of its own that it removes on every outcome (commit.ts), so nothing else is kept between calls.

One writer per id at a time. Every write to the log and the whole of a commit run under withSessionLock, an exclusive lock at <id>.lock (proper-lockfile: an atomic mkdir, its mtime refreshed while held so a lock left by a killed process goes stale and is reclaimed by the next writer of the same id). A second writer does not wait: it fails with session.locked, because a second commit of the same id would race the first for the log’s pending and its ref. A lock left by a killed process under an id nobody uses again (a run’s) is never reclaimed by a writer, which is why pruneSessions removes stale lock directories.

Every write replaces the whole file, and a caller computes what it writes from the log it read earlier, outside the lock (a session exec reads the log, runs a script for seconds, then appends its harvest). So the lock alone would still lose an update: two execs that read the same log each append to it, and the second write drops the first’s ops. Every writer therefore re-reads the log under the lock and writes only when the file still holds the ops the caller read (requireLogUnmoved); a log whose ops changed is refused with LOG_MOVED and nothing is written, so the caller reads it again and decides. The ops are what is compared, not the base: rebaseSession is pure and a caller may commit or append over a rebased session it has not saved, which moves the base on purpose.

A log that carries pending (a commit killed after it wrote its intent) holds exactly the ops that commit built, and the next commit clears them when it finds the commit landed. So no write adds ops beside a pending: appendOps settles it first, and replaceOps refuses.

readonly baseSha: string;
readonly id: string;
readonly ops: readonly OverlayOp[];
readonly pending?: string;

The commit a commitSession built and was about to land when the log was last written, or absent. Written before the ref moves and cleared after, so a process killed between the two leaves a record: the next commit checks whether it reached the ref and answers committed for it instead of refusing the same ops as duplicates of themselves. An append settles it the same way before it adds an op (appendOps).

readonly ref: string;

refs/heads/main by default; refs/heads/curate/<date> for a curator.

readonly root: string;

The repository root. Not persisted: the log is found by root plus id, so it cannot disagree.


The version startSession starts a session on. Only its sha is read, so a bare { sha } fits and so does a HeadView.

readonly sha: string;

Test seams for withSessionLock.

readonly updateMs?: number;

How often the holder refreshes the lock’s mtime, in milliseconds; proper-lockfile’s default (half of SESSION_LOCK_STALE_MS) unless a test shortens it to see a refresh happen.


One log that still holds ops: never removed.

readonly ageMs: number;
readonly id: string;
readonly modifiedAt: string;

The log’s last write.

readonly ops: number;
readonly pending: string | null;

A commit an earlier call built and may have landed; session commit settles it.


One op afterDroppedVacates took or rewrote, with the archive or move it followed.

readonly detail: string;

The edge’s rel, or the entity a label or unlabel names.

readonly home?: string;

The path the record still has, for an op made there or an edge aimed back at it.

readonly kind: "link" | "put" | "archive" | "unlink" | "label" | "unlabel" | "move";
readonly path: string;

The op’s path as the log held it.

readonly vacate: OverlayOp;

The options validateOps and isReservedPath take. scope defaults to session.

readonly batchCap?: number;

The most ops the batch may carry; BATCH_CAP when omitted. curate merge passes Infinity for a replay, whose ops are the union of commits that each passed the cap on the curate branch: the 2026-09-24 collapse shape lands 2,747 ops, which one capped batch cannot.

readonly scope?: CommitScope;
readonly writeBar?: WriteBarPolicy;

The store’s write bar (writeBar in memhtml.config.json, read by the CLI’s loadStorePolicy): the types it refuses and whether every new record must be anchored. DEFAULT_WRITE_BAR when omitted, which refuses no type and anchors nothing, so a caller judging a real store passes the store’s policy. Format, integrity, reserved paths, and the batch cap hold whatever it says.

type CommitOutcome =
| {
contradictions: ReadonlyArray<{
against: string;
path: string;
}>;
kind: "committed";
paths: ReadonlyArray<string>;
recovered: boolean;
restatements: ReadonlyArray<{
against: string;
path: string;
reason: RestatementReason;
}>;
sha: string;
worktreeSynced: boolean;
}
| {
kind: "rebase-needed";
mainSha: string;
overlapping: ReadonlyArray<string>;
}
| {
kind: "refused";
violations: ReadonlyArray<Violation>;
}
| {
kind: "worktree-dirty";
paths: ReadonlyArray<string>;
};
{
contradictions: ReadonlyArray<{
against: string;
path: string;
}>;
kind: "committed";
paths: ReadonlyArray<string>;
recovered: boolean;
restatements: ReadonlyArray<{
against: string;
path: string;
reason: RestatementReason;
}>;
sha: string;
worktreeSynced: boolean;
}
readonly contradictions: ReadonlyArray<{
against: string;
path: string;
}>;

Each put step 3 linked to a frame-key rival (against, its destination when moved).

readonly kind: "committed";
readonly paths: ReadonlyArray<string>;
readonly recovered: boolean;

True when this call found the commit an earlier call had landed before it was killed (the log still carried it as pending) and reports that commit instead of building one. The contradictions and restatements of a recovered commit are not re-derived and come back empty.

readonly restatements: ReadonlyArray<{
against: string;
path: string;
reason: RestatementReason;
}>;

Each frame-key rival step 3 left unlinked, named the way contradictions names one, with the reason (RestatementReason).

readonly sha: string;
readonly worktreeSynced: boolean;

True when HEAD is the session’s ref, so the shared index and working tree were moved to the new commit before the ref was. False when the ref is not checked out and nothing needed to follow.


{
kind: "rebase-needed";
mainSha: string;
overlapping: ReadonlyArray<string>;
}

{
kind: "refused";
violations: ReadonlyArray<Violation>;
}

{
kind: "worktree-dirty";
paths: ReadonlyArray<string>;
}
readonly kind: "worktree-dirty";

HEAD is the session’s ref and the checkout has uncommitted state (staged, modified, or untracked) at a path this commit writes or removes, so the shared index could not follow the ref. Nothing was committed; the caller cleans up or commits from another checkout.

readonly paths: ReadonlyArray<string>;

type CommitScope = "session" | "curate";

Who is writing. session is an ordinary session on main; curate is a run of the curator (@memhtml/curator) on its own curate/<date> branch. The scope names the commit subject (memhtml(<scope>): ...) and widens what the validator lets through: the arcs and people prefixes are the curator’s to write and nobody else’s.


type Landing =
| {
kind: "landed";
worktreeSynced: boolean;
}
| {
kind: "raced";
tip: string | null;
}
| {
kind: "worktree-dirty";
paths: ReadonlyArray<string>;
};

What landOnRef did.

{
kind: "landed";
worktreeSynced: boolean;
}
readonly kind: "landed";

The ref moved to to; the checkout moved with it when worktreeSynced.

readonly worktreeSynced: boolean;

{
kind: "raced";
tip: string | null;
}
readonly kind: "raced";

The ref no longer named expected. Nothing moved; tip is what it names now.

readonly tip: string | null;

{
kind: "worktree-dirty";
paths: ReadonlyArray<string>;
}
readonly kind: "worktree-dirty";

HEAD is the ref and the checkout holds uncommitted state at these paths. Nothing moved.

readonly paths: ReadonlyArray<string>;

type PlumbingFactory = (indexFile) => Plumbing;

How a commit gets its plumbing: called with the index file the staging calls use (a commit’s own temporary file), or undefined for the calls that stage nothing. The default is makePlumbing({ root, indexFile }); a test wraps it to act at one exact step of the sequence (a v1 commit landing right after the checkout moved, a process dying right after it).

string | undefined

Plumbing


type RestatementReason = typeof RESTATEMENT_REASONS[number];

type Violation =
| {
kind: "format";
path: string;
reasons: ReadonlyArray<string>;
}
| {
existing: string;
kind: "duplicate";
path: string;
}
| {
kind: "claim-edit";
path: string;
}
| {
kind: "reserved-path";
path: string;
}
| {
cap: number;
count: number;
kind: "batch-cap";
}
| {
kind: "write-bar";
path: string;
reasons: ReadonlyArray<string>;
};

The mechanical checks a commit runs before it writes anything. No model call, no I/O: every check reads the head view and the ops, so a refusal costs nothing and leaves the tree untouched.

{
kind: "format";
path: string;
reasons: ReadonlyArray<string>;
}

{
existing: string;
kind: "duplicate";
path: string;
}

{
kind: "claim-edit";
path: string;
}

A put over an active path with a different content hash. Claims are never edited in place.


{
kind: "reserved-path";
path: string;
}

.memhtml/, index.html, sitemap.xml under every scope; areas/arcs/ and resources/people/ too unless the scope is curate.


{
cap: number;
count: number;
kind: "batch-cap";
}

{
kind: "write-bar";
path: string;
reasons: ReadonlyArray<string>;
}

A new record that fails the store’s write bar (writeBarReasons): a type the store refuses, or, under the anchor rule, a record that names nothing it is about. Every scope and every type is judged; only an archive destination is not, because it is an existing record moving. Under the anchor rule an unlabel that leaves its record with no entity and no workspace path fails too, and so does a move destination that names nothing.

const BATCH_CAP: 200 = 200;

The most ops one commit carries. A larger batch is two sessions.


const DEFAULT_REF: "refs/heads/main" = "refs/heads/main";

const GIT_REPO_SELECTION_ENV: ReadonlyArray<string>;

The variables by which the environment, not -C, decides which repository a git call operates on. Removed from every child for the reason the store removes them: a hook exports an absolute GIT_DIR and a child would silently operate on the hook’s repository. The same list as packages/store/src/git.ts GIT_REPO_SELECTION_ENV, minus nothing: GIT_INDEX_FILE is scrubbed here too and then set explicitly for the calls that stage into the session index.


const HEAD_SOCKET: ".memhtml/head.sock" = ".memhtml/head.sock";

The head server’s Unix domain socket (memhtml head serve), as an ignore pattern. The server passes it to ensureExcludedQuietly before it binds, the way a session start passes .memhtml/sessions/ and a snapshot write .memhtml/snapshots/, so a store whose .gitignore predates the server keeps the socket out of git status too. The store package’s V2_IGNORE_PATTERNS names the same path for a new store’s .gitignore.


const LOG_MOVED: "session.moved" = "session.moved";

The failure a writer gets when the log changed between its read and its write.


const OverlayOpSchema: Union<readonly [Struct<{
html: String;
kind: Literal<"put">;
path: String;
}>, Struct<{
html: String;
kind: Literal<"archive">;
path: String;
to: String;
}>, Struct<{
href: String;
kind: Literal<"link">;
path: String;
rel: String;
}>, Struct<{
href: String;
kind: Literal<"unlink">;
path: String;
rel: String;
}>, Struct<{
entity: String;
kind: Literal<"label">;
path: String;
}>, Struct<{
entity: String;
kind: Literal<"unlabel">;
path: String;
}>, Struct<{
html: String;
kind: Literal<"move">;
path: String;
to: String;
}>]>;

One OverlayOp as JSON: the log’s schema, and the head server’s for every route that carries a session’s ops (/v1/neighbors in apps/cli/src/head-protocol.ts), so the log and the wire decode the same kinds and a kind the log gains is one the wire reads.


const RESTATEMENT_REASONS: readonly ["same-value", "superseded", "archived"];

Why step 3 left a frame-key rival unlinked: it agrees with the put (same-value: one value or one claim, and for two verdicts one ruling), the put carries a supersedes edge to it or to where the batch archives it (superseded), or the batch archives it (archived). A pair that meets more than one is reported with the first, in that order.


const SESSION_ID: RegExp;

A session id: one path segment, so it can never leave .memhtml/sessions/. Shared with the CLI’s usage check.


const SESSION_LOCK_STALE_MS: 30000 = 30_000;

How long a lock may go without an mtime refresh before another writer may reclaim it.


const SESSION_PENDING: "session.pending" = "session.pending";

The prefix of the failure replaceOps gets over a log that carries a pending commit: the whole operation is session.pending: <what to do>, naming the session commit that settles it.


const SESSION_REFS_PREFIX: "refs/memhtml/sessions/" = "refs/memhtml/sessions/";

The namespace a session commit once recorded provenance under (refs/memhtml/sessions/<id>). No commit writes it: the Memhtml-Session trailer on every session commit names the id, and nothing read the refs. pruneSessions removes the ones a store still carries.


const SESSIONS_DIR: ".memhtml/sessions" = ".memhtml/sessions";

The directory every session’s state lives under, relative to the repo root.


const UNANCHORED_REASON: "names no system, project, or person: add at least one memhtml-entity (<meta name=\"memhtml-entity\" content=\"system:<name>\"> in the head, an entities value in a write op, or a label op) or write it in a workspace (projects/<slug>/)" = 'names no system, project, or person: add at least one memhtml-entity (<meta name="memhtml-entity" content="system:<name>"> in the head, an entities value in a write op, or a label op) or write it in a workspace (projects/<slug>/)';

The reason a record that names nothing gives, worded so the writer (a person, an agent, or the curator’s model reading a refused proposal) knows exactly what to add.

function afterDroppedVacates(
view,
keep,
dropped
): object;

What a log keeps once session commit --drop-refused has taken dropped out of it, for each archive or move among them.

Its own repoint pairs always go with it: each link to its destination and the unlink of the same holder and rel from its source, so the holder keeps its edge to the record where it is, whatever holds the destination, and no other op on the holder is refused for it.

Two rewrites follow only when neither view nor a kept op holds the destination and the record is still live in view, at the vacate’s source or, for a chain the round dropped whole (a move and a second move or an archive of the same record), at the source the chain began from. Each kept head op at a dropped move’s destination is made there instead: a repoint the commit appended after the move, for a holder the move took, then repoints the holder where it stays. Every other edge a kept put or link aims at the destination is aimed back there too: a successor’s supersedes edge to the slot of a record whose archive was dropped then names the live record, which the curator archives later as it does any superseded record, rather than a file that was never written. A held destination keeps the edges aimed at it, which name the record that holds it; a record that is not live (an archive of a record the batch put, dropped with the put) leaves them as they are. So does a vacate whose destination two distinct dropped ops aim at, and a chain through such a path or through one two dropped ops leave while a dropped op also aims at it or the log puts there: neither names one record. A path only the version’s record leaves twice, as a move written twice or a move and an archive of one record do, still names that record; one record moved out and back and on is left as written.

removed lists the ops taken, retargeted each edge aimed back, and returned each op made at the record’s path, all with the vacate they followed; homes maps each dropped move’s destination that got the rewrites to that path.

HeadView

readonly OverlayOp[]

readonly OverlayOp[]

object

readonly homes: ReadonlyMap<string, string>;
readonly keep: readonly OverlayOp[];
readonly removed: readonly VacateFollower[];
readonly retargeted: readonly VacateFollower[];
readonly returned: readonly VacateFollower[];

function appendOps(session, ops): Effect<Session, StorageFailure>;

Append to the overlay log and persist it. The returned session is the one to keep using. Refused with LOG_MOVED when the log is no longer the one session was read from.

A log that carries a pending commit is settled first (settleStored): when that commit had landed, the returned session sits on it (its baseSha is the landed commit) and holds only ops; otherwise it keeps its ops and gains ops. So an op appended after a commit was killed past its swap is committed by the next session commit rather than cleared with the landed ops.

Session

readonly OverlayOp[]

Effect<Session, StorageFailure>


function archiveBody(html, archivedAt): string;

The archive body: memhtml-status archived and memhtml-archived <instant> stamped into the head. setMeta splices head lines only, so the article bytes and the content hash are unchanged.

string

string

string


function checkoutLockTarget(root): string;

The checkout lock’s target for a repository root (@memhtml/contracts checkout-lock.ts): the lock is the directory <root>/.memhtml/checkout.lock. @memhtml/store computes the same string from the same constant, which is what makes a session commit and a v1 store write exclude each other.

string

string


function commitMessage(
summary,
sessionId,
scope?
): string;

Subject, blank line, one trailer. The id is flattened so it cannot open a second trailer line.

string

string

CommitScope = "session"

string


function commitSession(input): Effect<CommitOutcome, StorageFailure | GitFailure>;

The commit path, in order. The whole call holds the session’s lock (withSessionLock), so two commits of one id cannot interleave and a session put cannot rewrite the log mid-commit. Every step that touches the shared checkout runs under the repository’s checkout lock as well, which every v1 store write holds from its first stage to its commit.

  1. Recovery (settlePending, settle.ts). A log carrying pending names a commit an earlier call built and may have landed before it was killed. Reachable from the ref: answer committed for it with recovered: true and clear the log. Not reachable: undo whatever checkout update it made, drop pending from the log, and continue. Every append settles pending the same way before it writes (appendOps), so the ops a landed pending leaves are exactly the ones it committed, and clearing them loses nothing.
  2. validateOps(head, ops, { scope }); any violation is refused and writes nothing.
  3. The ref must be at the version that was validated. A ref that moved past head.sha is rebase-needed whether or not the changed paths overlap: duplicate hashes and frame keys are collisions between different paths, so a path-disjoint advance can still carry one, and only a head rebuilt at the tip can judge it. overlapping still names the path collisions.
  4. Stage the ops over the head. A head op the validator passed as a change whose editor hands back its file unchanged (changedNothing) is refused as a format violation and writes nothing, so an editor that disagrees with the reader cannot land an empty commit. Then frame-key contradictions, marked on the new file’s head before hashing. A rival that agrees with the put, that the put supersedes, or that the batch archives is reported under restatements and left unlinked.
  5. Blobs, index, tree, commit object, through a temporary index file removed on every outcome.
  6. Write pending: commit to the log, BEFORE anything shared is touched: from here on a process killed at any instant leaves a log the next call settles in step 0.
  7. landOnRef under the checkout lock: the ref must still be at step 2’s value (else rebase-needed); when HEAD is the ref, uncommitted state at a session path is worktree-dirty, else the checkout follows; then the compare-and-swap, whose loss rolls the checkout back and is rebase-needed. worktree-dirty and rebase-needed clear pending. The checkout follows before the ref moves because the reverse order would leave HEAD ahead of the index on any failure, and git then reports the session’s own files as staged deletions that the next git commit -a or v1 write commits.
  8. Persist the log (base moved to the commit, ops empty, no pending) right after the swap.

string

The instant archive ops stamp; defaults to now. Explicit so a test can pin it.

number

The validator’s batch cap (ValidateOptions); BATCH_CAP unless a replay lifts it.

CheckoutLockOptions

The checkout lock’s wait; 15 s unless a test shortens it.

HeadView & object

string

PlumbingFactory

The plumbing; PlumbingFactory. A test seam: the default is the real git plumbing.

CommitScope

The subject’s scope and the validator’s; session unless a curator run says otherwise. Under curate the arcs and people prefixes are writable.

Session

WriteBarPolicy

The store’s write bar (ValidateOptions writeBar), judged in step 1. The library default when omitted, which refuses no type and anchors nothing, so a caller landing on a real store passes the policy its memhtml.config.json states.

Effect<CommitOutcome, StorageFailure | GitFailure>


function commitSubject(summary, scope?): string;

memhtml(<scope>): <summary>, one line, capped, never empty.

string

CommitScope = "session"

string


function decodeLog(raw, id): Effect<Omit<Session, "root">, StorageFailure>;

A log’s text as the session state it holds, for the session id it must name. Not JSON is session.parse, not a log is session.decode, another id’s log is session.id-mismatch.

string

string

Effect<Omit<Session, "root">, StorageFailure>


function ensureExcluded(root, patterns): Effect<ExcludeResult, StorageFailure | GitFailure>;

Append every pattern of patterns that .git/info/exclude does not already carry, as a line each. Idempotent: a second call with the same patterns appends nothing. A pattern is matched as a whole trimmed line, so .memhtml/sessions/ present as a comment does not count as present.

string

readonly string[]

Effect<ExcludeResult, StorageFailure | GitFailure>


function ensureExcludedQuietly(root, patterns): Effect<ExcludeResult | null>;

ensureExcluded as a best effort: a failure is logged and the caller proceeds, because an ignore rule that could not be written is a noisier git status, and the write it was guarding is still the right write.

string

readonly string[]

Effect<ExcludeResult | null>


function gitPathOf(root, relative): Effect<string, GitFailure>;

Where git keeps relative for this repository (rev-parse --git-path), absolute. info/exclude of a linked worktree lives in the common directory and .git may be a file, so the path is asked of git rather than spelled as <root>/.git/<relative>.

string

string

Effect<string, GitFailure>


function isReservedPath(path, scope?): boolean;

True when a writer under scope may not write the path.

string

CommitScope = "session"

boolean


function isSessionId(id): boolean;

string

boolean


function landOnRef(input): Effect<Landing, StorageFailure | GitFailure>;

Move ref from expected to to with the checkout following, as one critical section under the repository’s checkout lock (withCheckoutLock), so no other writer of the shared index (a v1 store write in any process, another session’s commit, a curate merge) runs between the checkout moving and the ref moving:

  1. The ref must still name expected; otherwise raced before anything is touched.
  2. When HEAD is the ref, a path in paths with uncommitted state is worktree-dirty; otherwise read-tree -m -u from to brings the shared index and working tree to to (a two-tree merge, so uncommitted edits at other paths survive) while the ref still names from.
  3. update-ref ref to expected, the compare-and-swap. raced (a writer that does not take the lock moved it) rolls the checkout back to from, and so does a git failure that left the ref where it was.

The three steps are uninterruptible once the lock is held, so an interrupt cannot leave the checkout one step ahead of a ref that never moved; only waiting for the lock can be interrupted.

string | null

What the ref must still name: the compare-and-swap’s old value; null when it must not exist.

string

The commit the checkout holds now: expected, or a new session’s base when the ref is unborn.

Plumbing

CheckoutLockOptions

readonly string[]

The paths from..to changes; uncommitted state at any of them is worktree-dirty.

string

string

string

Effect<Landing, StorageFailure | GitFailure>


function makePlumbing(input): Plumbing;

The plumbing over root. indexFile is the index the staging calls (readTree, updateIndexAdd, updateIndexRemove, writeTree) read and write: a commit’s own temporary file (withTemporaryIndex in commit.ts). Without one those four fail with a GitFailure before any child runs, so a caller that only reads refs, moves a ref, or updates the checkout can never stage into the shared index by accident.

string

string

Plumbing


function missingRepoints(
view,
ops,
scope?,
cap?
): object;

The repoints a log already written lacks against view, the version a commit lands it on, made to go after the log rather than inside it, so the log is only ever appended to (§D2).

They are withRepoints’ pairs with each holder at the path the log moves it to, since an edit after a move is made at the destination. Every pair is judged by validateOps as part of the log, whose dangling-edge rule runs after the whole batch, so a pair after the archive it repoints for passes like one before it. A log that is already complete gains nothing.

overflow names, as a format violation on its source, each archive or move whose pairs would take a log within cap over it, taking the pairs in log order while they fit; when it is not empty the commit should write nothing and refuse with it, so --drop-refused can drop those ops, with what afterDroppedVacates takes after them, and land the rest. A log already over cap gains nothing and names nothing: its own batch-cap refusal stands.

HeadView

readonly OverlayOp[]

CommitScope = "session"

number = BATCH_CAP

object

readonly ops: readonly OverlayOp[];
readonly overflow: readonly Violation[];

function pruneSessions(input): Effect<PruneReport, StorageFailure | GitFailure>;

boolean

number

The instant ages are measured from; now unless a test pins it.

number

string

Effect<PruneReport, StorageFailure | GitFailure>


function rebaseSession(session, onto): Session;

Move the session’s base to a newer version, keeping its ops. Pure: the log on disk moves when the caller next appends or commits, and validation of the kept ops happens at that commit.

Session

HeadView & object

Session


function replaceOps(session, ops): Effect<Session, StorageFailure>;

Replace the overlay log’s ops with ops, keeping the base and the ref. The one way ops leave a log short of a commit: session commit --drop-refused takes out the ops a rebase revealed as unlandable (a duplicate or a claim-edit against what main gained meanwhile) so the rest can land, and writes back the kept ops with the repoints a drop took from them appended again (missingRepoints). Short of this, ops are only ever appended. Refused with LOG_MOVED, like every other log write, when the log is no longer the one session was read from, so a put or exec that landed meanwhile is never lost.

Also refused, with session.pending: ... (SESSION_PENDING) and nothing written, when the stored log carries a pending commit. ops was computed from ops that commit built, and when it landed they are on the ref, so writing them back would commit them twice (or record them as dropped); the commit that settles pending decides that, and the failure names it. A commit settles pending before it refuses anything, so this is reached only when another commit of the same session ran between the refusal and the replacement.

Session

readonly OverlayOp[]

Effect<Session, StorageFailure>


function resumeSession(input): Effect<Session, StorageFailure>;

Reopen a session from its log. A missing or malformed log is a StorageFailure.

string

string

Effect<Session, StorageFailure>


function saveSession(session): Effect<void, StorageFailure>;

Persist a rebased or otherwise changed session without appending. Takes the session’s lock, and is refused with LOG_MOVED when the log’s ops are no longer session’s: a rebase keeps every op it read, so an op appended since would otherwise be dropped.

Session

Effect<void, StorageFailure>


function sessionStateFile(root, id): string;

string

string

string


function startSession(input): Effect<Session, StorageFailure | GitFailure>;

Start a session on a base version. Creates the state directory, checks that the base names a commit (rev-parse; a GitFailure when it does not), and writes an empty log. Fails when the id is not a single path segment.

An id that already has a log is refused with session.exists unless force is set: a retried session start or two agents choosing one id would otherwise replace an in-progress overlay with an empty one and report success. resumeSession is the way to reopen a log; force is the way to discard one on purpose.

Only the base’s sha is read (for the check and the log), so a caller that has no head loaded passes { sha }: the implicit start of session put and session exec under MEMHTML_SESSION costs a rev-parse rather than a head load. A HeadView with its sha still fits.

SessionBase

boolean

string

string

string

Effect<Session, StorageFailure | GitFailure>


function touchedPaths(op): readonly string[];

Every path an op reads or writes, for the disjointness check and the reserved-path check.

OverlayOp

readonly string[]


function validateOps(
view,
ops,
options?
): readonly Violation[];

HeadView

readonly OverlayOp[]

ValidateOptions = {}

readonly Violation[]


function withCheckoutLock<A, E, R>(
root,
effect,
options?
): Effect<A, E | StorageFailure, R>;

Run effect holding the repository’s checkout lock: wait up to 15 s for another writer of the shared index (a v1 write from any process, another session’s commit, a curate merge), then fail with StorageFailure checkout.locked. Released on every exit. Reentrant within one fiber.

A

E

R

string

Effect<A, E, R>

CheckoutLockOptions

Effect<A, E | StorageFailure, R>


function withRepoints(
view,
ops,
scope?
): readonly OverlayOp[];

The edge repoints a batch of moves and archives needs, completed from the view.

A move relocates one record and nothing else, and an archive moves one under its archive/<YYYY>/ slot, so every live edge that named the old path has to be moved by an unlink of it and a link of the same rel to the destination (the slot, for an archive), in the same batch; validateOps refuses a batch that leaves one behind. The pairs are mechanical (the view’s inbound index names every holder), so a writer should never have to compute them: session exec completes its harvest with this, the curator’s binder completes a proposal with it before it judges it, curate collapse completes the archives its driver adds, and curate merge completes a replay against the tip it lands on. A log already written is completed by missingRepoints instead, which appends what it lacks.

The repoints go in before the batch’s first move or archive, so a holder the batch itself moves is edited at its old path and the edit travels with it. A holder the batch archives is left alone (its archived copy keeps the edge as history, which validateOps allows), an edge the batch already unlinks (at the holder’s path, or at the path the batch moved it to) is left to the batch, and a move’s source the batch puts a new record at needs nothing, nor does an archive’s source the batch puts at after the archive (a claim edit, refused whatever the edges). Nor does a source under archive/, or at a path that is not a usable memory path (activeRecordPathViolation): validateOps refuses that archive or move, and a pair for it would aim a live edge under archive/<YYYY>/archive/, or out of the archive, at a path no commit writes. A holder scope may not write (isReservedPath: an arc or a people record under session) is left alone too, its edge the curator’s to repoint; validateOps lets an archive leave it and names it on a move. Edges a batch’s own puts or links aim at the old path are not the view’s to know; validateOps names them.

HeadView

readonly OverlayOp[]

CommitScope = "session"

readonly OverlayOp[]


function withSessionLock<A, E, R>(
root,
id,
effect,
options?
): Effect<A, StorageFailure | E, R>;

Run effect holding the session’s exclusive lock. A lock already held fails with session.locked at once rather than waiting: the holder is another writer of the same id, and the right answer for a double-submitted command is a refusal, not a second commit.

A lock that is compromised while held (its directory removed, or its mtime moved by another writer that judged it stale, because this holder missed its refreshes for SESSION_LOCK_STALE_MS) is logged as a warning when the holder finishes, rather than proper-lockfile’s default of throwing from its refresh timer, which would kill the process mid-commit: in head serve that is every request in flight. The effect runs to its end either way, as it would have without the lock’s loss being noticed; the release that follows touches nothing another writer took.

A

E

R

string

string

Effect<A, E, R>

SessionLockOptions = {}

Effect<A, StorageFailure | E, R>


function writeBarReasons(
path,
html,
policy?
): readonly string[];

Why a new record falls below the store’s write bar, or nothing when it clears it.

The bar has two halves. This is the mechanical one, and its rules are the store’s (writeBar in memhtml.config.json): a type the store refuses (refusedTypes, each with the reason its refusal gives), and, under requireAnchor, a record that does not name what it is about through at least one memhtml-entity or by sitting in a workspace under projects/<slug>/. The other half (a mechanism or a decision, something a future run would look up) needs a model’s judgment and is the writing agent’s, stated in the session put and session exec help and, for the curator, in its two charters.

The bar holds under every scope and for every type, the curator’s writes and tasks included: a task under projects/<slug>/tasks/ is anchored by its path, one in the inbox by an entity. An archive destination is an existing record moving, never a new one, and is not judged. A file that does not parse answers nothing here, because the format check has already refused it. With the library default (DEFAULT_WRITE_BAR) nothing is refused.

string

string

WriteBarPolicy = DEFAULT_WRITE_BAR

readonly string[]