npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vault-brain

v0.3.1

Published

A fast, local-first encrypted Markdown knowledge engine with controlled AI access, backlinks, history, attachments, templates, and portable YAML frontmatter.

Readme

Vault Brain

A least-exposure, .env-style personal data vault for the AI age.

Project direction: this MVP is evolving into a desktop-first, local-first, encrypted knowledge workspace under the motto “Faster to recall. Safer to trust.” See docs/PRODUCT.md, docs/ARCHITECTURE.md, and docs/ROADMAP.md.

Compatibility: Vault Brain is the product and CLI name. Existing vault storage, cryptographic, plugin-signature, sync, and writer-lock identifiers remain immutable so data created by earlier releases stays readable.

TL;DR (TR): Obsidian gibi kişisel bilgi yönetimi araçları verini (sağlık, kimlik, finans) düz metin olarak AI ajanlarına besliyor. Vault Brain, veriyi şifreli anahtar-değer çiftleri olarak saklar; AI önce sadece anahtar adlarını ve açıklamaları görür (değerleri değil), ihtiyaç duyduğu tek değeri talep eder, ve her erişim denetim kaydına (audit log) yazılır.

Why

Personal-knowledge-management tools are increasingly wired straight into AI agents. The common pattern — a graph of free-text notes, fully readable by whatever model touches it — means every query potentially exposes your whole vault, not just the fact you asked about.

This project borrows a habit developers already trust: .env files. We don't hand an agent a whole config file to "figure out" — we let it ask for one variable at a time. Vault Brain applies the same discipline to personal data.

Two modes — read this before you wire it into anything

This is the most important section in the README.

| | Mode 1 — vbrain get (Direct) | Mode 2 — vbrain mcp (AI-assisted) | | ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Who's involved | You, or a script. No LLM. | An AI agent (e.g. Claude) via MCP | | Exposure | Zero — value never enters any model's context | Not zero — content does pass through the calling model's context, both when you tell the agent something worth saving (store_note) and when it resolves a value back (resolve_key) — that's inherent to how a conversational agent has to work | | What the agent sees without decrypting anything | N/A | Key names + descriptions (list_keys / find_key), and note timestamps + tags (find_notes_in_range) | | Audit | Logged | Logged — writes and reads both, including denials | | Scoping | N/A | Required per-agent grants: which keys, which actions, for how long, how masked |

Mode 2 is a deliberate trade-off, not a zero-exposure claim: you get an agent that can capture and recall notes for you in natural language, in exchange for that agent seeing what you tell it in that moment. What it does not get is standing access to your whole vault — every other note stays encrypted and out of its context until it specifically asks for that one key, and every access (read or write) is logged. If you want a hard guarantee that something never touches any LLM, keep using Mode 1 for it.

Grants: narrowing Mode 2 to one agent, one slice, one answer

MCP access is fail-closed. A vault with no grant policy cannot start the MCP server. Add a grant first; from then on an agent gets only what a live grant gives it.

# A kitchen assistant that may read one key, in one category, for a week.
vbrain grant add claude-code \
  --scope "health:BLOOD_TYPE:discover,resolve:none" \
  --expires 7d --note "meal planning"

# The same agent may see that an IBAN exists and confirm the last four digits,
# but never read the whole thing.
vbrain grant add claude-code --scope "finance:IBAN:discover,resolve:partial"

# Anything under this grant waits for you before it is answered.
vbrain grant add research-bot --scope "*:*:discover:full" --confirm

vbrain grant list                  # every grant, active, expired or revoked
vbrain grant requests              # resolutions waiting on you
vbrain grant approve <id>...       # one or more; single-use, expires in five minutes
vbrain grant revoke <id>           # effective on the agent's very next call

