Skip to content
STAIxBWLBPublic

About

vmem: validated, Obsidian-compatible Markdown knowledge vaults for agents and people. Every write passes one rule set or nothing is written. Part of the ahub family.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

vault-mem

An Obsidian-compatible knowledge vault tool for agents and people. Requires Bun >=1.4.2. The full check (bun run check) needs Bun, git and tar; it includes a package dry-run and clean installed CLI/MCP smoke. Node/npm are used by the separate release workflow, not required for the ordinary Bun gate. CI runs on Ubuntu and macOS for pull requests and for pushes to main.

Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the vmem mcp stdio server, reversible vmem setup host registration and promotion from agent-mem (promote-from-amem) are also implemented below. Lint/graph analysis, promotion to org vaults (vmem promote) and explicit vmem sync are implemented below. The vmem plugin for the acon console (agent-console) is described under "Console plugin"; vault-mem ships no console bin.

Status: released versions are published to npm (CHANGELOG, release readiness). Replacing an existing vault writer is a live cutover: it is the parent plan's operation, outside this repository, and follows the cutover checklist. This repository does not record its result, and a release does not authorize it.

bun install
bun bin/vmem.js --help
bun bin/vmem.js vaults add personal /absolute/vault --kind personal --write mcp
bun bin/vmem.js status --json
bun bin/vmem.js doctor
bun bin/vmem.js vaults remove personal
bun bin/vmem.js vaults remove personal --confirm
bun bin/vmem.js recover            # preview crash leftovers
bun bin/vmem.js recover --confirm
bun run check

Output: every command prints text for people by default, following agent-hub's CLI style. Lists are tables with uppercase headers, single objects are a title line and aligned fields, checks (doctor, check, lint, spec validate) print LEVEL/CHECK/FINDING rows and a verdict, and writes print what changed or would change. A preview prints the exact confirm command with its fingerprint; a truncated read or full page prints the command for the next one. --json prints compact JSON objects for scripts and agents; existing fields keep their shape; status and vaults list additionally report effective domains. --color=auto|always|never is accepted by every command: auto colors only a terminal, and NO_COLOR or TERM=dumb turn it off. Text wraps to the terminal width, or to COLUMNS when output is not a terminal. Errors stay vmem: <message> on stderr, and exit codes are unchanged. vmem help <command> or <command> --help prints one command's entries.

The first remove prints a preview; --confirm unregisters the vault. It never deletes vault files. Commands work without a TTY and in agent environments. Add requires an existing notes/ directory, stores the canonical vault path, and rejects duplicate ids and paths. Doctor returns a nonzero status when a registered vault becomes unavailable or its notes directory is replaced by a symlink.

Home precedence: VMEM_HOME (the pointer is not consulted), else a home pointer in the default home's vmem.config.json, else the default home itself. The default home is $AGENTHUB_HOME/vmem when AGENTHUB_HOME is set, otherwise ~/.agenthub/vmem. Home paths are reported absolute and lexically normalized (., .. and trailing slashes removed, symlinks not resolved); registered vault paths are stored and reported canonical (symlinks resolved). Environment paths and configured paths must be absolute. A redirect file contains only home; the target contains the registry and policy, and cannot redirect again. Missing config means an empty registry. Malformed, oversized, linked or nonregular config files fail closed.

{"vaults":[{"id":"personal","path":"/absolute/vault","kind":"personal","write":"mcp"}],"denyPaths":[],"piiPatterns":[]}

Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. An optional top-level version (positive integer, absent means 1) names the config format; this build supports format 4. Registry writes add version: 4 when a vault declares canonTables, at least version: 3 for domains, or at least version: 2 for specs. Configurations without these fields keep their existing version field (or its absence). A config whose version is newer than this build's format is read tolerantly: unknown keys at the top level, in vaults and in PII policies are kept and written back unchanged by registry commands, while the keys this build knows are still validated. A PII policy with members this build does not know cannot be enforced, so it fails closed: every write on that vault (note writes and check, two-step previews and confirms, tidy, promote-from-amem, promote and log appends) is refused with PII_POLICY, naming the members, until the newer vmem that wrote the policy does the write. Reads and vaults pii show are unaffected. vaults pii --set refuses to replace a policy that carries such members, since the replacement would drop them; change it with the newer vmem. Versions that add a key bump the format. Errors name the config file or vault id and path but never echo config or note contents.

A vault entry may declare "domains": ["finance", "health", "household"] in vmem.config.json (or in the object passed to addVault). This adds at most 16 ids matching [a-z][a-z0-9-]{0,31} to the built-ins research, projects, teaching, operations, people, ai-practice; an empty or omitted array leaves the built-ins available. Duplicate ids count toward the 16-entry declaration bound and appear once in the effective set. status --json, MCP vmem_status and vaults list report the effective set as domains (the text list has a DOMAINS column). Reads do not rewrite config. Writers and lint check domain values against the selected vault; missing-domain lint behavior is unchanged. promote and promote-from-amem check the target vault's set and name an undeclared domain with how to fix it; a personal-only domain is refused by an org vault that does not declare it. The existing per-vault PII policy applies to every domain; note types remain fixed.

Durability: the temporary file is fsynced before the rename and the home directory is fsynced after it. On Linux this makes the new config durable once the command returns. On macOS directory fsync succeeds but does not flush the drive cache (no F_FULLFSYNC), so a power loss right after a change can revert to the previous complete config, never to a partial one. Windows is not a supported platform for durability claims; the rename stays atomic.

Crash recovery: the lock file records the writer's pid, host and start time. Nothing is cleaned up automatically. vmem recover previews the state; vmem recover --confirm removes the lock only when it names this host and a pid that no longer runs, and then removes leftover .config-<uuid>.tmp files. A live lock, a lock from another host, or an unreadable (older-format) lock is never removed and the command exits 1; confirm no writer runs, then delete config.lock by hand. No other file in the home or any vault is touched. If cleanup fails after an operation (for example the lock cannot be removed), the original error is kept and the cleanup failure, including a retained lock, is appended to it.