A scope reads file:keys:actions[:redaction]. Keys are exact names, a PREFIX* glob, or *; actions are discover, resolve and store; redaction is none, partial (mask identifiers, keep a short tail so the agent can confirm a match) or full (return the value's shape — "an IBAN, 26 characters" — and none of its characters).

Choose redaction per key, not per file. partial recognises identifiers — an IBAN, a card or phone number, an email, a long opaque id — and keeps a short tail. Anything it does not recognise gets the same treatment: every character but the last few is replaced. That is the right default for an unknown secret and the wrong one for a name or a date, which come back as •••••••••••raca and answer nothing. Applied to one identifier it is exactly the intent; stretched across a file with health:*:...:partial it masks every ordinary value in that file, and the agent reports that it could not find answers the grant does permit. vbrain grant add prints a note when a scope does this.

# Readable where reading is the point, masked where it is not.
vbrain grant add claude-code \
  --scope "health:DOCTOR_NAME:discover,resolve" \
  --scope "health:BLOOD_TYPE:discover,resolve" \
  --scope "health:INSURANCE_POLICY:discover,resolve:partial"

Three properties are worth stating plainly:

  • The narrowest scope wins. When several scopes cover one key, the strictest redaction is applied, so adding a broad convenience grant can never quietly unmask something you already chose to hide.
  • An approval is single-use. One "yes" answers one resolution and is then spent; it never becomes a standing permission.
  • Redaction narrows exposure, it does not create a boundary. A masked value still crosses into the model's context as a masked value. The vault owner pins the grant identity with mcp --agent; it is never accepted from tool request data. Anything that must never reach a model belongs in Mode 1.

Grants live encrypted in grants.enc inside the vault, so the agent names, the scopes and the pending approvals are all unreadable without the passphrase.

Format

# @desc: Bir sonraki doktor kontrol tarihi
DOCTOR_NEXT_APPOINTMENT="2026-09-15"

# @desc: Kan grubu
BLOOD_TYPE="0 Rh+"
  • Files live as vault/<name>.kv.enc — AES-256-GCM encrypted, scrypt-derived key from your passphrase. The plaintext above never touches disk.
  • Key names and @desc comments are copied into encrypted schema.enc by vbrain index; values are never included. Never put sensitive information in a description — the tool can't enforce that for you.

Entering many keys at once

vbrain add <file> --from <path.kv> reads exactly the shape above and writes the whole file in one transaction. Adding keys one command at a time costs about 620 ms each, almost all of it fixed: process start, the module graph and the scrypt keyring unwrap, all paid again for every key. Measured, 250 keys took 158 seconds that way and 2.1 seconds through --from.

vbrain add health --from ./health-entries.kv
# Stored 250 entries in health.kv.enc (encrypted): 250 new, 0 replaced.
  • All or nothing. Every entry is validated before anything is written, so one bad key writes none of them rather than leaving the category half updated. A key repeated inside one file is refused too: "last one wins" is a rule you did not necessarily intend.
  • Existing keys are replaced in place; an entry with no @desc keeps the description already stored.
  • The audit chain records each key, not one line for the batch.
  • The source file is plain text. It holds your values unencrypted on disk until you delete it, and the command says so when it finishes.

Quickstart

The npm package is named vault-brain; installing it globally exposes the short, project-specific vbrain command:

npm install --global vault-brain
vbrain --version
vbrain --help

The package is published from CI with provenance: the registry carries a signed statement tying the tarball to this repository, the publishing workflow and the commit it was built from. npm view vault-brain dist shows the attestation. The desktop application is not in this package and is not released yet — see docs/ROADMAP.md.

For local development from this repository:

npm install
npm run build

export VBRAIN_PASSPHRASE="use-a-real-passphrase-here"

node dist/cli.js --vault ./vault/personal init
node dist/cli.js --vault ./vault/personal add health 'DOCTOR_NEXT_APPOINTMENT="2026-09-15"' --desc "Bir sonraki doktor kontrol tarihi"

# many at once, from a plain .kv file — see "Format" below for the shape
node dist/cli.js --vault ./vault/personal add health --from ./health-entries.kv

node dist/cli.js --vault ./vault/personal index

# Mode 1 — direct, zero-exposure
node dist/cli.js --vault ./vault/personal get health DOCTOR_NEXT_APPOINTMENT

# fast path — search the value-free schema
node dist/cli.js --vault ./vault/personal search "doktor"

# freeform journal entry (dev/testing — normally an agent calls store_note for you)
node dist/cli.js --vault ./vault/personal note health "Check-up yaptırdım, sonuçlar normal." --desc "Doktor ziyareti notu"

# browse notes by date after unlocking the encrypted catalog
node dist/cli.js --vault ./vault/personal timeline --category health

# Mode 2 — start the MCP server for an agent (Claude Code, etc.)
node dist/cli.js --vault ./vault/personal mcp --agent claude-code

A working demo vault (dummy data only) is checked in at vault/example/.

Exit codes

Scripts and CI chain on these, so they describe the result rather than the output format — --json and the human-readable form of the same command always exit the same way.

| Code | Meaning | | ---: | ------------------------------------------------------------------------------ | | 0 | The command did what it was asked to do. | | 1 | It failed: bad arguments, wrong passphrase, a damaged or busy vault. | | 2 | It ran, reported honestly, and did not do the work. |

Code 2 covers a failed audit verification, purge without --yes, a docs verify that found integrity errors, and a sync apply blocked by unresolved heads. Treat 2 as "stop the pipeline", not as success with a warning.

Unlocking, locking and the storage format

# Prove the passphrase, and optionally hand it to the OS credential store
node dist/cli.js --vault ./vault/personal unlock --remember

# Which store is in use, and what this vault currently holds
node dist/cli.js --vault ./vault/personal keychain-status

# Explicitly end it: the remembered passphrase is forgotten
node dist/cli.js --vault ./vault/personal lock

# Rewrite pre-versioning files in the current encrypted envelope
node dist/cli.js --vault ./vault/personal migrate

# Re-wrap the keyring under a new passphrase, at the current KDF cost
node dist/cli.js --vault ./vault/personal passphrase change

# Inspect public slot metadata; no passphrase is needed
node dist/cli.js --vault ./vault/personal keyring status

# Create an offline recovery kit outside the vault
node dist/cli.js --vault ./vault/personal keyring recovery create --out ../personal-recovery.json

# Restore a missing or damaged keyring and choose a new primary passphrase
node dist/cli.js --vault ./vault/personal keyring recovery restore --from ../personal-recovery.json

# Replace the vault's keys and re-encrypt every object under them
node dist/cli.js --vault ./vault/personal rekey

Encrypted files carry an explicit envelope version, cipher name and the exact scrypt parameters they were written with, and those header fields are authenticated together with the ciphertext — an attacker cannot edit the recorded cost factor to weaken the next derivation without the tag check failing. Files written before versioning existed still open, and vbrain migrate upgrades them in place. test/fixtures/ holds vaults written by earlier formats so a future change has to prove it can still read them.

  • vbrain passphrase change — change the vault passphrase. The keyring is re-wrapped at the current key-derivation cost, so this is also how a vault created under an older, cheaper setting raises its work factor. Nothing is re-encrypted, and it takes the same time on a 100,000-note vault as on an empty one. Add --allow-same-passphrase to raise the cost without changing the passphrase. It never consults the OS credential store for the current passphrase, so running it unattended requires both VBRAIN_PASSPHRASE (the current passphrase) and VBRAIN_NEW_PASSPHRASE (the replacement) to be set. If this vault has a passphrase remembered in the OS credential store, it is updated to the new one.
  • vbrain keyring status [--json] lists slot IDs, labels, creation times and scrypt costs without unlocking the vault. It never prints salts or wrapped ciphertext.
  • vbrain keyring recovery create --out <file> adds one recovery-labelled slot and writes its independently usable recovery kit outside the vault. The generated 256-bit code is shown once and must be stored separately from the kit. Neither the kit nor the keyring stores the code.
  • vbrain keyring recovery restore --from <file> opens the kit with its code, verifies the recovered keys against the selected vault, preserves the old keyring as a ciphertext backup and installs a freshly wrapped primary slot. vbrain keyring recovery remove --slot <id> detaches a live slot; it cannot invalidate offline copies of a kit. Rotate content keys before creating a replacement kit when a kit and its code may have leaked.

Key-material commands add paired pending and allowed/denied entries to the same authenticated audit chain as content access. Recovery codes, kit paths and key material are never included in those entries.

  • vbrain rekey replaces the vault's keys and re-encrypts every object under them. Use it when a passphrase has leaked: a passphrase change re-wraps the same keys, so the leaked passphrase would still open a copy of keyring.json taken before the change. It asks for a new passphrase, and --keep-passphrase skips that when the keys, not the passphrase, are what is suspect. It drops every keyring slot the passphrase you give it cannot open: a recovery slot, or a second person's slot, stops working the moment the re-key commits, so re-add it afterwards. Attachment identities, sync change IDs and the audit chain are deliberately unchanged, so attachments keep their content addresses, peers keep converging and vbrain audit verify keeps validating the whole chain.

Encrypted Markdown documents

The new document engine stores Markdown notes as individually encrypted objects with stable IDs and revisions. Its search/link index is encrypted too; titles, note bodies, tags and search tokens are not written to disk as plaintext.

# Create two linked notes
node dist/cli.js --vault ./vault/personal docs put Projects/Alpha \
  --title "Project Alpha" \
  --body "Launch plan owned by [[People/Ada]]. #project/active"

node dist/cli.js --vault ./vault/personal docs put People/Ada \
  --title "Ada" \
  --body "Works on [[Projects/Alpha]]. #person"

# Fast encrypted-index recall and knowledge links
node dist/cli.js --vault ./vault/personal docs search "launch tag:project/active"
node dist/cli.js --vault ./vault/personal docs backlinks Projects/Alpha
node dist/cli.js --vault ./vault/personal docs links Projects/Alpha

# Directly decrypt one note, or emit portable Markdown with frontmatter
node dist/cli.js --vault ./vault/personal docs get Projects/Alpha
node dist/cli.js --vault ./vault/personal docs get Projects/Alpha --with-frontmatter

Use docs list, docs import, docs rebuild-index, and docs remove for the remaining document lifecycle operations. These are direct CLI operations; no model sees their output unless you explicitly pipe it into one.

Search query language

docs search takes the following. Terms combine with AND unless separated by OR, and - negates whichever term it is attached to — including the filters.

| Query | Matches | | -------------------------------- | ---------------------------------------------------- | | launch plan | both words appear somewhere | | "launch plan" | that exact phrase | | launch* | a word starting with launch (a bare word is a plain substring, so apple also matches grapple; appl* does not) | | launch OR rollout | either | | launch AND rollout | both; the same as writing them side by side | | tag:project/active tag:proj* | tag, exactly or by prefix; a leading # is optional | | path:Projects/ file:Alpha | substring of the full path, or of the basename | | [status] | the note has a status property | | [status:done] [status:do*] | the property holds that value | | created:2026-01-15 | created on that UTC day | | created:2026-01-01..2026-06-30 | created in that inclusive range | | updated:>=2026-07-01 | modified on or after that day (<= also works) | | -tag:archive | excludes; likewise -[status:done], -"draft" |

OR and AND are operators only when written alone and in capitals, so a note about the word "or" is still searchable. An operator whose argument is unusable — an empty tag:, an unparseable date — falls back to plain text rather than failing the query, so a search box narrows as you type.

Revision recovery and encrypted attachments are built in:

node dist/cli.js --vault ./vault/personal docs history Projects/Alpha
node dist/cli.js --vault ./vault/personal docs restore Projects/Alpha 1
node dist/cli.js --vault ./vault/personal docs unresolved
node dist/cli.js --vault ./vault/personal docs rename Projects/Alpha Archive/Alpha

node dist/cli.js --vault ./vault/personal docs attach ./diagram.png --mime image/png
node dist/cli.js --vault ./vault/personal docs attachments
node dist/cli.js --vault ./vault/personal docs attachment-get <id> ./restored-diagram.png

Semantic recall is available as an explicit, on-device path and never changes ordinary full-text search. With an embedding model already available in a local Ollama instance:

node dist/cli.js --vault ./vault/personal docs semantic-search \
  "where did I write about fixing a car?" \
  --model nomic-embed-text \
  --url http://127.0.0.1:11434

The command unlocks the vault, sends at most 16,000 characters per note to the chosen local model, and keeps its revision-aware vector index only in process memory. --max-characters, --min-score, and --limit tune that boundary and the result set. Model URLs must be literal HTTP loopback addresses and redirects are refused; a remote model cannot be enabled accidentally. Locking clears the vectors, and successful semantic searches add a value-free audit event. The exported OllamaLocalModelAdapter also provides a non-streaming local generate() boundary for host integrations. Treat the local model process as trusted: it receives the bounded plaintext needed for the operation.

An existing Obsidian vault can be migrated as one checked operation. Markdown and frontmatter are preserved, ordinary files become content-addressed encrypted attachments, and JSON Canvas boards bind to the imported note/attachment IDs. Hidden directories such as .obsidian and .git are skipped by default.

node dist/cli.js --vault ./vault/personal docs import-obsidian ./MyObsidianVault \
  --report ./obsidian-integrity-report.json

The report records malformed notes/canvases, skipped symbolic links, ambiguous attachment names, unresolved wikilinks and missing local Markdown links. The report itself contains source paths and is plaintext, so store or remove it as you would any other migration log. The encrypted destination must be outside the source vault, preventing an import from recursively consuming its own output. Use --include-hidden only when hidden source content is intentional.

The same shape comes back out. vbrain export writes every note as Markdown with frontmatter, every canvas as JSON Canvas, and every attachment into an assets folder beside them — the shape import-obsidian reads, so a vault can be exported and re-imported without losing note identity.

node dist/cli.js --vault ./vault/personal export ./plain-copy \
  --report ./export-report.json

The export is plaintext: everything the vault encrypts is readable by anyone who can read that directory. It refuses a destination inside the vault, and refuses to merge into a directory that already holds files. Two things the vault permits that a filesystem does not — a note path such as Q3: results.md or aux.md, and two attachments sharing one filename — are renamed rather than dropped, and the report names every rename. The vault's audit chain records that an export happened and how much it carried, but not where it was written.

Backing up, and proving the backup works

A backup is one file. It carries every encrypted object, the journals and the vault's keyring.json, so it opens with the vault passphrase and needs nothing else — no key file, no earlier copy, nothing from this machine.

node dist/cli.js --vault ./vault/personal backup ./backups/personal-2026-09-05.vbrainbackup
node dist/cli.js restore ./backups/personal-2026-09-05.vbrainbackup ./restored --verify-only

The procedure:

  1. Take the backup. vbrain backup <archive> writes the archive and then reads it back through the restore path before reporting success. It refuses to overwrite an existing file, so each run names a new archive.
  2. Move it somewhere that survives losing this machine. A host that stores the archive sees its total size and the keyring header — slot labels, key derivation cost, creation dates — and nothing else. Not the number of notes, not their paths, not how many revisions any of them has: the file list is sealed with the contents. The passphrase is not in the archive, and no one holding only the archive can open it.
  3. Verify what you stored, where you stored it. vbrain restore <archive> <destination> --verify-only checks the seal over the header, the AEAD tag on every entry and every entry's hash, and writes nothing. Run it against the stored copy, not the one you just made: what you are testing is the storage.
  4. Restore into a new directory. vbrain restore <archive> <destination> stages the whole verified vault beside the destination and moves it into place only when every entry has checked out. It refuses a destination that already holds files, so a restore cannot replace a vault that may still be good.

What a backup is not: an answer to a forgotten passphrase. Both the vault and its backups are wrapped by the same passphrase, so losing it loses all of them together. That is what the recovery kit is for — vbrain keyring recovery create — and the kit and the backup should not live in the same place. A backup answers a lost or corrupted vault; the kit answers a lost passphrase.

A restored vault's audit chain ends where the backup was taken. Nothing that happened after it is in the archive, including the audit entry recording that the backup itself was made.

npm run recovery:drill exercises this path end to end against a disposable vault: it takes an archive, restores from it, catches up from a relay and verifies an owner-signed checkpoint.

Deleting, and bounding history

docs remove archives the outgoing revision before it unlinks a note, so a removed note is recoverable with docs restore — and its content is still in the vault. That is right for an editing mistake and wrong for "this should never have been written down".

# A preview: prints what would go, changes nothing, exits non-zero.
node dist/cli.js --vault ./vault/personal purge note "Health/Results.md"
node dist/cli.js --vault ./vault/personal purge note "Health/Results.md" --yes

# Bound how much history every object keeps, and apply it to what is already there.
node dist/cli.js --vault ./vault/personal retention set --keep-revisions 10 --keep-days 365
node dist/cli.js --vault ./vault/personal retention show

A purge removes the object and every archived revision of it, and records the id in the audit chain. It is not recoverable from that vault. It also does not reach backups taken earlier, this vault's own sync change log, a relay the changes were pushed to, or a device that already pulled them — and a purge does not propagate to other devices, which was a decision rather than an omission.

Read docs/DELETION.md before relying on any of this. It states each of those limits, and records why 1.x ships no deletion tombstone.

Retention defaults to unlimited: a new build must not silently discard history someone already has. Both cores honour the policy the vault carries, so the desktop application prunes to the same bound the command line set.

Performance gates

Saving one note no longer costs a pass over the vault. Phase 17 replaced the whole-index rewrite on the save path with an encrypted change log, in both the TypeScript library and the Rust desktop core. Incremental save acknowledgement is specified at < 20 ms p95, at 100,000 notes. Both cores now measure well inside it — 4.55 ms (TypeScript) and 5.98 ms (desktop) at that size — and a save costs the same there as it does at 1,000 notes. At 10,000 the TypeScript path went from 141.7 ms to 3.5 ms and the desktop core from 2,553 ms to 5.0 ms. See Phase 17 in docs/ROADMAP.md.

npm run benchmark          # 1,000 notes  — the everyday regression gate
npm run benchmark:10k      # 10,000 notes
npm run benchmark:100k     # 100,000 notes (slow: it writes 100k encrypted objects)
npm run benchmark:budgets  # strict: fails when a budget is missed
npm run benchmark:rust     # the same, for the Rust desktop core

Each builds a disposable encrypted corpus and enforces the p95 budgets from docs/PRODUCT.md at that size: unlock, quick-switch, full-text search, note open and backlinks. The gates are measured, not aspirational — a tier's numbers are only raised with a measurement and a reason, never to turn a red run green.

unlockAndIndexMs is timed until the index is actually usable, not until the constructor returns: new DocumentVault is lazy, so stopping the clock there measured an unlock that had not yet decrypted an index. It is reported as the p50/p95/max of five cold unlocks — each in its own process, because a second unlock in the same process measures a warm one — and the budget gates the median. A cold unlock reads and decrypts the whole index — 140 MB at the 100k tier, because the index carries every note body — so a single sample of it reports the runner as much as the vault: fourteen consecutive samples from CI ranged 1,087–2,074 ms around a 1.78 s median, and the one that crossed the 2 s budget failed a gate the other thirteen passed. Five samples on one development machine span about 30 ms (1,419–1,451 ms at the 100k tier), against that 1,000 ms spread.

Incremental save is always measured and always reported. It is gated on the median of 200 samples and on the worst of them (< 1 s), not on p95, and p95 is printed in every run — with a TAIL: line whenever it crosses the 20 ms product budget. A save is four durable file operations, and on a shared CI disk a small fraction of fsyncs stall for hundreds of milliseconds: across thirty measurements the median never left 2.2–4.9 ms while the worst sample ranged 4 ms to 493 ms, so p95 reported that run's stall rate rather than the save path and twice turned the job red without the path changing. More samples do not fix that. Any run that misses the gated numbers prints a BUDGET MISS line, and the success line says so rather than claiming a clean pass. npm run benchmark:budgets fails on a miss; npm run benchmark:rust does the same for the desktop core, whose save path is a different implementation of the same contract and which gates the same statistics. The performance-budgets CI job runs both, so neither core can pass a budget the other fails.

Building the larger tiers is what surfaced the work behind them. Three write and resolve paths scanned the whole vault (quadratic during a bulk import), and search re-normalized every note body inside the query loop, so a 10,000-note corpus took minutes to build and 80 ms to search. With reverse lookups in the index and search text normalized once per session, the same corpus builds in seconds and answers a full-text query in about 3 ms p95.

Native desktop workspace

The repository now includes a Tauri 2 desktop application. Its React webview has no direct filesystem or key access; fifty-one capability-scoped commands cross into the Rust core for unlock, lock, the whole note lifecycle, search, backlinks, value-minimized graph topology, typed property rows, templates and daily notes, encrypted canvases, content-addressed attachments and sandboxed plugins. The session key stays in Rust memory and is zeroized on lock.

# Browser-backed UI development (uses a safe in-memory demo vault)
npm run desktop:dev

# Full native application against encrypted vault files
npm run tauri:dev

# Produce native host bundles (MSI/NSIS, app/DMG, or deb)
npm run tauri:build

# Faster local release check without installer generation
npm run tauri:build -- --no-bundle

Application updates are deliberately manual. Open Updates, then separately approve the check, download, and final installation. The native controller owns the fixed GitHub Releases feed, supported bundle target, timeout, downloaded bytes, and mandatory Tauri signature verification; the webview never receives an update URL, signing key, or installer path. Installation first flushes note and canvas edits, aborts if either save fails, and locks and clears the vault before the verified package is applied.

Builds installed before the updater was added need one manual bootstrap install. The free release path does not provide Authenticode or Apple publisher identity, so Windows SmartScreen or macOS Gatekeeper may warn even though in-app updates remain cryptographically signed. Back up the updater private key offline: losing it can force another manual reinstall. Updates have no automatic rollback and remain optional, so an unavailable release channel never blocks offline work. Maintainers should follow the release runbook for signing-key provisioning, draft verification, the real cross-platform update drill, and publish.

The current workspace includes a lock screen, file tree, tab strip, CodeMirror Markdown editor, reading view, split pane, properties, outline, backlinks, encrypted search, quick switcher, command palette, keyboard shortcuts, theme editor, local knowledge graph, filterable property table, spatial canvas, attachment library and a manual updater. Dirty notes are persisted before navigation, before a tab closes and before lock. Canvas and note saves are also drained before updater installation, so neither debounce window nor an in-flight write can race the vault lock.

| Shortcut | Action | | ----------------------------------------- | ------------------------------------------------------------------------- | | Ctrl+O | Quick switcher — title, path or alias; alt+↵ opens it in the split pane | | Ctrl+K | Command palette | | Ctrl+Shift+F | Search the encrypted vault | | Ctrl+\ | Open the current note in the split pane | | Ctrl+W | Close the active tab (saving it first if dirty) | | Ctrl+D | Open (or first create) today's daily note | | Ctrl+N / Ctrl+S / Ctrl+E / Ctrl+L | New note · save · write/read · lock |

A note's whole life is available from the document toolbar and the command palette, not only from the CLI:

  • Move or retitle. A note keeps its ID, so its history and every link that resolved to it by ID survive the move. Wikilinks that spelled out the old title stay as written and become unresolved mentions, which the context panel can relink in one click.
  • Delete and restore. Deleting archives the live revision before unlinking the encrypted object, so the note is still recoverable from Restore a deleted note in the command palette.
  • History. Every revision is a separate encrypted object. The history dialog decrypts one at a time to read, and restoring writes the old content forward as a new revision rather than rewinding the counter, so nothing already archived is invalidated.
  • Templates and daily notes. Any note tagged template shows up in New note from a template, with the same {{title}}, {{path}}, {{date:YYYY-MM-DD}}, {{time:HH:mm}} and custom variables the CLI renders. Ctrl+D opens today's daily note, creating it only the first time.

The sidebar's Canvas and Files views round out the workspace without ever handing the webview a file path:

  • Canvas. A board of freeform text cards, group frames, web links, and file nodes that point at a vault note or an encrypted attachment by id. Nodes are dragged on a fixed surface, connected with directed edges, and written back as one encrypted canvas document under the same stale-revision check that guards notes — a canvas edited elsewhere is rejected rather than overwritten. Edits autosave 900 ms after the last change, and a node whose target has since been deleted renders as a missing reference rather than disappearing.
  • Files. The attachment library adds files by encrypting their bytes in the Rust core, and decrypts one only when it is previewed or downloaded. Images, audio, video, PDFs and text preview inline from an object URL that is revoked as soon as the preview closes.

Plugins, without Obsidian's trust model

An Obsidian plugin runs as arbitrary Node inside the app: it can read your whole filesystem, and no amount of encryption at rest survives that. Here a plugin is a manifest and one JavaScript file, and its reach is finite and declared.

{
  "manifestVersion": 1,
  "id": "word-count",
  "name": "Word count",
  "version": "0.2.0",
  "description": "Shows how many words the open note holds",
  "author": "example",
  "capabilities": ["notes:read", "ui:panel", "commands"]
}
vbrain plugins install ./plugin.json ./main.js   # installs, but leaves it off
vbrain plugins list                              # what is installed and what it may reach
vbrain plugins enable word-count
vbrain plugins remove word-count                 # takes the plugin's settings with it

Publishers can bind the manifest and exact source bytes into one Ed25519 package. The public key travels in the signature; the private key never enters the vault:

vbrain plugins keygen ./publisher.private.pem
vbrain plugins sign ./plugin.json ./main.js \
  --key ./publisher.private.pem --out ./plugin.signed.json
vbrain plugins install ./plugin.signed.json ./main.js

# Signed-only operation and local emergency revocation
vbrain plugins restricted on
vbrain plugins revoke-signer word-count
vbrain plugins policy
vbrain plugins restore-signer <64-character-key-id>

Restricted mode refuses unsigned installation and prevents already-installed unsigned packages from running. Revocation is keyed by the SHA-256 fingerprint of the publisher's Ed25519 public key, so every installed package from that key stops on the next plugin refresh; the encrypted policy travels with the vault. The first accepted signing key is pinned to that plugin ID, and an update from a different key is refused until the old plugin is explicitly removed. Signatures prove that the source and manifest have not changed since the holder of that key signed them. They do not prove who owns a self-declared key, so the first trust decision still belongs to the person installing the plugin.

The capabilities are notes:metadata, notes:read, notes:write, search, canvas:read, canvas:write, attachments:read, commands, ui:notice, ui:panel and storage. There is deliberately no network capability: it is not offered and cannot be requested. A manifest naming a capability this build does not know is refused, not trimmed — installing it would mean showing you a list that understates its reach.

Three things make that list mean something:

  • It runs in a worker with no DOM, no filesystem and no network. fetch, XMLHttpRequest, WebSocket and importScripts are removed before the plugin's first line runs, and the plugin is loaded as a worker script rather than eval'd — so the app never has to grant 'unsafe-eval' to anything.
  • Every call is checked against the manifest. A method that isn't in the capability table has no capability and is refused, so a host method added without one is unreachable rather than accidentally public.
  • The Rust command layer is the actual boundary, and it does not trust the worker or the host. A plugin that escaped the sandbox entirely would still hold only the capabilities the webview itself has — and the vault key never enters the webview at all.

What this does not do is protect you from a plugin abusing what you granted. notes:read means it reads your notes. The defence there is the capability list you approve before it runs, and the switch that turns it off.

Plugins and the restricted/revocation policy live encrypted in the vault like everything else, so they travel with it, and one cannot be swapped on disk without the passphrase. Both the TypeScript CLI core and the Rust desktop core verify the same length-prefixed signature payload before a signed package is accepted or loaded.

Two session guards protect an unlocked window you walked away from:

  • Inactivity lock. The vault re-locks after a configurable idle window — 1, 5 (default) or 15 minutes, or disabled — set from the sidebar's Auto-lock control. Keystrokes, pointer input and scrolling reset it; the lock screen then says why it locked.
  • Self-clearing clipboard. Copying a note or a single property value marks the clipboard as workspace-owned and rewrites it to empty 30 seconds later, and again on lock — but only if the clipboard still holds exactly what was copied, so it never wipes something you copied elsewhere in the meantime. If the webview denies clipboard read access, the copy still works and the workspace tells you it cannot take the value back.

The file tree and the property view are windowed: they keep a viewport's worth of rows in the document no matter how many notes the vault holds, so a 40,000-row folder costs the same to render as a 40-row one. (The interface is bounded; the encrypted core's own 10k/100k budgets are still only measured against the 1,000-note benchmark corpus.)

The theme editor — sidebar palette icon, or Customize theme in the command palette — derives the whole interface from four colours (chrome, surface, ink, accent) plus a reading size and typeface, with four presets to start from. It shows the WCAG contrast ratio for ink-on-surface and accent-on-chrome as you edit and flags a combination that drops below AA. The theme is a per-device preference stored in localStorage; it never enters the encrypted vault.

Obsidian-style YAML frontmatter, safe templates and daily notes are supported:

# Mark any encrypted note as a template and use familiar variables such as
# {{title}}, {{date:YYYY-MM-DD}}, {{time:HH:mm}} and custom {{name}} values.
node dist/cli.js --vault ./vault/personal docs from-template \
  Templates/Meeting Meetings/Kickoff \
  --title "Kickoff" --date 2026-08-30 --var client=Acme

# Idempotent: the first call creates the note; later calls open the same note.
node dist/cli.js --vault ./vault/personal docs daily 2026-08-30 \
  --folder Journal --template Templates/Daily

Imported Markdown accepts nested YAML properties, block scalars, tag/alias lists and common scalar values. YAML aliases are deliberately disabled and duplicate or prototype-shaping keys are rejected before anything enters encrypted storage.

Wiring into Claude Code / an MCP client

{
  "mcpServers": {
    "vault-brain": {
      "command": "node",
      "args": [
        "/absolute/path/to/secondbrain-vault/dist/cli.js",
        "--vault",
        "/absolute/path/to/vault/personal",
        "mcp",
        "--agent",
        "claude-code"
      ],
      "env": {
        "VBRAIN_PASSPHRASE": "use-a-real-passphrase-here"
      }
    }
  }
}