Vault fields: id, absolute path (must contain a real notes/ directory), kind (personal|org), write (mcp|cli|none), optional pii (the allow|deny shorthand or a policy object, see PII policy below; default allow for personal and deny for org, and status/vaults list always report the effective shorthand and piiPolicy matrix, including for unhealthy entries), optional remote (any nonempty string such as an https:// or git@host:path URL) and optional registry (absolute path). For healthy vaults, doctor checks locally that the vault is a git repository root with an origin whose fetch and push URLs all match remote (URL rewrites are refused), and that registry passes the same deny-path, size and project-id validation as log appends. Results appear under each vault's checks.remote and checks.registry. Doctor also runs the tidy planning pass for writable vaults and reports pending counts and unparsable paths, without per-note writer validation or writes. Planning failures appear as checks.tidy; a failed check exits 1. Actual tidy previews validate planned notes and identify skipped notes. These checks use no network and do not test reachability. status is unchanged. Policy fields are stored for later note-writing stages, not enforced by the read commands. Writer commands enforce write/channel policy and configured deny/PII screening.

A vault may declare canonTables in format 4, with no default note names:

{"version":4,"vaults":[{"id":"personal","path":"/absolute/vault","kind":"personal","write":"mcp","canonTables":[{"path":"notes/entities.md"},{"path":"notes/roster.md","keyColumn":2}]}]}

Each entry names a literal flat notes/<name>.md path in that vault (at most 256 UTF-8 bytes); absolute/traversing/dot paths, backslashes and controls are config errors. Up to 16 entries are allowed, with keyColumn defaulting to the first column and otherwise 1..64. Duplicate path/column pairs are refused. Missing, unreadable or denied table notes at preview time contribute no rows and produce an advisory warning with a repair step. They never become a promotion refusal. Other write policy and note validation remain enforced.

The only runtime dependency is the exact first-party pin @staix/agent-mem@0.23.0; it is not loaded by registry commands. Public library exports (@staix/vault-mem, from src/index.ts) cover configuration and home resolution, registry operations, frontmatter, link resolution and the derived index, validated writes, the append-only log, the MCP server and its vault tools, host setup, the CLI command table and completion, promotion from agent-mem and to org vaults, lint and graph analysis, sync, source references, migration tidy, and the spec source registry, graph, validation and freshness. @staix/vault-mem/ui-plugin is the console plugin entry (see Console plugin). No console bin is shipped. Exported functions and constants by module:

  • src/home.ts: resolveHome
  • src/config.ts: parseConfig, parseVault, parseSpec, readConfig, CONFIG_FILE, MAX_CONFIG_BYTES
  • src/registry.ts: addVault, removeVault, addSpec, removeSpec, specStatus, loadRegistry, registryStatus, recoverRegistry, effectivePii, syncDirectory, LOCK_FILE
  • src/frontmatter.ts: parseFrontmatter, emitFrontmatter
  • src/links.ts: extractLinks, resolveLinks, normalizeTarget
  • src/vault-index.ts: VaultIndex, MAX_NOTE_BYTES, MAX_RESULTS, MAX_READ_BYTES
  • src/writer.ts: applyWrite, sha256, MUTATION_LOCK, MAX_WRITE_BYTES
  • src/write-validation.ts: validateWrite, screenContent
  • src/vault-log.ts: appendLog, readLog, LOG_LOCK, MAX_LOG_READ_BYTES, MAX_LOG_RESULT_BYTES, LARGE_LOG_LINES
  • src/log-format.ts: formatLogEvent, LOG_TYPES, AGENT_MARKERS
  • src/mcp.ts: serveMcp, validArguments, FRAME_BYTES, RESULT_BYTES
  • src/mcp-vault.ts: createVaultMcp, vaultTools, VMEM_TOOL_NAMES
  • src/setup.ts: runSetup, defaultEntry, SETUP_TARGETS, SERVER_NAME
  • src/commands-table.ts: COMMANDS, completionDefinitions, completionScript
  • src/promote.ts: promoteFromAmem, listPromotionCandidates, draftNote, stemFor, logProjectFor
  • src/vault-lint.ts: lintVault
  • src/vault-graph.ts: buildGraph, graphNeighbors, graphReport
  • src/analysis-reports.ts: writeAnalysisReports
  • src/org-promote.ts: promoteToOrg, screenForOrg, dropUnresolvedLinks, lintPromoted
  • src/sync.ts: syncVault, sameRemote
  • src/source.ts: normalizeSource, sourceReferences
  • src/tidy.ts: tidyVault, parseTidyMap, TIDY_RULES
  • src/spec-source.ts: resolveSpecWorktree, discoverSpecs, SpecIndex
  • src/spec-graph.ts: buildSpecGraph, specSlice, SPEC_EDGE_KINDS, MAX_SPEC_INPUTS, MAX_SPEC_EDGES
  • src/spec-validate.ts: validateSpecs, introducedSpecErrors, validateSpecRepo
  • src/spec-freshness.ts: specFreshness, specsCoveringPath

PII policy

pii is either the shorthand allow/deny (every category on every surface) or an object that sets an action per category and, optionally, per surface:

{"email": {"notes": "allow", "log": "deny"}, "phone": "warn", "custom": "deny",
 "patterns": [{"name": "student-id", "regex": "S\\d{6}", "action": {"notes": "deny", "log": "allow"}}],
 "allow": ["owner@example.org", "@example.org", "+82 10 0000 0000"]}
  • Categories:
    • email;
    • phone;
    • custom, the global piiPatterns;
    • named per-vault patterns (at most 32; names [a-z][a-z0-9-]{0,31}, regexes compiled with the u flag).
  • Surfaces:
    • notes: note and report writes, frontmatter screening, tidy value echo;
    • log: log appends and tidy --log on existing lines;
    • promotion: the screen vmem promote applies when this vault is the target.
  • Actions:
    • allow: not screened.
    • warn: the write proceeds with a V07 warning, and tidy echo prints the value as [redacted].
    • deny: V07 error, and tidy --log replaces matches in existing lines.
    • Org promotion refuses only deny cells and secrets. The promoted note's write into the target vault is screened with the target's promotion cells, not its notes cells.
    • tidy --log replaces the raw denied matches and leaves allow-listed values intact. Screening also checks the NFKC form. If a denied match is zero-length, or the replaced line still contains a denied match (for example a form only NFKC normalization reveals, or a pattern that matches the [redacted] marker), the whole line is redacted (fail closed).
  • An omitted category or surface takes the vault default: allow for personal vaults, deny for org vaults.
  • allow entries (at most 64) are exempt everywhere:
    • an exact address, compared case-insensitively;
    • an @domain suffix;
    • a phone number standing alone, compared by its digits. Several numbers run together with spaces are never exempt (fail closed);
    • an exact match of a pattern.
  • Secret detection is separate from this policy and always applies.
vmem vaults pii personal                                   # effective matrix and allow list
vmem vaults pii personal --set '{"email":{"log":"deny"}}'  # preview: changed cells and every loosened cell
vmem vaults pii personal --set '{"email":{"log":"deny"}}' --confirm

The policy is changed only through the CLI, under the config lock. MCP clients can read it in vmem_status but cannot set it.

Indexed reads

bun bin/vmem.js rebuild personal
bun bin/vmem.js search personal "project discovery" --type insight --domain research --limit 20
bun bin/vmem.js show personal "Note or alias" --max-bytes 16384
bun bin/vmem.js links personal "Note or alias" --limit 50 --json

--type and --domain keep only notes whose frontmatter type and domain equal the given value. Validated writes accept these note types: insight, decision, observation, person, project, method, moc, reference.

All four commands require a registered vault id. They read only flat, regular notes/*.md files (case-sensitive .md extension), skip subdirectories and symlink files, and refuse a replaced vault/notes directory. Notes are limited to 2 MiB each. They never write note content, registry configuration or .obsidian/. rebuild rebuilds the derived cache; other commands scan metadata on every invocation and rehash new or metadata-changed files. Content-identical changes only update metadata; deleted/renamed paths leave the index. SQLite transactions make each scan atomic. A concurrently changing note aborts that scan, so retry after its writer finishes. No watcher, model calls or embeddings run.

The cache is $VMEM_HOME/indexes/<canonical-vault-path-sha256>-links<N>-schema<S>.sqlite using the resolved home, not necessarily the environment variable. N is the link-resolution version (currently 2) and S the index schema version (currently 4; schema 2 added the CJK bigram table, schema 3 the reference identifiers and schema 4 the arXiv id reference notes are searchable by), so two installed vmem versions that resolve links or store the index differently keep separate caches instead of rebuilding each other's; 0.2.4 and older use the unversioned <sha256>.sqlite, 0.3.0 through 0.3.3 use <sha256>-links2.sqlite, 0.4.0 uses <sha256>-links2-schema2.sqlite, and a first run after upgrading builds the new file once with no manual step. Cache files use mode 0600 on creation. The location must be outside every registered vault, including through symlinks; linked cache directories/files are refused. It contains note text and is disposable private derived state. Removing it while commands are stopped allows the next read to build it again. SQLite uses its own transaction lock and a five-second busy timeout. There is no automatic migration or live vault cutover.

Search uses FTS5 unicode61 over title, description and body with BM25 weights 5:2:1, ascending score and path tie-breaking. Whitespace-separated query terms are literal AND terms, not a raw FTS expression; quote the query as one shell argument. A term whose letters and digits form one run of 2 or more Hangul, Han, Hiragana or Katakana characters (surrounding punctuation is ignored) matches wherever it occurs in the title, description or body, including particle and compound forms: 사업단 finds 사업단은, 사업단장 and 해외사업단. That matching uses a side FTS5 table of overlapping character bigrams (Unicode NFC), so a 2-character term is one indexed lookup and a longer term a phrase of consecutive bigrams; it is substring matching, not morphological analysis. Every other term, including Latin words, single CJK characters and terms mixing CJK with Latin letters or digits (AI교육), keeps unicode61 token matching, and a query without CJK terms ranks exactly as before. With a CJK term, notes where every CJK term is a standalone word or occurs in the title or description rank before notes that contain it only inside a longer body word; within each group BM25 over the bigram table (5:2:1) and path break ties, and the snippet is a body window around the first CJK term. --type and --domain are exact scalar frontmatter filters. Results include a bounded snippet and metadata, not the full body. Search queries and note targets accept at most 1024 UTF-8 bytes. Search/links default to 50 results, with an enforced 1..500 range. Show defaults to 16384 UTF-8 bytes and permits 1..65536, returning truncated and the full note SHA-256. --section "Exact H2" selects one real H2 through the next H1/H2, including its H3 children; code-fenced headings are ignored and duplicate/missing names refuse. Truncated reads include bounded headings (at most 64 names/8KiB metadata; headingsTruncated when omitted) and nextOffset. --offset N is a UTF-8 byte offset within the selected section or whole note; use the returned nextOffset to page even a single section larger than 64 KiB, and compare note hashes across pages. Invalid UTF-8 boundaries and zero-progress byte budgets refuse. Link string fields are capped at 1024 bytes in command results. Oversized arguments fail instead of silently raising a bound.

Link resolution uses NFC and case-insensitive filename stems only, as Obsidian and vmem lint L01 do. Frontmatter aliases (string or string array; legacy alias accepted) are not link targets; show, links <target> and vmem_read still find a note by alias. A link that only an alias matches is a missing-note link, so the writer refuses a new one and org promotion drops it to text. An optional notes/ prefix and .md suffix are accepted. [[target|label]], [[target#heading]], [[target#^block]], ![[target]] and same-note anchors work. ATX/Setext headings and end-of-line block ids are checked; fenced code is skipped. Missing/ambiguous targets and missing anchors remain explicit with a reason. Stems that differ only by case or Unicode normalization are ambiguous. Reported path values are the on-disk file names byte for byte, while targets (show, links, vmem_read, wiki-links) are compared after NFC: a note another tool saved under an NFD name is found from an NFC target and reported under its NFD name. Links returns outgoing links in source order, with one-based body-relative line values (frontmatter excluded); it does not write backlinks or repair dead links. A dead link is also offered at most one suggested existing target: the closest indexed title to its text, with its positive title score, above a minimum score. links returns it as suggested on the dead entry, and vmem lint L01 names the same one. A text that mixes Hangul and Latin is ranked and thresholded on both parts added together, so a Hangul phrase most titles share cannot hide the one title that carries the whole text. A hint is looked up for at most 1024 UTF-8 bytes of the raw link text, the bound a reported link target is capped at, and the bound is read on the raw text before a control character is read as the separator the index already tokenizes it as, never on the capped target a row displays, so links, vmem_links and vmem lint name the same hint for the same dead link and a longer text gets no suggestion. A text FTS cannot parse is a miss rather than an error. It is a hint only; nothing rewrites the link.

parseFrontmatter retains source bytes alongside semantic YAML data using Bun's built-in YAML parser. emitFrontmatter(document, changes) returns the entire note, preserving omitted and semantically unchanged keys byte-identically (comments, quoting, sequences, one-level mappings, folded/literal/chomped scalars, BOM and line endings). Changed keys use canonical JSON-compatible YAML; undefined removes a key. Editing requires a block mapping, while an unchanged flow mapping round-trips. This is a library transformation only, not a note-write command. Synthetic fixtures and create/edit/delete/rename parity tests cover this increment; Linux/macOS CI remains the platform verification gate.

Validated writes

Authored note content writes stamp updated with the local calendar date (YYYY-MM-DD, using the same clock as vault logs): create, append, patch, frontmatter edits (including explicit DOI assignment), both sides of supersession, promotion and link. Mechanical move rewrites (including backlinks), tidy and sanitize preserve existing dates, as do checks, reports and semantic no-ops. Create also fills created when the key is absent; existing and caller-supplied values retain their current validation. Automatic stamping takes precedence over an explicit updated set or unset on an authored write; the caller candidate still passes normal validation first. The writer validates the final content too, reusing parsed snapshots across the two passes. Writer and promotion previews account for the dates without writing; their date-bearing fingerprints need a fresh preview after a date change. A header that cannot be edited in place, including flow mappings and merge-key block mappings, is never canonicalized just to add dates: the authored candidate keeps its header bytes and gets a V01 warning. If adding only the automatic dates would push an otherwise valid note over 256 KiB, the candidate is saved unstamped with a V01 warning asking you to shorten the note enough to leave room for later automatic dates. Oversized caller content is still refused. Stamping also reparses its candidate and preserves YAML anchor/alias relationships. If emission would remove or change a reference, alter other metadata, or produce an invalid header, the original authored candidate is kept unstamped. A cause-specific V01 warning directs you to open the note in a text editor and rewrite its header as a block mapping with plain literal values. Replace any aliases with their literal values and remove anchors; later authored writes then stamp updated automatically. This is a manual header edit, not a vmem frontmatter operation, which cannot repair every flow header or semantic no-op alias. An applied receipt says the content was saved; the warning never advises repeating that content write. Readonly previews do not claim a save. Unrelated anchors and aliases remain intact and do not prevent stamping.

All mutations use applyWrite(home, request): per-vault mutation.lock, current-SHA checks, in-memory planning, final-snapshot validation/screening, same-directory flushed temporary files and atomic renames, directory flushes, derived-index refresh and a structured receipt. write: mcp allows CLI and MCP writes; cli allows CLI only; none refuses mutations. check is read-only and works with none. No command requires a TTY or rejects agent environments.

vmem new personal example-finding --type insight --domain research --topics research --description "A distinct supporting observation."
vmem new personal another-finding --type insight --domain research --topics research --description "An independently sourced result." --body body.md
vmem patch personal example-finding --old "This note argues that" --new "This finding states that" --expected-sha <sha256>
vmem patch personal example-finding --edits edits.json --expected-sha <sha256>   # [{"oldString": "...", "newString": "..."}, ...]
vmem append personal example-finding --section Evidence --markdown "Additional evidence."
vmem frontmatter personal example-finding --set '{"confidence":"likely"}'
vmem link personal example-finding another-finding --relation supports
vmem supersede personal example-finding another-finding
vmem check personal candidate.md --path candidate
vmem report personal reports/result.md --body report.md
vmem move personal example-finding renamed-finding
vmem move personal example-finding renamed-finding --confirm --fingerprint <preview-fingerprint>
vmem delete personal renamed-finding
vmem tidy personal --connections
vmem tidy personal --all --json                 # migration sanitize preview (see Migration tidy)

Syntax follows indexed reads: the vault id comes first. new supports --title, --source, --confidence, --status, --extra JSON and --body file|-; omitted body gets an H1/Relevant Notes/Topics skeleton. Patch accepts nonempty old/new strings up to 8192 bytes and requires exactly one occurrence (including overlapping occurrences). --edits file|- (MCP edits) instead takes a JSON array of 1 to 32 {oldString, newString} pairs with the same bounds: each oldString must match exactly once in the original note, edits must not overlap and none may start in frontmatter. They apply all-or-nothing, so any failing edit (or a stale --expected-sha) refuses the whole patch; a success gives one receipt and one log event, and the 256 KiB bound applies to the combined result. Use exactly one form per call; both or neither is refused. Append uses an existing section or inserts it before Topics; it refuses targeting or introducing a Topics H2. frontmatter supports --set JSON, --unset key,key and --expected-sha; changing topics also replaces only the body Topics wiki-list items in the same write, preserving other content and line endings; setting an equal topics value leaves the body unchanged; append also supports --expected-sha. Link relations are supports, contradicts, extends, applies-to, context-for; links are bidirectional unless --one-way. Supersede sets status/superseded_by on the old note and adds a reverse link in the next note. Report permits plain paths below reports/ or ops/, is schema-exempt and still screened. Inputs are bounded to 256 KiB, follow no file symlinks and accept stdin with -.

Reference notes may optionally hold citekey ([A-Za-z0-9_:-]{1,64}, unique vault-wide ignoring ASCII case (stored case is preserved)), doi (10. plus a 4..9-digit registrant, / and a nonempty suffix without whitespace or control characters), arxiv_id (YYMM.NNNN for YY < 15, YYMM.NNNNN for YY >= 15, or archive/YYMMNNN, optionally vN with N >= 1 and month 01..12), authors (1..100 nonempty strings), year (integer 1000..2100) and venue (1..200 Unicode characters). These keys are refused on other note types. Create and explicit DOI assignments store lowercase; body-only edits preserve legacy metadata. Identifier validation is offline syntax only, with no existence lookup or model call. vmem search <vault> "10.1234/example" --type reference (MCP vmem_search with type: "reference") searches DOI, citekey and arXiv id as well as title, description and body; the arXiv id joins the search text without its vN version suffix, so 2502.04321 finds a note holding arxiv_id: 2502.04321v2. The derived cache uses -links2-schema4.sqlite, rebuilt once independently of older versions, so an index built before arXiv ids were indexed answers from a fresh cache rather than without the id; stored note bodies and lint input stay unchanged.

--dry-run plans and validates any writer without creating locks, temporary files, directories or caches. check always takes this path, even without --dry-run. Move, delete and tidy first return a preview with planned path/hash changes and a fingerprint; confirmation requires that fingerprint. It covers canonical operation input, the canonical vault path/identity and policy, expected SHAs, every scanned note and all touched files, so even a new inbound reference after preview forces another preview. Move rewrites visible wiki/heading/block/embed targets and wiki references in frontmatter across the vault, preserving explicit |label text, anchors and code. Alias-only links resolve to no note and are left as written. The move source may be an existing note with a legacy file name (lint L13, for example a dot in the stem); the destination must be a valid stem. Tidy folds Connections sections into Relevant Notes. Delete refuses when it would newly break inbound links or MOC topics; fix those references first.

Validation returns named issues with severity and no matched secret text: V01 schema/enums/description; V02 existing MOC topics; V03 newly unresolved links; V04 flat ASCII note paths (maximum 200 bytes) and safe report paths; V05 final Topics placement/content (error when the write introduces a placement or content problem the note did not already have, or on create; warning when every problem was already present), with explicit append-to-Topics refusal; V06 pinned agent-mem secret regex parity and denied sources/input/snapshot paths; V07 email/phone/configured PII patterns when denied; V08 create duplicates (stem/NFKC H1); V09 256 KiB prospective note size / 16 MiB report size, exact patch and expected-SHA/drift; V10 supersession target integrity. Unchanged pre-existing broken links warn, including after a move. possibleDuplicates lists candidate paths for warnings from a disposable in-memory FTS5 query of title terms, matched like search terms (CJK substrings included), with BM25 <= -0.000001, up to three paths; no disk cache is written for that check. LOCKED, IO, ROLLBACK, INDEX and CLEANUP identify operational failures. CLI returns exit 1 when ok is false.

A successful receipt reports applied, planned path/hash/byte changes, issues and indexUpdated. Unrelated malformed legacy YAML is skipped by duplicate hints and by writer index refresh, with a V01 warning naming the unchanged note; normal read/rebuild commands retain strict parsing. Move/tidy still require parseable inputs and name the offending path on refusal. Reads allocate each note's stat size plus one growth-probe byte, capped at the 2 MiB read limit plus one. A failed index refresh rolls back the file changes. Rollback first verifies each file still equals this operation's committed result; external edits are preserved. Failed/refused rollback retains the original backup and reports its relative recovery path. All files are staged before replacement and backups permit rollback of caught multi-file failures. Each individual rename is atomic; a machine/process crash between multiple renames can leave a partially completed batch. The retained lock and .vmem-<uuid>.tmp files require inspection and manual recovery after confirming the writer stopped. They are never automatically removed by registry recovery. Uncoordinated editors do not honor the lock; the pipeline rechecks SHAs before replacement, but this does not provide a filesystem transaction against concurrent external edits. Existing notes may be read up to the index's 2 MiB limit; prospective notes are limited to 256 KiB; report operations under reports/ and ops/ allow up to 16 MiB for both prospective content and existing-file reads (including staging and rollback rechecks), with at most 10000 flat notes per mutation snapshot. File/directory fsync has the platform guarantees described above. Successful changed mutations now append an event as described below; log failure is reported separately from note success.

Note merge

vmem merge <vault> <keep> <absorb> and vmem_merge preview a lossless merge, then require confirm with that preview's fingerprint. Preview creates no lock, cache or vault file. The JSON receipt's merge object contains keep, absorb, the full merged body, stable keep-first source and topics unions, and inboundLinks with original path, field, target, replacement, body-relative line and optional fragment. Human output shows the plan and confirm command; --json shows the full body.

vmem merge personal retained-note duplicate-note --json
vmem merge personal retained-note duplicate-note --confirm --fingerprint <sha> --json

Keep's H1 and prose remain first. Absorb's leading # <title> line is removed only when it is the first non-blank line of the original body, before Topics extraction; its other ATX headings are demoted one level (H6 stays H6), and its content follows ## Merged from [[absorb]]. No prose is summarized; fenced and inline code, including multiline spans, stay unchanged. Setext headings and ambiguous blocks stay byte-unchanged. Raw HTML blocks are preserved conservatively from a line starting with up to three spaces then < through the next blank line. Both plain Topics lists become one final union ## Topics; unusual prose/code or mismatched lists in Topics refuse with a repair step so nothing is silently dropped. Source union over 32 entries refuses instead of discarding provenance. Notes must have the same type; person and MOC notes, identical endpoints, and content over the 256 KiB write bound refuse.

Confirm recomputes the plan under the shared vault lock, writes keep, repoints existing inbound references using move's path-only rewrite (preserving labels/fragments), and marks absorb status: superseded, superseded_by: [[keep]]. The absorb file stays. The new provenance heading intentionally links to absorb. Fragment collision checks use the same anchor index as the link reader. Referenced heading/block collisions already present in keep refuse with V03, including absorb's fragment-only self-links ([[#Heading]], [[#^id]]) whose containing note changes on copy: rename the conflicting anchor before a new preview; unreferenced duplicate anchors are allowed. Normal link/metadata/secret/PII validation still applies, and new broken fragments refuse. If keep is already superseded_by: [[absorb]], or absorb has supersedes: [[keep]], keep is older and V10 asks you to swap the endpoints. A keeper supersedes: [[absorb]] relationship is retained as provenance and excluded from repointing. updated changes only on keep and absorb; mechanical inbound rewrites retain dates. All note changes use the existing staged writes and rollback, followed by one default RETHINK event (or the existing log override). A post-publication log failure keeps applied: true and the failing log receipt; notes are not rolled back after an ambiguous append. A stale fingerprint refuses without changing note bytes. An MCP plan exceeding the bounded full acknowledgement is refused before publication with guidance to use CLI preview and confirm or reduce the inputs.

Append-only events

vmem log append personal EXTRACT - synthetic:source "A synthetic event" --dest notes/example.md
vmem log tail personal --limit 50 --json
vmem log grep personal "EXTRACT|CONNECT" --type EXTRACT --limit 20
vmem patch personal example-finding --old "argues" --new "states" --log '{"type":"EXTRACT","project":"-","source":"synthetic:source","note":"Reworded claim"}'

Events keep the canonical local-time format YYYY-MM-DD HH:MM TYPE project source → dest — note (two spaces between fields, one LF at the end). Destination is optional. Allowed new types: INGEST ROUTE EXTRACT CONNECT DIGEST LEARN LINT TASK GRAPH SYNC RETHINK SOURCE. Project must be - or a lowercase alphanumeric/hyphen id; when registry is configured, ids must exist in its bounded YAML projects/nested sub_projects arrays. Registry files are limited to 1 MiB, eight nesting levels and 10000 unique ids; invalid or unavailable configuration fails closed. Invalid types/fields/projects use L09 issues, the 1000-byte line bound uses V09, and secret/PII refusals use V06/V07. Fields cannot inject pipes, control characters or two-space/column separators. Validation and screening run before a file or lock is created. The 1000 UTF-8-byte bound includes the LF and attribution.

Every MCP log append and CLI call with a nonempty AGENTHUB_PEER_ID, CLAUDECODE, CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID or PI_SESSION_ID adds (vmem:agent) to the note. Ordinary CLI events have no automatic suffix. Being an agent never causes refusal. Complete ISO timestamps are excluded from phone-number candidates, while real email/phone/configured PII matches remain refused when the vault denies PII.

appendLog validates the registered canonical vault and write/channel policy, acquires a separate log.lock (bounded two-second contention wait), then makes one O_APPEND write and fsyncs the log. New log files are mode 0600 and their parent directory is flushed. Linked/nonregular files and an incomplete existing final line are refused. Cooperating appenders share the lock; a short write is truncated back only when the same file still has the expected size, and its tail still equals our exact prefix, then flushed. Error-after-write cases inspect at most one event's bytes. Failed/ambiguous writes or cleanup are explicit; the receipt reports appended (possibly null when unknown), synced, bytesWritten (possibly null) and rolledBack when a partial append was removed. Before reporting sync, the current log and vault identities are rechecked; rotation produces LOG_DRIFT and an unknown current-path append state. A synced event can coexist with a cleanup error. No existing event is rewritten. A crash can retain log.lock, and failed short-write recovery can retain an incomplete line; inspect the file and stopped writer before manual recovery. There is no automatic cleanup or new TTY requirement.

Successful changed mutations default to EXTRACT, link to CONNECT, move to ROUTE, and supersede/tidy/merge to RETHINK. Default events use project -, affected paths and the operation name rather than note text. Library callers can supply WriteRequest.log to override the event or false to disable it. Every MCP write tool (vmem_create, vmem_patch, vmem_append, vmem_set_frontmatter, vmem_link, vmem_supersede, vmem_merge, vmem_move, vmem_delete) takes the same optional log object (type, project, source, note, optional dest); move, delete and merge use it on the confirm call. The CLI writers new, patch, append, frontmatter, link, supersede, merge, move, delete and report take the same object as --log JSON, checked against the same closed schema (unknown keys, a missing field or a bad type are refused); omitting it keeps the default event. tidy --log is the unrelated redaction rule. Preview/check/no-op/refused mutations never append. The append is bound to the mutation's resolved vault identity/policy, so re-registration cannot route its event to another vault; the same binding covers the tidy LINT event and both SYNC events of org promotion, which are refused with LOG_DRIFT instead. A bound append compares the registry before any policy or path check, so a re-registration always reads as LOG_DRIFT. Tidy and org promotion also bind every note write to the vaults resolved at the start of the run: if a vault id is re-registered before a write, that write is refused with VAULT_DRIFT and nothing is written to either vault. After a committed mutation, log append is independent: ok/applied describe the note operation, log holds its append receipt, and logged is true only when the event was appended and synced. A log error does not roll back a successfully saved note. CLI exits 1 for a log failure even when the note receipt says ok: true; inspect the receipt before retrying to avoid duplicate mutations/events. A given event is validated and screened like log append (type, registry project id, fields, secret/PII screening, line bound) before any lock or note change, also on dry runs and previews: an invalid event refuses the whole write with its L09/V06/V07/V09 issue and nothing changes. The event is not part of the move/delete/merge fingerprint.

Readers never create a home, log or lock, and work with write: none. They skip blank/comment lines, return the last matching 1..200 records (default 50), accept an optional canonical TYPE and non-backtracking regex subset up to 256 characters (|, ^, $, ., character classes, \d/\s/\w and their inverses, escaped literals, and atom */+/?; groups, backreferences and brace quantifiers are refused), and scan a fixed descriptor-size snapshot. Streaming is capped at 32 MiB; individual historical lines over 4096 bytes and incomplete final lines are omitted with warnings, and the serialized line array stays within 128 KiB. Above 20000 data lines, the manual-archival warning is preserved. Read failure returns dataLines: null rather than claiming a count. Golden synthetic fixtures and concurrent/fault persistence tests cover the format and actual append state; no live vault log was read or modified for this increment.