Once connected, you don't type CLI commands yourself — you just talk. Tell the agent "bugün doktora gittim, tahliller normaldi, 6 ay sonra tekrar kontrol" and it calls store_note on your behalf. Ask "bir sonraki doktor kontrolüm ne zaman?" and it calls find_key → resolve_key. The CLI's own add/note/get commands still exist, but they're there for scripting and testing — not the intended everyday interface.

The server exposes six tools:

| Tool | Touches values? | Gets audited? | | --------------------- | ------------------------- | ------------- | | list_keys | No | No | | find_key | No | No | | find_notes_in_range | No — timestamps/tags only | No | | store_note | Yes — that's the point | Yes | | resolve_key | Yes | Yes | | resolve_keys | Yes — up to 20 named keys | Yes, one line per key |

Under a grant policy the three value-free tools return only the keys that agent may discover, store_note needs a store action, and resolve_key additionally records who asked, under which grant, how much came back, and whether the answer was allowed, denied, held for approval, asked for a key that does not exist, or failed with an error. An error line is value-free like the others: it names the file and key asked for, never the error message. A category named in the wrong case (HEALTH for a stored health) that the grant covers under its stored spelling is denied with that spelling, rather than with advice to widen the grant.

resolve_keys is resolve_key for several keys at once: the agent names each {file, key} pair, up to twenty, and gets them back in one answer instead of one tool call each. It adds no reach. Every key goes through the same grant decision, approval hold and mask as it would alone, and the audit chain records each key, not one line for the batch. A list that is empty, too long, or names a key twice is refused before anything is decrypted. Each entry in the answer opens with a marker drawn fresh for that response, so a stored value cannot pass itself off as a different key's entry.