Vault statistics

vmem stats personal --json             # log window: last 30 days
vmem stats personal --days 7 --json

vmem stats <vault> and vmem_stats report note counts (total, by type, domain and frontmatter status; empty values count as (none)), notes whose indexed mtime falls in the last 7 and 30 local calendar days (today included), and log event counts by type dated on or after log.since, the first local date of the --days window (1..3650, default 30). Known types always appear, unknown ones count as (other). The derived index cache is refreshed as by every read command and the log is streamed with the bounded log reader; no vault file or log is written and no vault or log lock is taken. When the log cannot be read, log.total and log.byType are null, log.issues names the cause and the CLI exits 1.

size adds the note text size in bytes and words, as total and by type and domain ({ notes, bytes, words } each); words are the whitespace-separated tokens of the stored source, frontmatter included. ratio compares each note with the workspace files its frontmatter source cites: the note's byte size over the summed size of those files, reported as median and p90 over the notes that qualified (p90 by nearest rank, both null when none did), with overOne counting the notes larger than their sources and largest listing up to five of them by ratio. notes and sources are the counts behind those figures. Each source counts at its own byte size on disk, not at the size of its decoded text, so a file that is not UTF-8 contributes its real bytes instead of the three bytes each invalid byte becomes when decoded. skipped counts only the source references left out of the ratio, skippedNonFile of them memory ids or URLs rather than files; a path is skipped too when it is denied, missing or unreadable, is past the 2 MiB auxiliary read bound or past the bytes the budget has left, or the call reaches its own limits. Those limits bound the reads and not only the counts: 64 MiB of source bytes and 10000 read attempts, failed or not, where each read asks only for the bytes the budget has left, a larger file is refused from its size without being read, and nothing further is opened once the budget is spent. A note whose readable sources total zero bytes has no ratio and is counted in emptySourceNotes instead, so a file that was read and found empty is never also reported as a skipped reference. truncatedNotes counts the notes that declare a source but fall past the index's 10000-note cap, in path order, so the figures describe those notes and not every sourced note. Source paths resolve from the workspace root found by walking up from the vault's registered project registry; a vault with no registered workspace reads nothing and skips every entry. A note without a source is not part of the ratio. The pass reuses the bounded auxiliary reader, which follows no link, and writes neither the vault nor the sources.

vmem_list pages the whole vault: pass the returned nextCursor as cursor to get the next page in path order. The cursor is the previous page's last path (exclusive), so a note added or removed between pages does not repeat or skip the others; a cursor outside the prefix filter is refused.

MCP server

vmem mcp serves newline-delimited JSON-RPC 2.0 (MCP tools, protocol 2025-06-18 and earlier) on stdio. stdout carries protocol messages only; it needs no TTY and runs in agent sessions. Requests are limited to 64 KiB per frame and results to 256 KiB (a larger result becomes a structured LIMIT refusal). Arguments are validated against each tool's bounded schema (additionalProperties: false, string/array/integer caps); invalid arguments or unknown tools return JSON-RPC -32602, unknown methods -32601, malformed frames -32700/-32600. At startup it loads every module a tool can run, the @staix/agent-mem root included, so a server keeps its own code and tool list when its install is replaced or a newer release is installed beside it; restart the host session to pick up the new release.

The vault set is read once at startup: every tool's vault property is an enum of the registered ids and defaults to the first personal vault. Restart the host session after vaults add/remove. Registered spec sources are also read once when vmem mcp starts; restart the MCP server after specs add or specs remove. Reads go through the same index as the CLI; writes go through applyWrite/appendLog with channel mcp, so they require write: mcp and pass the same V01-V10 gates. Every tool returns a JSON result: a receipt, or {refused: true, issues} for policy, validation or lookup failures. Spec mutations add refused: true only when no change was applied (ok: false, changed: false); post-publication errors return the applied receipt (changed: true, ok: false) without that marker.

tool arguments (caps)
vmem_spec_get id, optional source, absolute worktree, body, maxBytes<=65536, section, offset<=2097152; metadata, relations, hash, freshness and optional body pages
vmem_spec_graph id, optional source, worktree, direction subtree/ancestors/neighbors, edge, depth<=3, limit<=500
vmem_spec_grep optional source, worktree, query<=1024 UTF-8 bytes, literal, type, status, tag, parent, dependsOn, coversPath, offset<=10000, limit<=500; continuation after filters
vmem_spec_validate optional source, worktree, requireFresh, limit<=500; bounded report; stale is fatal only with requireFresh
vmem_status none; vaults with health, write policy, effective pii and piiPolicy (read-only)
vmem_search query<=1000, vault, limit<=50, type, domain
vmem_read path (stem, alias or notes/ path), vault, maxBytes<=65536; returns {path, frontmatter, body, sha256, truncated}
vmem_read_many paths<=20 (8 KiB each), vault
vmem_list vault, prefix, limit<=500, cursor; rows in path order with mtime, the filtered total and nextCursor while more remain
vmem_links path, vault, direction out/in/both, limit<=200; returns {out, dead, in}, each dead entry with suggested (the closest title and its score, or null; a hint, never a rewrite)
vmem_mocs vault; MOC notes with incoming link counts
vmem_check vault, path, text<=256 KiB; validation only
vmem_log_tail vault, n<=200, type, grep<=256
vmem_create stem, type, domain, description, topics[]<=32, title, body<=65536, source, confidence, status, extra, log
vmem_patch path, oldString<=8192 and newString<=8192, or edits[] 1..32 of {oldString, newString} (all-or-nothing), expectedSha, log
vmem_append path, section<=120, markdown<=16384, expectedSha, log
vmem_set_frontmatter path, set, unset[], expectedSha, log
vmem_link from, to, relation, context, bidirectional (default true), log
vmem_supersede old, next, log
vmem_log_append type, project, source, dest, note<=500
vmem_move from, to, confirm, fingerprint (two-step), log (on confirm)
vmem_merge keep, absorb, optional confirm, fingerprint, log; full body/source/topic/inbound preview, then locked merge and supersession
vmem_delete path, confirm, fingerprint (two-step; the preview lists backlinks), log (on confirm)
vmem_promote path (in the personal vault), to (enum of org vaults), topics[], again, confirm, fingerprint (two-step; never pushes)
vmem_skills_mine vault, days, project, allProjects, vocab, skills[], minSize, minNovelty (read-only; see Skill mining)
vmem_stats vault, days<=3650 (default 30); read-only counts, note size and note-to-source ratio, see Vault statistics
vmem_promote_from_amem id, project, stem, type, domain, topics[], description, title, body<=65536, linkTo, confirm, fingerprint (two-step)
vmem_read_file path, vault, maxBytes<=65536, offset<=2097152; returns {path, sha256, truncated, body, offset?, nextOffset?} (read-only; allowed roots below the table)
vmem_list_files dir, vault; one level, <=500 entries {name, path, type, bytes?} (read-only)
vmem_report path under reports/ or ops/, body<=65536, dryRun, log; the receipt of vmem report

vmem mcp lists 36 tools: vmem_status, vmem_search, vmem_read, vmem_read_many, vmem_list, vmem_links, vmem_mocs, vmem_check, vmem_log_tail, vmem_create, vmem_patch, vmem_append, vmem_set_frontmatter, vmem_link, vmem_supersede, vmem_log_append, vmem_move, vmem_merge, vmem_delete, vmem_promote_from_amem, vmem_lint, vmem_graph_build, vmem_graph_neighbors, vmem_promote, vmem_skills_mine, vmem_read_file, vmem_list_files, vmem_report, vmem_stats, vmem_spec_get, vmem_spec_graph, vmem_spec_grep, vmem_spec_validate, vmem_spec_create, vmem_spec_update, vmem_spec_delete.

Move and delete without confirm write nothing and return the preview and fingerprint; confirm: true requires the fingerprint recomputed on the current state, otherwise the call is refused with V09. vmem_promote_from_amem is described under Promotion from agent-mem. vmem_promote is described under Org promotion and sync. Graph/lint tools are listed below. Note paths in read results are relative to notes/.

File reads and reports: vmem_read_file and vmem_list_files read only these roots: ops/, reports/, self/, templates/, AGENTS.md and README.md. They refuse notes/ (use vmem_read), log (use vmem_log_tail), absolute paths, . or .. segments, dot paths such as .git, any symlinked component, denied paths, non-UTF-8 files and files over 2 MiB. Paging, maxBytes and offset match vmem_read; listings leave out dot, linked, special and denied entries. Neither tool takes a lock, touches the index or writes a file. vmem_report is vmem report over MCP: the same path rules, screening, receipt and default EXTRACT event (a log override sets another event, for example LINT or GRAPH), with write: mcp required; MCP events always carry the agent attribution.

The initialize result carries short instructions (under 400 bytes): the vault is durable, linked knowledge; search first with vmem_search, then read the best match with vmem_read by section; writes are validated and receipted, and because a failure after publication can leave changes behind, read the receipt and its issues before retrying. The receipt state field differs by tool: applied and changes for vault writes, changed for spec writes.

Host setup

vmem setup claude            # dry-run: lists the file and change per host, writes nothing
vmem setup all --apply       # claude codex kimi pi qwen jcode
vmem setup agy --apply       # run inside the workspace; writes ./.agents/mcp_config.json
vmem setup all --undo
vmem setup all --root ~/.local/share/vmem/releases/0.3.0 --apply   # register a versioned install root