The three value-free tools answer with one entry per line, each naming its own file so the next resolve_key call cannot take a key from the wrong one:

health/DOCTOR_NAME — family doctor
health/BLOOD_TYPE — blood type
health/NOTE_20260920_212739_0e78eb198dd6 — 2026-09-20T21:27:39.000Z — checkup summary
work/CURRENT_PLAN — updated 2026-09-26 — this quarter's plan

A journal note carries the time its key encodes. A fact carries the day it was last written, so an agent can tell a plan rewritten yesterday from one left untouched for a month. Neither the .kv format nor the catalog stores a timestamp, so that day comes from the audit trail: the latest signed CLI or MCP write of the key, read only when the whole chain verifies. A fact never written through the CLI or MCP on this machine, including one that arrived only by sync, shows no date.

Discovery is the largest thing this server puts into a context — it lists every key the agent may see, on every conversation that browses the vault — so the format is chosen for what it costs a model to read. It used to be pretty-printed JSON, which spent about a third of its characters on indentation and quoting; on a 250-key vault that made listing one key cost more than handing over that key, its description and its value would have, which inverts the reason an agent discovers before it resolves.

store_note covers both shapes: pass an explicit key for a fact that should overwrite itself (like IBAN), or omit it for a freeform journal note — it gets an auto-generated, timestamp-prefixed key (NOTE_20260830_...) so entries stack up instead of colliding. find_notes_in_range then lets an agent browse "what did I note about health in August" using only that timestamp, with zero decryption — the note-taking equivalent of Obsidian's daily-notes view, but without reading your notes to build it.

The key is decided before permission is checked, so a journal note needs a grant whose scope actually covers the generated name — NOTE_* or *. A grant scoped to one exact key (health:IBAN:store) permits writes to that key and nothing else, including auto-named notes.

What MCP does not reach: this surface is key-value only

These five tools are the whole AI boundary, and they all operate on the key-value store — the *.kv.enc categories and the value-free catalog built from them. Markdown documents are deliberately not reachable over MCP in this release. Notes created with vbrain docs put, or brought in with the Obsidian importer, have no discovery or resolve tool: an agent cannot list them, search them, or read a field out of one.

This is a scope boundary, not an oversight, and it is narrower than the broader "per-agent grants scoped by vault, collection, note, field, action and expiry" described in docs/PRODUCT.md. The grant model implemented here is file/key/action/expiry over key-value entries; note- and field-level scoping for Markdown is not built, and adding discovery tools without it would mean handing an agent a boundary the policy language cannot express. Use Mode 1 (vbrain docs ...) for Markdown, and reach for MCP when a value belongs in the key-value store.

Encrypted sync protocol — Phase 6 foundation

Sync remains transport-independent: an append-only encrypted change log with per-device chains, causal parents and explicit conflict inspection. Exported JSON contains only keyed opaque IDs and AES-GCM ciphertext. Enrolled devices sign every new change with a separate Ed25519 key; the owner-signed encrypted registry pins device certificates and revocation sequence cutoffs. Owner-signed freshness checkpoints commit to the verified causal heads and change count. An authenticated self-hosted relay stores only opaque immutable ciphertext, with request, storage and object-count quotas.

Pass --sync-device <uuid> to a document or plugin command to capture its note, canvas, attachment, plugin-package or plugin-policy mutation automatically. A synchronized session also records an encrypted per-object application cursor, so conflict-free remote changes can be replayed into the real vault exactly once.

# Initialize enrollment and pin the returned authority fingerprint.
vbrain --experimental-trusted-sync sync devices init "Owner laptop" \
  --device-id <device-id>

# On a securely bootstrapped second device, create a proof-of-possession request.
vbrain --experimental-trusted-sync sync devices request "Travel laptop" \
  --device-id <second-device-id> > enrollment-request.json

# The owner approves it, then transfers the newer encrypted registry back.
vbrain --experimental-trusted-sync sync devices enroll enrollment-request.json
vbrain --experimental-trusted-sync sync devices export > device-registry.json
vbrain --experimental-trusted-sync sync devices import device-registry.json

# Removal is owner-signed at the last device sequence observed locally.
vbrain --experimental-trusted-sync sync devices revoke <second-device-id>

# Record revision 1 of one logical object.
vbrain --experimental-trusted-sync sync append <device-id> note <note-id> put \
  --revision 1 --value '{"title":"Plan","body":"private"}'

# Exchange opaque envelopes through files/stdout.
vbrain --experimental-trusted-sync sync export > changes.json
vbrain --experimental-trusted-sync sync import changes.json

# Validate the complete DAG, list/inspect concurrent heads, then apply a clean one.
vbrain --experimental-trusted-sync sync verify
vbrain --experimental-trusted-sync sync conflicts
vbrain --experimental-trusted-sync sync resolve note <note-id>
vbrain --experimental-trusted-sync sync apply note <note-id>

# Resolve manually by naming one preserved head. Plugin-policy conflicts can
# instead use --safe, which only unions revocations and enables restricted mode.
vbrain --experimental-trusted-sync --sync-device <device-id> \
  sync resolve note <note-id> --head <change-id>
vbrain --experimental-trusted-sync --sync-device <device-id> \
  sync resolve vault plugin-policy --safe

# Example automatically captured edit.
vbrain --experimental-trusted-sync --sync-device <device-id> \
  docs put Plans/Launch.md --body "Ready"

# Publish a known-good checkpoint and push it with the encrypted change log.
export VBRAIN_RELAY_TOKEN='<at-least-32-random-bytes>'
vbrain --experimental-trusted-sync sync checkpoint create
vbrain --experimental-trusted-sync sync relay push https://relay.example

# First contact pins fingerprints obtained through a trusted channel.
vbrain --experimental-trusted-sync --vault ./restored-vault \
  sync relay pull https://relay.example \
  --authority <owner-authority-sha256> \
  --checkpoint <owner-checkpoint-sha256>

Unresolved heads fail before live storage is touched. Plugin package bytes and the fail-closed plugin policy are portable, while plugin storage and the local enabled/disabled execution choice never enter the sync log; a received plugin is always installed disabled. Attachment bytes travel outside the change envelope: a change carries a manifest of content-addressed, AEAD-sealed 1 MiB blobs, so an attachment of any size the vault accepts synchronizes and an interrupted transfer resumes one chunk at a time. Independently witnessed freshness remains Phase 6 work. The relay is not key escrow: first-device recovery needs an encrypted vault backup containing the original key-derivation metadata. A normal device clone must never carry another device's documents/sync/identity directory; private identity keys are local-only and absent from sync exports.

Revoking a device rotates the content key: the registry advances to a new epoch, a fresh random content key is wrapped to each remaining device's X25519 key, and the revoked device receives no wrap. Rotation is forward-only. A revoked device keeps the epoch keys it already held and can still decrypt every change written before the rotation; what it loses is everything written afterwards, including whatever the relay accumulates from then on. Recovering historical plaintext still only takes the passphrase and an encrypted vault backup — the passphrase remains the security boundary.

See the sync protocol design and relay operations guide.

The on-disk format is frozen at 1.0 — see docs/FORMAT-1.0.md. The product itself remains pre-1.0 pending independent review.

The "fast find" layer

vbrain index rebuilds encrypted schema.enc — key names + descriptions, no values — across the whole vault. vbrain search / the find_key MCP tool run a fuzzy match over that small, safe file instead of decrypting and scanning every vault file. This is the speed win over an Obsidian graph traversal: lookup cost scales with the number of keys, not the size of your notes.

Threat model / honesty notes

  • Encryption protects data at rest. Once vbrain get or resolve_key decrypts a value, it's plaintext in that process's memory / stdout — treat it like any other secret in a terminal.
  • The passphrase prompt is masked on a real terminal, and vbrain unlock --remember can hand the passphrase to Windows DPAPI, macOS Keychain, or libsecret on Linux. The macOS backend sends a base64 representation through security -i on stdin under a versioned service name, so the passphrase never appears in the child process argument vector; lookup remains compatible with older records. CI exercises a full store/lookup/delete cycle on every host OS. An OS credential store protects the secret from other users and other machines — not from code already running as you.
  • VBRAIN_PASSPHRASE still wins over both, which is what makes scripts and MCP work; an environment variable is visible to your own processes, so prefer the credential store for interactive use.
  • Nothing here stops a user (or a misconfigured agent with general filesystem tools) from reading vault/*.kv.enc directly — they'll just get ciphertext, which is the actual protection. The real enforcement point is keeping an agent's filesystem access scoped away from the vault directory and routed through the MCP tools instead.
  • This is an MVP. It has not been audited. Don't put real medical or financial identifiers in it yet — validate the model with dummy data first.
  • Rotation is forward-only. A revoked device keeps the epoch keys it already held and can still decrypt every change written before the rotation; what it loses is everything written afterwards, including whatever the relay accumulates from then on. Recovering historical plaintext still only takes the passphrase and an encrypted vault backup — the passphrase remains the security boundary.

Roadmap

Phases 0–5 are implemented. Phase 6 now has the encrypted immutable change log, live note/canvas/attachment/plugin capture and application, owner-signed device enrollment and removal with automatic epoch content-key rotation on revocation, owner-signed freshness checkpoints, an authenticated opaque self-hosted relay, an automated recovery drill, a frozen 1.0 on-disk format with committed conformance fixtures, a native-packaged desktop sync helper with manual pairing, revocation, relay push/pull and conflict resolution, and resumable content-addressed transport for attachment blobs of any size the vault accepts. The independent security audit remains open — see docs/AUDIT-SCOPE.md for the audit readiness package. Sync is desktop-to-desktop: Vault Brain stays local-first with optional self-hosted sync, so the passphrase never leaves a machine the owner controls and no hosted service is ever required.

Phase 7 replaced the passphrase-derived content key with a wrapped keyring, so vbrain passphrase change re-wraps the keyset without re-encrypting a single object, both cores read the new format, and the key-derivation cost can be raised per vault. It also ships resumable re-key, recovery kits, controlled lock recovery and the explicit vbrain rekey --rotate-identities migration. Desktop passphrase and key-management controls are implemented in the current desktop slice. The maintained checklist keeps the remaining external release, platform and audit evidence open in docs/ROADMAP.md.

Personal-memory modules under src/memory/ are experimental and disabled by default. No production import path enables them. Any future integration must use the separately reviewed Phase 15 plan: native broker, explicit pairing, an unlocked session, bounded secure queue, and owner-controlled review/forget. It must preserve the new owner epoch and require re-enrollment after identity rotation. No live MCP, desktop or account integration is part of the current release.

License

MIT