Setup registers one server entry named vmem (<bun> --smol <package>/bin/vmem.js mcp) by editing the host's own MCP configuration: ~/.claude.json (mcpServers; $CLAUDE_CONFIG_DIR/.claude.json when set), $CODEX_HOME/config.toml ([mcp_servers.vmem]), $KIMI_CODE_HOME/mcp.json, $PI_CODING_AGENT_DIR/mcp.json, $QWEN_HOME/settings.json (all mcpServers), $JCODE_HOME/mcp.json (servers) and the workspace's .agents/mcp_config.json for Antigravity. Defaults are ~/.codex, ~/.kimi-code, ~/.pi/agent, ~/.qwen and ~/.jcode; overrides must be absolute. It does not call host CLIs and does not need a TTY. --root <absolute path> registers another vault-mem install root instead of the running one, for example a versioned <install>/releases/<version>/ that a newer release is installed beside; the root is resolved to its real path (a current link registers the release it names) and must hold the package's package.json (a plain file of at most 64 KiB) and bin/vmem.js. The report names the root and its version. The current registration changes only on --apply; running servers keep their release until the host session restarts. Run it while the host is closed: Claude Code in particular rewrites .claude.json while it runs and can overwrite a concurrent change.

Apply is idempotent and touches only the vmem entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior vmem value in a receipt, $VMEM_HOME/setup/<host>-<hash of the config path>.json, so separate host profiles (for example two CODEX_HOMEs) sharing one VMEM_HOME keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every [mcp_servers.vmem] and [mcp_servers.vmem.*] table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as refused. Every mode also handles the console plugin manifest (see "Console plugin"): apply writes it, undo removes it while it still points at this install.

vmem completion bash|zsh|fish prints a completion script generated from the same command table as --help.

vmem manifest --json prints {name, version, configVersion, mcp: {server, tools}, commands}: the package version, the config format it writes, the MCP tools and the CLI command names from the same table, for tools that would otherwise hard-code them. A read-only tool (its readOnlyHint annotation) is listed as {name, readOnly: true}, every other tool as its plain name.

Promotion from agent-mem

vmem promote-from-amem turns one durable agent-mem entry into a vault note and records agent-mem's own vault link back to it. Agent memory is never indexed or copied into a second store, and no agent-mem tool is added: candidates come from agent-mem's vaultCandidates selection and the link from its recordVaultLink.

vmem promote-from-amem personal                                  # candidates of the current project (--project KEY | --all-projects, --limit 1..200)
vmem promote-from-amem personal mem:<id> --type decision --domain research --topics storage   # preview: full note text + fingerprint
vmem promote-from-amem personal mem:<id> ... --confirm --fingerprint <sha>
vmem promote-from-amem personal mem:<id> --link-to existing-note  # link an existing note instead of creating one
  • src/amem/seam.ts is the only module that imports the @staix/agent-mem root (exact pin 0.23.0, at or above agent-mem's 0.13 floor for agent workflows). Reads, writes and other commands never load it; promotion and mining load it when they run, and vmem mcp loads it at startup. The project defaults to agent-mem's key for the current directory (--project overrides); AMEM_HOME selects the memory home as in agent-mem.
  • Listing and linking require agent-mem's generation profile; a shared-v1 memory home is refused. Candidate listing applies agent-mem's durable selection and policy (secrets, default PII and the vault config's piiPatterns) before ordering and --limit; previews screen the requested entry with the same policy.
  • The preview prefills the H1 and stem from the entry title, the body from its text, about[] as Relevant Notes hints, and provenance promoted_from: mem:<id> plus promoted_at. --stem --type --domain --topics --description --title and --body file|- override it; none of this needs a TTY. When a note with the same stem or title exists, the preview is refused with V08 and lists link-existing <path> choices; --link-to takes one. --edit opens $VISUAL/$EDITOR on the draft only when stdin is a terminal (the only TTY check); the edited text is previewed and confirmed with --body.
  • Confirm requires the preview fingerprint, then creates the note through applyWrite (V01-V10), appends one LEARN <project> mem:<id> → notes/<stem>.md <em-dash> promoted: <kind> event (--log-project sets the project field; it defaults to the last segment of the agent-mem project key) and records the vault link last.
  • If the final link fails, the note stays, the result reports LINK and link-only notes/<stem>.md, and the entry stays a candidate. A rerun finds the note through promoted_from and offers link-only, never a second note or a second LEARN event.
  • vmem_promote_from_amem is the same two steps over MCP and needs a vault with write: mcp.

Configured canonical tables add canonRowMatches: [{path,line,key,row}] to promotion results and deduplicated link-existing <path> choices. Keys come from the selected Markdown-table column: bold, wiki brackets and inline code are stripped; parentheses, comma and spaced-slash aliases are split; one-character keys are ignored. Matching uses whole-token Unicode boundaries, ignoring case only for Latin characters, including in mixed-script keys. Matches rank by key length, then configured table/row order, with at most five distinct rows and 200 characters of row text. Text output lists "canonical rows" beside possible duplicates. vmem promote --to <org> uses only the org target's own tables; personal-vault rows never appear in its preview.

Rows and canonTableWarnings are advisory, outside the fingerprint: changing canonTables leaves a preview valid, while draft and note edits still invalidate it. A table is a header row followed by a delimiter row whose cells each hold one or more hyphens with optional leading and trailing colons, as GFM allows, so |-|-|, |--|--| and | :-: | - | all open a table. A missing/unreadable/denied note produces canonTables: <path> not read (<cause>) plus recovery advice, and a note that reads but holds no recognizable table produces canonTables: <path> has no recognizable Markdown table with what a table needs, so an empty canonRowMatches names its cause instead of staying silent. With no canonTables key, JSON and text output retain their previous shape. Reads reuse the open target note index with its 2 MiB per-note limit, an 8 MiB aggregate byte budget and at most 16 attempts; parsing is bounded to 20000 rows, 40000 aliases and 256 KiB of key text. There are no model calls, embeddings, automatic row edits or canonical-row lint rules.

Console plugin

vmem setup <target> --apply also writes $AGENTHUB_HOME/console/plugins/vmem.json (default ~/.agenthub; directory 0700, file 0600) with the absolute path and sha256 of src/ui/plugin.ts. acon ui then shows a Vault area. The panel needs agent-console 0.1.0 or later, the first release with the host.ui panel kit and ui.button. An older acon still loads the plugin, but the panel then fails with a TypeError on host.ui; upgrade agent-console to fix it. Re-run setup after upgrading vmem: acon refuses a module whose hash changed.

  • Panels: Overview (vaults, maps, recent log), Search, Note (body, links, actions), Promote (agent-mem candidates), Lint (typed findings linked to notes) and Log (parsed rows).
  • Inbox: promotion candidates (group candidate), lint errors, a stale graph report and org-vault sync pending (group alert). Sync pending counts uncommitted paths plus local commits ahead of the upstream tracking ref; without an upstream it counts only uncommitted paths. It reads local Git state only (no fetch or credential prompt) and opens Overview with that vault, with no one-key sync verb. The palette searches notes.
  • Inbox snapshots share one background refresh, with one worker source at a time. A cold snapshot waits at most 700 ms and then shows a refreshing status row; refreshing: true also marks an active collection. Later snapshots keep serving the last complete rows while refreshing. A source failure retains its last rows; if every source is unavailable at startup, a separate unavailable status row opens Overview and advises checking System. Refreshes are cached for 60 seconds, including failed attempts. Applied writes invalidate freshness without clearing the rows.
  • The inbox returns at most 50 rows, errors and alerts first, then newest candidates, with titles capped at 160 Unicode code points and detail at 240. more counts omitted rows. Because agent-mem limits candidates per project and exposes no total-count API, vmem fetches 51 per project to detect overflow: when moreIsLowerBound: true, read more as "at least N more", not an exact total. That lower bound does not decrease when candidates are added to the same population, but may stay flat once a project reaches the fetch limit. A source failure retains its previous count too. Full candidate and lint details are available in their panels. Each source has a 30-second budget to initiate worker termination; shutdown may wait for an active native call, without blocking inbox responses.
  • Actions: delete, move, promote (to an org vault) and promote-from-amem reuse the two-step MCP tools. Each first answers a preview with the planned changes; the write happens only after the console's confirm dialog, and only while the fingerprint still matches.
  • The provider calls the vmem mcp handlers in process, so the console has exactly the MCP rights: writes need a vault with write: mcp, and log lines carry the agent attribution.
  • The module is exported as @staix/vault-mem/ui-plugin. Lint results are cached for 60 seconds per vault.

Lint and graph analysis

vmem lint <vault> checks L01/L02/L03/L09/L10/L11/L12 and L13 (lowercase ASCII kebab-case filenames). An L01 finding for a dead link adds at most one suggested existing target and its title score, dropped when no title scores above the minimum; the hint is reported and the link is never rewritten. Hints are computed for at most 1000 distinct dead link texts per run: a text past that bound is reported as hint not computed (cap 1000) with the count left out, never as a missing match. L15 is a warning, not a write error: a note with confidence: proven and an empty source (absent, empty string or empty list) is listed with what to add, and existing notes are never refused. --note notes/example.md limits note findings. Section names and SUMMARY errors=N warnings=N follow the Python reference. --write writes a screened lint report and LINT event; --dry-run previews without persistence. Findings may identify paths, link targets and malformed log prefixes. Note bodies are never included in analysis results.

vmem graph build <vault> [--dry-run] writes reports/vault-graph.json, a dated graph report, graph trend history and GRAPH events through applyWrite. Old dated reports are preserved. Trend history appends one row per build, including repeated builds on the same date. Output receipts expose partial report/log failure; these separate writes are not a single transaction. A - [[x]] (relation) line written by vmem link --relation keeps its relation as rel on the wiki_link edge (supports, contradicts, extends, applies-to, context-for); other links carry no rel. Edges stay undirected, so rel does not say which note supports which, and when a pair carries different relations the first in note path order is kept. vmem graph neighbors <vault> <stem> [--hops 1..2] [--limit 1..500] [--relation REL] reads a bounded neighborhood; --relation (MCP relation) follows only edges of that relation, wiki_link meaning untyped links, and an unknown relation is refused. Matching MCP tools are vmem_lint, vmem_graph_build, and vmem_graph_neighbors; MCP report writes require write=mcp.

Communities come from a deterministic, dependency-free Leiden (Traag, Waltman and van Eck 2019) optimizing modularity with resolution γ = 1.0: fast local moving with a node queue, refinement that merges only well-connected nodes inside each community (the greedy variant, so no random step), and aggregation on the refined partition initialized from the unrefined one, repeated while modularity improves. Nodes are processed in code-point id order and gain ties go to the lowest community id, so the same graph gives byte-identical output whatever the input order. Every community induces a connected subgraph. MOC (and, in the workspace graph, bu) nodes are excluded and get community: null; an isolated note is its own community. Community ids are numbered by size (descending), then by smallest member id; they are build-local, so cite a member, not the id. vault-graph.json records graph.partitioner: "leiden", resolution, the achieved modularity and the number of levels, and the report Overview prints the partitioner, resolution and modularity. Modularity has a resolution limit: communities smaller than a size that grows with the whole graph cannot be separated, so a very small cluster may merge into a neighbor. There is no resolution setting yet. Skill mining (vmem skills mine) and the workspace graph use the same function. bun scripts/leiden-live.ts <vault-id> prints counts only: modularity, community count, largest share, connectivity, clustering time and NMI against the Python reference's Leiden partition.

vmem graph build <vault> --workspace [--dry-run] (MCP: vmem_graph_build with workspace: true) builds the workspace graph: the vault graph exactly as above plus the work layer under the vault's work root (the parent of its registry; a vault without registry is refused). It writes only reports/workspace-graph.json through applyWrite (no dated report, no trend row) and one GRAPH event, and returns the work counters (work_docs, yaml_fail, template_skip, related_*, source_*, collisions, dup_edges) and edge counts per relation. It ports extract_work from build-graph.py --work-root rule for rule:

  • Walk. .md files under the work root, never following symlinks, pruning .git node_modules .venv .sync-conflicts temp .maru .pnpm-store .conductor .dotfiles .omx .omc .gstack .frontend-slides .claude .archive archive vault inbox sites dev shared _templates templates and the scratchpad memos/temp roots (workspace.config.yaml paths.scratchpad, scratchpad.memos_subdir, scratchpad.temp_subdir; default scratchpad/memos, scratchpad/temp). denyPaths are honored. Only a file whose first bytes open frontmatter (--- and whitespace) is read; the rest are probed and skipped.
  • Nodes. A work doc qualifies with document_type, a non-empty projects project vault_note topics attendees relatedMeetings relatedTasks, or related; frontmatter containing {{ is a template (template_skip) and unparseable frontmatter is yaml_fail. Ids are work:<relpath>; business-unit hubs bu:<slug> come from bu_id in bu-config.yaml files named by a doc's business_unit; a vault source: that names an existing work file outside the qualifying set becomes a type: file stub.
  • Edges. related (work to work, by unique stem or relative .md path, rel kept), fm_ref (work field wiki-link to a vault stem, field kept), wiki_link (work body to a vault stem), bu_member, source_ref (vault source: to a work node) and supersedes (supersedes/superseded_by, field kept). Edges to unknown nodes and self-loops are dropped; one edge per pair survives by priority wiki_link < fm_ref < bu_member < source_ref < related < supersedes, and a work-layer edge replaces a vault-layer edge on the same pair. Communities exclude moc and bu nodes.
  • Bounds. 50,000 probed files (each counted once, whether or not it is then read), 2 MiB per read work doc, 128 MiB read in total and 2,000,000 directory entries per walk; exceeding one fails explicitly. Every probed file is stamped, so a file that changes during the build (including a plain file that gains frontmatter) refuses the write. The output is within the 16 MiB report bound.

bun scripts/workspace-parity.ts <vault-id> runs the unchanged Python build-graph.py --work-root into a private temp directory and the in-memory vmem build on the same tree, and prints only counts: node-id, edge-pair, relation, attribute and counter differences (--python, --reference select the interpreter and script). Communities are not compared.

Analysis consumes note sources from the derived SQLite index, including invalid-frontmatter rows retained separately from searchable notes. Normal scans also retain note sources in analysis_notes, roughly doubling cached source storage outside the vault; the cache remains derived and can be rebuilt. Read-only analysis and dry runs clone any existing derived cache through a readonly connection, then refresh changed notes in memory. A missing cache is built in memory. They create no cache, locks, reports or events. Auxiliary log, registry and report inputs use bounded plain-file reads. Snapshot limits are 10,000 notes / 64 MiB source, 1,000 reports / 16 MiB per file and combined, and 32 MiB log; oversized inputs fail explicitly. Graph limits are 100,000 edges. MOCs are excluded from clustering; ties sort by node id. Graph extraction intentionally retains the reference's body-only legacy wiki-link semantics, distinct from the richer navigation resolver.

bun scripts/lint-parity.ts and bun scripts/graph-parity.ts compare the synthetic corpus against frozen Python reference oracles. Use --reference /absolute/path/to/lint.py or --reference /absolute/path/to/build-graph.py to run the unchanged Python tools directly (PYTHON selects an interpreter with PyYAML/networkx). graph-parity.ts compares node attributes and edge pairs only; workspace-parity.ts also compares edge relation, field and rel, but ignores rel on wiki_link edges, so the vmem-only link relation stays outside both comparisons. Keep those references until a separate retirement decision after two consecutive zero-diff runs. L02 additionally validates explicitly present domain values against the selected vault's effective domain set; this is a vmem-only check that the Python reference never performed. An undeclared domain can therefore add L02 findings in a live-corpus comparison as an intentional extension, not parser drift; the frozen legacy fixture does not trigger this additional check. Fixture parity does not establish private-corpus parity or authorize a live vault cutover. Private corpus evidence stays private; no note bodies belong in public fixtures.

Analysis uses YAML 1.1 scalar compatibility for Python parity, including implicit booleans, octal/sexagesimal numbers, floats and timestamps, while preserving quoted/block strings and merge keys. Explicit tags, complex/non-string mapping keys, recursive aliases and nesting beyond 64 levels are refused explicitly instead of reporting misleading findings. These bounded-parser refusals require manual resolution before parity can be established for an affected corpus.

Org promotion and sync

Each vault is its own git repository. There is no cross-vault link syntax: [[x]] always resolves inside its own vault, so an org note cannot link back into a personal vault. Nothing is scheduled and nothing pushes on its own.

vmem promote personal shared-method --to team                     # preview: full org text, dropped links, screen result, fingerprint
vmem promote personal shared-method --to team --confirm --fingerprint <sha>
vmem sync team                                                    # preview: remote check, branch, ahead/behind, paths to stage
vmem sync team --confirm --fingerprint <sha>

vmem promote copies one note from a personal vault into the local clone of a writable org vault (kind: org, write not none):

  • Transform. The last prose ## Topics section (never one inside a code fence) is replaced with the target topics, which must name MOCs that already exist in the org vault (source topics by default, --topics to override); a missing section is appended. Body wiki links that do not resolve in the org vault become plain text (their label, if any); wiki-valued frontmatter (project, supersedes, superseded_by, source_ref) pointing outside it is dropped. All of this is listed in droppedLinks. The org note gets promoted_from: "<personal>:notes/x.md" and promoted_at.
  • Screen (fail closed). It refuses: secrets; PII denied by the org vault's promotion cells (org defaults to deny, plus configured piiPatterns; a warn cell is reported, not refused); type: person or domain: people records; private correspondence (source/source_ref from inbox/, outlook:, telegram(, kakao); and internal meeting notes (source under meetings/). These are keyword heuristics; the org clone's git diff before vmem sync is the review surface. Body links that are dropped are also resolved in the personal vault: a link to a person/people, meeting or correspondence note refuses the promotion (reporting only the category and count), so their names never reach the org vault. A refusal or a preview writes nothing in either vault.
  • Confirm. It requires the current fingerprint. Then it creates the org note through applyWrite, records promoted_to: "<org>:notes/x.md" on the source, and appends SYNC <project> personal:notes/x.md → <org>:notes/x.md <em-dash> promoted: <type> to both vault logs (--log-project sets the project, default -). The note is also linted against the org vault (lintVault over the org snapshot with the note overlaid, scoped to that note): any L01/L02/L13 error refuses the preview, and the lint result is returned again after the write. Vault-wide warnings do not block. A second promotion of the same note needs --again, and an existing org note is never overwritten. If the source provenance write failed after the org note was created, a rerun detects the org note's matching promoted_from and offers a provenance-only preview and confirm.
  • MCP. vmem_promote does the same over MCP and needs write: mcp on both vaults.

vmem sync <vault> is the only command that commits or pushes:

  • Preview. It requires a configured remote that every effective origin fetch and push URL matches (remote.origin.pushurl included), after normalising scheme, credentials, host case and, for host remotes only, a trailing .git (local and file: paths keep their case and their .git suffix, because x and x.git are different repositories). Any url.*.insteadOf or pushInsteadOf rewrite is refused, because the real push destination cannot be verified. A mismatch fetches and pushes nothing; the push URLs are part of the preview fingerprint and are checked again just before pushing. It refuses a detached HEAD or an in-progress rebase or merge, then fetches and previews the branch, ahead/behind counts, and the changed paths it will stage: only notes/, reports/ and log. Anything else is listed under ignored and never staged. A changed file that matches the configured denyPaths or the default deny names refuses the whole sync (before hashing, staging, committing or pushing) and is reported only as a count; a denied vault root is never synced.
  • Confirm. With the current fingerprint it commits those paths, rebases onto origin/<branch> when behind, pushes, then re-fetches and reports success only when the remote head equals the local head. Conflicting history aborts the rebase and keeps the local commit (DIVERGED). A refused push (PUSH) or a failed fetch (FETCH, for example missing credentials) reports ok: false.
  • Git hardening. Git runs through agent-mem's hardened argv: hooks, fsmonitor, external diff, pager, credential helper and askpass are disabled, GIT_TERMINAL_PROMPT=0 is set, and repository-redirecting GIT_* variables are removed. So sync uses ssh keys or a deploy key and never prompts.
  • Not decided by the tool. Which org, visibility, remote and deploy key a vault uses are deployment decisions, and whether an agent may push a given org vault is the workspace's external-sending rule.

Skill mining

vmem skills mine <vault> [--days N] [--project KEY|--all-projects] [--vocab DIR] [--skills DIR ...] [--min-size N] [--min-novelty X] [--json] ranks repeated work patterns as new-skill candidates. It replaces the Python skill-mine pipeline and reads memory instead of raw session logs. vmem_skills_mine takes the same inputs over MCP.

  • Records (within --days, default 30; exact duplicate texts kept once). agent-mem curated entries of kind decision, finding, contract or fail (title and text), screened by agent-mem's standalone policy plus the vault config's piiPatterns. agent-mem capture rows with event UserPromptSubmit, observation, summary, Recap or Compact (the excerpt), opened read-only. Record excerpts are parsed by agent-mem's parseRecordLines: mined kinds are decision, fix, fail, contract or finding, with parsed anchors appended to the text as matching context. Excerpts with no parsed records, parser drops or an incomplete parse are dropped as whole rows and counted in JSON dropped.malformedRecord and the Markdown dropped line; they never enter mining as raw text. Title and tool events are skipped. Vault notes of every type except person, moc and reference (title, description and body), dated by frontmatter created/date, else the file mtime. All active agent-mem projects are read by default; --project narrows to one.
  • Matching and graph. The materialize.py rules: action and object vocabularies from --vocab DIR (actions.yaml, objects.yaml; the skill ships them), plus project keywords, acronyms, people and orgs from the vault's registry; every contained surface matches (ASCII case-insensitively, Hangul exactly), giving nodes action--<canon>, object--<canon> and entity--<kind>--<slug>. A person node id is a digest, never the name. Records and vocabulary nodes form one graph clustered by the same deterministic Leiden as vmem graph build.
  • Scoring. The analyze.py rules: a community with at least --min-size records (default 3) and novelty at least --min-novelty (default 0.3) is a candidate, labelled {top action}-{top object}, with up to 8 triggers. Cohesion is the community's edge density; novelty is 1 minus the highest Jaccard similarity against the SKILL.md name and description tokens under each --skills directory; rank is log2(size) × (unique days / day span) × max(0.1, cohesion) × max(0.1, novelty). The report keeps the god nodes, candidates, surprising connections and rejected sections and adds per-candidate counts by source (curated, capture, vault).
  • Privacy boundary. The command writes nothing: no vault file, lock, cache, report or log event, nothing under the vmem home and no content under the agent-mem home (it opens no store that does not exist). Reading a SQLite database may create an empty -wal file and a -shm WAL index (no row content) next to it in the agent-mem home, and nothing else. A record matching vmem's secret screen is dropped and counted. Returned samples from all three sources are masked the same way: registry people become [PERSON] (ASCII names case-insensitively, as matching does), emails [EMAIL], absolute paths [PATH] and matches of the vault config's piiPatterns [PII]. Over MCP a vault re-registered after server startup is refused. The JSON result and the markdown report are the only output, and the calling skill decides where to save it.
  • Bounds. At most 20,000 records, 64 KiB per record (larger records are dropped and counted) and 64 MiB in total; exceeding the record or total bound fails with an explicit error. Capture reads keep the newest 20,000 rows per machine. JSON dropped.truncatedCapture and the Markdown report count truncated machine shards; rows of any event beyond maxReadRows were not read. This count describes shards, not excluded rows by event. The pinned CaptureStore API cannot count Record rows outside the window; an event-filtered upstream API remains a follow-up, without raw database scans or a larger read bound.

Release readiness

bun run check includes strict types, import boundaries, tests, package dry-run and a clean tarball installation with CLI/MCP reads, non-TTY mutation and stale-preview refusal. Package files are limited to runtime source/bootstrap, manifest, license, README and changelog. Tag validation additionally runs npm package dry-run and tests the exact npm tarball later used by the gated OIDC job. No release tag or publication is created by this readiness change. The tarball install resolves the pinned first-party dependency from the configured registry, so the full check requires network access.

  • bun run parity:release checks same-snapshot frontmatter bytes, incremental notes/FTS/links against clean rebuild, and two consecutive synthetic Python-oracle lint/graph comparisons. External Python reference paths can be supplied with --lint-reference and --graph-reference; --output records aggregate evidence.
  • bun run release:verify enforces committed runtime/method/external-parity evidence freshness and the declared synthetic thresholds at the explicit release gate. Ordinary tests validate evidence shape and internal sample consistency; routine source edits and version bumps do not require fresh benchmark evidence until release validation.
  • bun run bench:release --output <file.json> measures a generated 5000-note corpus in the authorized Linux dgx02 container. It records actual MCP search round-trip timings (including metadata refresh), kernel RSS high-water, the derived index size after rebuild (indexBytes), raw samples and environment; it exits nonzero when a synthetic threshold fails. Synthetic write timings are diagnostic and do not establish representative private-vault acceptance.
  • Readiness evidence, release workflow and cutover checklist distinguish verified results from outstanding private/native/integration/deployment gates. Live cutover is covered by the Status paragraph at the top.

Core operating compatibility

  • Frontmatter accepts YAML double-quoted Unicode/control escapes, quoted keys and escaped line continuations while preserving unchanged source bytes. New non-ASCII values are emitted as literal characters. A count-only local private snapshot probe verified 717/717 parses and 0 round-trip differences; the private result contains no note names or bodies and is not included here.
  • Existing unchanged V01 schema debt (unknown keys, off-enum values and other retained field violations) warns on unrelated edits and moves. Added/changed violations and explicitly assigned invalid fields error; create remains strict. Secret/PII/path safety checks stay enforced, including bounded screening of decoded metadata scalars and key/value context, so quoted escapes cannot hide prohibited values. No extra metadata keys are adopted or automatically migrated.
  • link skips any requested side already linking the target in visible body content, including embeds and dated Connections sections. Topics and code references do not count. A missing reverse side is still added. A repeated request returns unchanged: true, no file changes and no event.
  • patch refuses any match beginning inside frontmatter with a hint to use vmem_set_frontmatter or frontmatter; patching body text retains exact-one-match and SHA checks.
  • source accepts one string or a nonempty list of up to 32 strings (512 UTF-8 bytes each). Each reference is a workspace-relative path, mem:<id> or an absolute URL. Explicit source assignments strip a leading work/; ordinary edits do not rewrite legacy values. CLI new --source accepts either a string or a JSON string array; MCP create and frontmatter updates accept arrays directly.
  • With registry configured, source existence uses the nearest ancestor containing workspace.config.yaml, falling back to the registry directory for standalone fixtures. Missing relative paths warn as V11 on writes and L14 in lint; no referenced file content is read. Denied source references are refused/reported generically before existence warnings. URLs and memory ids are not fetched.
  • MCP vmem_read supports section, offset, and maxBytes; section/page responses return raw body with frontmatter: null. CLI show uses --section, --offset, and --max-bytes. Responses retain the whole-note SHA; each payload stays bounded while sections beyond the first 64 KiB remain accessible from the derived index.
  • bun scripts/frontmatter-probe.ts --vault <vault> --output <private-counts.json> is a read-only bounded local diagnostic. It stores aggregate counters/hash only, rejects unstable reads, and writes its private result outside note directories. Real note bodies must never be copied into fixtures or public evidence.

Runtime changes intentionally make release:verify report stale historical release evidence until the separately authorized release-time refresh. This core fix does not rerun a benchmark or authorize live cutover.

Migration tidy

vmem tidy <vault> [--connections --escapes --enums --keys --sources --topics --secrets --log | --all] [--map <file>] [--json] [--confirm --fingerprint SHA] sanitizes debt from older, unvalidated writers. Existing vaults must run vmem tidy <vault> --all once after upgrading to the validating writer, previewing first and then confirming the returned fingerprint. vmem doctor reports pending migration planning counts and unparsable paths for writable vaults; it writes nothing. Doctor does not run the per-note writer validation, so its planning counts may include notes the actual tidy preview will skip. Tidy also handles later imports.

  • Two steps. Without --confirm it prints per-rule counts, a per-note change list (key: old -> new, dropped keys with their values, sections added) and reported items that were left unchanged; nothing is written. With --confirm the fingerprint must match: it covers the rules, the map file, and the before/after sha of every touched file (and of the log). Each note is then written through applyWrite (atomic per note, under the vault lock) and one LINT event is appended, for example tidy: escapes 1, enums 13, keys 78, ....
  • Never blocked by one note. A sanitized note that the writer would still refuse (for example an unknown key) is left unchanged, listed under skipped and reported, and the rest of the run proceeds. Counts and the LINT event cover only notes that are written.
  • No value leaks. Every value echoed into a change line or report is screened: secret-looking values, and PII values that the notes surface warns on or denies, print as [redacted].
  • Restart-safe. If a run is interrupted, every note is either fully old or fully new; a rerun previews only the rest. A second --all run previews zero changes.
  • Scope. Only flat notes/*.md and, for --log, the vault log are touched; .obsidian/, templates/ and self/ never are. Nothing is committed; commit the receipt paths yourself.
rule default disposition
--escapes values written with YAML escapes (\uXXXX, ...) or the old writer's trailing-backslash wraps are re-emitted as canonical single-line scalars with literal characters
--enums confidence: confirmed/verified -> proven, observed -> likely, possible/speculative/web_unverified -> experimental, a | b -> the lower one. Other confidence, status and type values are reported, not guessed
--keys date/record_date -> created (dropped when equal, reported when different). decided_date/decision_date/decided -> decided_at on type: decision. decided_at/decided_by are schema keys for decisions, and email/phone for type: person in personal vaults (org promotion still refuses them). related/relatedMeetings/github: resolvable wiki targets become ## Relevant Notes links (deduplicated), paths and URLs join source. origin, ideation_stage, keywords, priority, deadline, completedDate, taskSourceType, submission_status are dropped (values listed in the preview). Any other key is reported and kept
--sources strip work/, split joined paths into a list (only when every part is a workspace path; URLs and mem: ids are never split), report paths that do not resolve
--topics append ## Topics listing the frontmatter topics where no such heading exists; mismatched sections are reported
--connections fold ## Connections (...) headings into ## Relevant Notes
--secrets replace V06 secret matches with [redacted]: in the body the match itself, in frontmatter the whole field value (the key stays, so the YAML stays valid). A match outside a field value, such as a YAML comment, is reported for manual repair. The preview names note and line, never the value. PII in personal vaults is left alone
--log replace secret-looking substrings in existing log lines with [redacted], and PII values that the vault policy denies on the log surface (allow-list entries excepted), under the log lock (refused with V06 when denyPaths covers the log). A line that fails the lint L09 structure check loses a trailing seconds field when that makes it valid, and is otherwise kept verbatim behind # legacy: (readers skip # lines); no time, zone or TYPE is guessed. Other lines are kept byte for byte. Git history is not rewritten: rotate exposed credentials

--map <file> overrides defaults with strict JSON {"enums": {"confidence|status|type": {"from": "to"}}, "keys": {"key": "drop" | "keep" | {"rename": "key"}}} (at most 64 KiB and 1000 entries). For tidy, --log selects the log rule; tidy always writes its own LINT event.

Spec source registry

Register a local repository as a separate spec source:

vmem specs add project-specs /absolute/repository --write none --pii deny
vmem specs list --json
vmem doctor --json
vmem specs remove project-specs
vmem specs remove project-specs --confirm

The config specs array holds id, canonical git working-tree root path, write (mcp|cli|none, default none) and pii (allow|deny, default deny). IDs are unique across vaults and spec sources. A spec source does not need a notes/ directory. Registration and removal reuse the vault registry lock and atomic config write. Removal previews first, then --confirm unregisters only; repository files remain in place.

Config writes containing specs use at least format 2; vault domains requires format 3. A config without either keeps its existing serialization and version field. status lists spec entries under a separate specs key, and doctor checks that every registered path is still its canonical git working-tree root. An unavailable or changed root makes doctor exit 1 with a per-source issue. Existing vault entries remain valid; vaults list also reports each effective domain set.

The registry, read-only MCP tools and CLI commands, spec creation, frontmatter-only update and two-step deletion are implemented. resolveSpecWorktree checks the registered git common directory; discoverSpecs discovers nested Markdown specs in memory; SpecIndex reuses the vault scanner and BM25 with a separate spec- cache prefix per canonical worktree. Call scan() to refresh a SpecIndex; its snapshot() uses the last completed scan and search() admits only shared-cache rows matching that snapshot's paths and hashes. Call scan() again to see documents another index instance inserted or replaced. A failed scan invalidates the instance snapshot; the next read refreshes it or propagates the original failure. buildSpecGraph resolves typed links by frontmatter id, and specSlice returns bounded subtree/ancestor/neighbor slices (depth 0..3, at most 500 nodes). Discovery uses Git's tracked/untracked non-ignored Markdown list, reports skipped symlinks/missing/nonregular paths, accepts NFC/NFD and filesystem case aliases, and keeps the first duplicate id in NFC path order with a diagnostic. Root device/inode identity and per-file physical containment remain checked while accepting these spelling aliases. Unknown type/status strings remain readable. To exclude large or vendored untracked Markdown, add it to .gitignore; already-tracked ignored files must also be untracked with git rm --cached. Discovery/indexing is bounded to 10000 Markdown inputs, 2 MiB each and 64 MiB total; graphs allow 10000 inputs and 100000 unique edges. Graph results omit raw note sources. Library validation (validateSpecs), introduced-error comparison (introducedSpecErrors), computed freshness (specFreshness), tracked-file ownership (specsCoveringPath) and registry-free CI logic (validateSpecRepo) are implemented. The read tools are vmem_spec_get, vmem_spec_graph, vmem_spec_grep and vmem_spec_validate. vmem_spec_create / vmem spec create, vmem_spec_update / vmem spec update and vmem_spec_delete / vmem spec delete are implemented (#97). No spec command runs git commit, branch or push.

Read commands share one bounded schema table with their MCP tools. source (CLI --source) defaults to the first registered source; worktree (--worktree) defaults to its registered root and must be an absolute root with the same Git common directory. Reads are allowed with write: none and do not edit repository content. Derived search caches stay outside protected sources.

vmem spec get goal --source project-specs --body --max-bytes 16384 --json
vmem spec graph goal --direction subtree --depth 3 --limit 100 --json
vmem spec grep implementation --type module --tag storage --limit 20 --offset 0 --json
vmem spec grep --covers-path src/index.ts --json
vmem spec validate --source project-specs --require-fresh --json
vmem spec validate --repo /absolute/checkout --require-fresh --json

get returns frontmatter, resolved outgoing links, backlinks, children, path, SHA-256 and computed freshness. Body reads are optional (--body, or body paging/section options) and use byte offsets with bounded UTF-8 pages. graph accepts subtree, ancestors or neighbors and an optional edge kind. grep searches FTS terms by default or a literal substring with --literal; filters are type, status, tag, parent, depends-on and covers-path. Use -- before a positional id/query beginning with a hyphen, for example vmem spec grep --literal -- "- [ ]". Results carry a continuation nextOffset when more matching hits exist. Snapshot path/hash filtering occurs before search limit/offset, so another scanner's cache rows cannot shorten pages. Rerun a command to refresh its snapshot. Large responses refuse with guidance or mark bounded collections as truncated.

validate exits 1 on validation errors; stale scopes become errors only with --require-fresh. --repo validates a checkout without consulting a home/config file or creating a registry/cache, and cannot be combined with --source or --worktree. The four read tools are listed by vmem manifest --json as {name, readOnly: true}; hosts that approve from a frozen allow-list instead of the manifest need to add their names separately.

Spec validation and freshness library

validateSpecs(snapshot, { requireFresh?, limit?, env?, git? }) returns full error/warning counts and count/byte-bounded items, source diagnostics and computed freshness. Types are goal, architecture, module, submodule, with increasing parent/child ranks and skipped levels allowed; status is optional or draft|active|deprecated. A deprecated spec may name one replacement; dangling, self or non-deprecated replacement links error, while missing or already-deprecated replacements warn. Metadata link values accept plain ids or wiki links with labels/headings. List link fields and paths require lists; malformed scalars remain visible to validation. covers and tags require lists of nonempty strings; scalar, non-string or empty items error. Empty lists are allowed. Only valid covers lists contribute duplicate claims.

Validation candidates are any SPEC.md, plus Markdown with parsed id or type frontmatter. A bare SPEC.md reports missing required keys. If YAML cannot be parsed, only the filename asserts candidacy; ordinary horizontal-rule/title-only READMEs are ignored. Oversized/non-UTF8 non-candidates yield skipped-path diagnostics; candidates retain blocking file errors. A truncated oversized frontmatter header is ambiguous and fails closed; it is never assumed to be a non-candidate. Discovery strips inherited Git pathspec-mode variables. Git binding/discovery subprocesses run outside the cache's IMMEDIATE transaction, while descriptor/path stamp checks remain inside.

introducedSpecErrors(current, prospective, options) compares complete internal error identities before applying display limits. Owners are matched across both trees by ids that are unique in both (including file moves), with stable file paths as the fallback for missing/duplicate-id repairs. Old owners are matched one-to-one, so a new spec reusing a moved spec's path cannot inherit its errors. Error identity also includes the repository namespace, code, field and target. Existing errors remain reportable but do not block an unrelated change; new dangling targets, cycles and rank errors are returned. Results contain only introduced errors and are bounded samples; internal resource-budget failures throw instead of returning an empty success. Spec create, update and delete use this API before publishing the prospective change. Delete allows only the preview-acknowledged dangling links to the removed id; that exception is applied before result limits so unrelated introduced errors cannot be hidden.

specFreshness checks full commit stamps against a pinned HEAD, counts scoped source commits and reports staged/unstaged/untracked changes touching native Git pathspecs. paths must be a nonempty relative pathspec list with no parent traversal and matching tracked files. Freshness is never authored into files. Stale scopes are report-only by default; requireFresh makes them errors. A spec without a stamp has no computed freshness entry. git: false skips native repository checks while retaining metadata shape/relative-path/full-SHA-format checks, and cannot be combined with requireFresh.

specsCoveringPath matches a tracked relative filename using Git's pathspec engine. validateSpecRepo(repo, { requireFresh?, limit?, env? }) discovers and validates a checkout without reading or creating a registry, home or disk cache, returning { code, report }; code is 1 for validation errors or required stale scopes. CLI vmem spec validate --repo implements this registry-free mode.

The portable schema contract is test/fixtures/spec-graph/README.md, with independent small valid/error/warning trees and expected codes. Consumers can copy the fixtures without a runtime dependency. Validation/CI reports omit raw sources; display limits are 1..500 records per collection and 128 KiB total, with errors shown before warnings, with truncated when records or oversized optional identifiers are omitted. Fixing errors and rerunning reveals later items.

vmem_spec_create / vmem spec create <path> --id ID --type TYPE --title TEXT share the create schema in SPEC_COMMANDS:

  • The path is a literal filename relative to the selected worktree, ends in .md, is not Git-ignored, has no symlink component and does not exist. It must stay in that repository's discovery tree: nested repositories and submodule gitlink ancestors are refused; create the spec through that repository's own source instead. Missing parent directories are created only after validation; a failed write removes only directories it created that are still owned and empty. Create refuses before publication if a new Git-listed Markdown path would exceed the 10000-input discovery cap; ordinary Markdown and skipped tracked paths count too. Remove or ignore Markdown inputs (untrack tracked exclusions) before retrying. Non-empty parents are kept; expected retention after a refused create race is reported as a warning. Other owned-directory cleanup errors name the directory and the errno or ownership cause, with an access/ownership recovery step.
  • Repository, ignore and gitlink checks run once at each create stage: initial read, locked validation and final publication check. Each stage streams the index once, retaining only whether a mode-160000 gitlink matches the target's own ancestors by canonical NFC/lowercase key, including missing workdirs and mixed normalization. There is no spelling-variant expansion. The scan bounds actual index output to 128 MiB, 1000000 records, 64 KiB per record and 10 seconds. Exceeding a bound refuses with the index cause and asks you to inspect, reduce or repair the index before retrying. Filesystem checks still run while creating parents.
  • New file permissions are 0666 & ~umask; new parent directory permissions are 0777 & ~umask, matching editor-style creation. Updates retain the existing file mode. After publication, create syncs the immediate file parent, then the parent of each new directory, deepest first. A post-publication sync or cleanup failure returns changed: true, ok: false and "created with errors". Inspect the created file, its parent directories and the lock state under VMEM_HOME; preserve any replacement temporary file, and do not repeat the create.
  • Titles supplied to create or update must contain no line separators or control characters (C0/C1, including NEL and NUL). Rejected inputs leave the file unchanged; the frontmatter emitter is unchanged.
  • Required metadata is id, type and a single-line title. Status defaults to draft. Types are goal|architecture|module|submodule; statuses are draft|active|deprecated. Optional fields are parent, superseded-by, depends-on, references, implements, covers, tags, paths and verified-against. CLI list flags take JSON arrays. There is no body input.
  • Frontmatter uses this field order: id, type, title, status, parent, superseded-by, depends-on, references, implements, covers, tags, paths, verified-against. The body starts with # <title>, then empty H2 sections: goal has Scope, Decisions, Success and evidence; architecture has Boundaries, Decisions; module and submodule have Responsibility, Interfaces, Decisions. New files use LF and no BOM.
  • Creation uses the same write policy, raw and decoded metadata screens, worktree lock, introduced-error gate and actor attribution as update. Duplicate-id refusals name the new file even when it sorts before the existing spec. After setting the final mode and fsyncing the staged file, create publishes it atomically with a hard link and removes its temporary name. An intervening destination is preserved and refused with EEXIST. Other link failures name the cause and require checking permissions or using a filesystem with hard-link support; create never falls back to rename. Update publishes with rename.
vmem spec create design/module.md --id module-id --type module --title 'Module design' --parent root-id --tags '["new"]' --json

vmem_spec_update / vmem spec update <id> share the update schema in SPEC_COMMANDS:

  • set (--set JSON) accepts scalar title, type, status, parent, superseded-by and verified-against. Types are goal|architecture|module|submodule; statuses are draft|active|deprecated.
  • unset (--unset JSON) is a list of optional field names. id is immutable, and id, type and title cannot be unset. Unknown keys are refused.
  • add / remove (--add JSON, --remove JSON) map depends-on, references, implements, covers, tags or paths to string lists. Entries are compared literally; existing entries are not added twice. A field cannot be unset and edited together, and a value cannot be added and removed together.
  • Optional expectedSha (--expected-sha) takes the SHA-256 from spec get and refuses a stale read. The target is re-read under an exclusive per-canonical-worktree mutation lock under VMEM_HOME, outside every worktree. Registry/policy and spec-state changes during validation refuse rather than overwrite the new state. Update and delete ignore ordinary Markdown path-list churn in this drift comparison. Create compares the Markdown count and whether its target is listed, preserving the discovery-cap guard while allowing same-count unrelated renames; spec candidates and diagnostics still participate in every operation's drift check.
  • emitFrontmatter preserves body bytes, BOM, original line endings, existing field order and unchanged field syntax. A semantic no-op writes nothing and returns changed: false. A changed file is staged in a same-directory temporary file and atomically renamed; the result names the path, SHA-256, actor and any refusal rule.
  • The prospective tree is built in memory and checked with introducedSpecErrors: new errors refuse the write, while old unrelated errors do not block it. Refusals name the written path, with related paths/targets when needed.
  • write: mcp permits CLI and MCP; write: cli permits CLI only; write: none refuses both. The shared screenContent rules always refuse secrets and refuse email/phone under pii: deny. The whole written text and decoded frontmatter are screened. Results carry actor: agent for MCP or the usual agent environment markers, otherwise person; no vault or source log is appended. Body prose is edited with normal file tools. Delete uses the preview-confirm flow described below.
vmem spec update module-id --source project-specs --set '{"title":"Reviewed module"}' --unset '["status"]' --add '{"tags":["reviewed"]}' --json

vmem_spec_delete / vmem spec delete <id> use a two-step acknowledgement:

  • Without confirm, preview returns preview: true, the path, SHA-256, every backlink edge kind (body links included), children and a fingerprint. Arrays are sorted; the fingerprint binds the selected worktree, path, file SHA and the complete listed backlinks and children. Preview creates no cache, lock or file. The 224 KiB budget applies to preview-invariant acknowledgement fields, so a payload at the bound passes both preview and confirm. If it exceeds that budget, it refuses instead of omitting relations.
  • Confirm with confirm: true and the fingerprint (--confirm --fingerprint SHA in CLI). The preview is recomputed under the canonical-worktree mutation lock. A changed file, child or backlink list makes the fingerprint stale and refuses deletion; request and inspect a fresh preview before confirming again.
  • Only dangling frontmatter links to the removed id that the current preview acknowledged are permitted. Other newly introduced validation errors refuse. The caller repairs the listed links afterwards through spec update. Delete does not remove children or rewrite other specs.
  • The same source write policy and actor attribution apply to preview and confirm. Confirm rechecks the shared registry guard immediately before unlink, removes only the selected file and leaves an ordinary Git deletion; it does not stage, commit, branch, push or append a log. Post-unlink sync or cleanup errors retain changed: true, ok: false and the SHA, with "deleted with errors" and no refusal marker. After an applied delete error, inspect the deleted file's parent directory and the mutation lock state under VMEM_HOME; the file is already gone. A blocked delete, including a path refusal before any deletion, directs the caller to request and inspect a fresh preview before confirming again, never to supply an update expectedSha.
vmem spec delete module-id --source project-specs --json
vmem spec delete module-id --source project-specs --confirm --fingerprint <preview-fingerprint> --json

About

vmem: validated, Obsidian-compatible Markdown knowledge vaults for agents and people. Every write passes one rule set or nothing is written. Part of the ahub family.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages