Generated banner for Open Second Brain
Generated logo for Open Second Brain

Open Second Brain

Memory server

First committed by hermes-vps-agent

Published in itechmeat/open-second-brain. Publisher page

550 GitHub stars

What it is

Second brain for AI agents using Obsidian-compatible Markdown vaults. Works with Hermes, Claude Code, Codex, and OpenClaw.
— the description by hermes-vps-agent

AutoGineer overview, from the brain’s own files

Open Second Brain acts as a memory provider for AI agents, enabling them to read and write persistent knowledge directly into an Obsidian-compatible Markdown vault. It employs a deterministic 'dream' pass to convert repeated signals into actionable rules, while maintaining a hash-chained log for auditability. The system supports various agent clients through MCP servers, lifecycle hooks, and a dedicated CLI, ensuring that agent-learned information remains accessible, versioned, and searchable as standard Markdown files.

How it works

Memory model: Hybrid

AutoGineer overview, from the brain’s own files

The system organizes data into a Brain folder containing Markdown files. Agents interact with this vault via MCP servers, lifecycle hooks, or the o2b CLI. Signals from agents are stored in an inbox, processed through a dream pass to generate preferences, and stored as rules in active.md. A search index is maintained in a hidden directory and can be rebuilt from the source files. The system also supports truth ledgers, decision models, and staged review gates for agent writes.

Folders
FolderRoleFiles
.githubagent-config7
binscripts1
docsdocs774
hookshooks35
scriptsscripts22
skillsagent-instructions6
srcsource-code1496
templatestemplates23
teststests2101
.agentsfolder1
.ai-notesfolder1
.apbfolder87
.claude-pluginfolder2
.codex-pluginfolder1
.githooksfolder2
installfolder16
openclawfolder1
openspecfolder33
pluginsfolder21
schemasfolder5

The agent reads

The agent writes

Requirements

Runtimes: Node.js, Python >=3.11, Bun

Databases: SQLite

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. This brain runs code automatically: 54 hooks (codex), 3 scripts. Review .githooks/pre-commit, .githooks/pre-push, hooks/hooks.json, package.json, plugins/codex/hooks/hooks.json before opening the folder in Claude Code. No step below turns a hook on for you.

    • .githooks/pre-commit: runs on git pre-commit
    • .githooks/pre-push: runs on git pre-push
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" post-write-reminder; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook post-write-reminder; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-agenda; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-agenda; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" backlog-nudge; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook backlog-nudge; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" log-catchup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook log-catchup; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" view-render; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook view-render; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" recall-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook recall-inject; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" nav-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook nav-inject; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" counterparty-brief; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook counterparty-brief; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-orient; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-orient; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-host-backup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-host-backup; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" stop-log-guardrail; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook stop-log-guardrail; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" answer-quote-check; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook answer-quote-check; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" hygiene-digest; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook hygiene-digest; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-promote; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-promote; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
    • hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • package.json: bun scripts/prepare.ts || exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" post-write-reminder; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook post-write-reminder; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-agenda; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-agenda; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" backlog-nudge; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook backlog-nudge; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" log-catchup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook log-catchup; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" view-render; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook view-render; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" recall-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook recall-inject; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" nav-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook nav-inject; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" counterparty-brief; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook counterparty-brief; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-orient; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-orient; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-host-backup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-host-backup; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" stop-log-guardrail; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook stop-log-guardrail; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" answer-quote-check; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook answer-quote-check; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" hygiene-digest; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook hygiene-digest; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-promote; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-promote; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
    • plugins/codex/hooks/hooks.json: r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  2. Not scanned by AutoGineerThe author’s MCP configuration (.claude-plugin/plugin.json) also starts it in a way AutoGineer does not offer. Read that file: AutoGineer did not scan that path; what it runs is listed under “This brain runs”. https://github.com/itechmeat/open-second-brain/blob/233b91d1bdc7cf74c47ea1c1fd72e949887e1fc4/.claude-plugin/plugin.json
  3. First committed by hermes-vps-agent. Published in itechmeat/open-second-brain. Licence: MIT License.

Usage

AutoGineer overview, from the brain’s own files

  • Initialize a new vault for agent memory
  • Connect agents like Claude Code or Codex via MCP
  • Manage agent preferences and rules through automated dream passes
  • Audit agent activity using hash-chained logs
  • Perform semantic or keyword searches across agent-generated notes
  • Enforce write-path integrity with staged review gates
“o2b brain today”
Displays the current day's view and active memory markers for the agent session.
“o2b brain rollback”
Restores the vault state from the last snapshot, reversing recent brain mutations.

Documentation by hermes-vps-agent

Open Second Brain

Image: Open Second Brain - your knowledge, amplified by AI

Open Second Brain is a memory layer for AI agents that lives in an Obsidian vault. Agents read and write it through MCP servers, lifecycle hooks and the o2b CLI. Everything the agent learns is a Markdown file under Brain/, so you can open, edit, grep and version it like any other note. A deterministic dream pass turns repeated signals into rules and retires the ones nothing uses; no model runs inside that algorithm.

How it fits

flowchart LR
    subgraph Clients["Agent clients"]
        Plug["Plugins: Claude Code, Codex,<br/>Hermes, opencode, Grok Build"]
        Mcp["MCP config: Cursor, kiro, Copilot CLI,<br/>Gemini CLI, ZCode, any MCP host"]
        Claw["OpenClaw<br/>in-process plugin"]
        Cliu["Aider, Pi<br/>CLI and context file"]
    end
    subgraph Product["Open Second Brain"]
        Srv["MCP servers<br/>open-second-brain, open-second-brain-writer"]
        Hooks["Lifecycle hooks<br/>o2b-hook"]
        Cli["CLI<br/>o2b"]
        Core["Core"]
    end
    subgraph Vault["Your vault"]
        Brain["Brain/<br/>Markdown the agent writes"]
        Notes["Your notes"]
        Index[(".open-second-brain/brain.sqlite<br/>rebuildable search index")]
    end
    Plug --> Srv
    Plug --> Hooks
    Mcp --> Srv
    Claw --> Core
    Cliu --> Cli
    Srv --> Core
    Hooks --> Core
    Cli --> Core
    Core --> Brain
    Core --> Index
    Core -. reads, writes a named note on request .-> Notes

Each client reaches the same core through a plugin, an MCP registration or the CLI. The core keeps its own records under Brain/ and a generated search index under .open-second-brain/ that it can rebuild from the files. Your notes are read; the note tools write one only at a path the caller names.

flowchart LR
    Agent["Agent"] -->|brain_feedback| Inbox["Brain/inbox/<br/>signal"]
    Inbox -->|dream pass| Pref["Brain/preferences/<br/>rule"]
    Pref -->|rendered into| Active["Brain/active.md"]
    Active -->|read at session start| Agent
    Agent -->|brain_apply_evidence| Log["Brain/log/"]
    Log -->|next dream pass| Pref

The learning loop: signals become rules, rules reach the next session through Brain/active.md, and applied or violated evidence confirms, quarantines or retires them. Details: docs/how-it-works.md.

Install

  1. Install Bun 1.1 or later: install/prerequisites.md.

  2. Install the plugin for your client (guides below), or get the o2b CLI: install/prerequisites.md.

  3. Create the vault files:

    o2b init       --vault /path/to/vault --name "My Second Brain"
    o2b brain init --vault /path/to/vault
  4. Connect the client (table below), then check the result:

    o2b doctor --vault /path/to/vault
    o2b install --check

The full router with readiness criteria is install.md; native Windows is covered in install/windows.md.

Supported clients

ClientIntegrationGuide
Claude CodeMarketplace plugin: MCP servers and hooksinstall/claudecode.md
OpenAI CodexMarketplace plugin, then o2b install --target codex --applyinstall/codex.md
ChatGPT desktopReads the shared Codex config: o2b install --target codex --applyinstall/codex.md
Hermes AgentPlugin and memory providerinstall/hermes.md
OpenClawNative plugin, no MCPinstall/openclaw.md
opencodeo2b install --target opencode --apply: MCP servers and a native plugininstall/opencode.md
Grok Buildo2b install --target grok --apply: MCP servers and hooksinstall/grok.md
Cursoro2b install --target cursor --applyinstall/cursor.md
kiroo2b install --target kiro --applyinstall/kiro.md
GitHub Copilot CLIo2b install --target copilot-cli --applyinstall/copilot-cli.md
Gemini CLIo2b install --target gemini-cli --applyinstall/gemini-cli.md
ZCodeMCP servers added to its config by handinstall/zcode.md
Aidero2b install --target aider --apply: context file, no MCPinstall/aider.md
Pio2b install --target pi --apply: skill over the CLIinstall/pi.md
Any other MCP hosto2b install --target generic --apply --out - prints the server entriesinstall/generic.md

o2b uninstall --target <name> --apply removes what o2b install wrote; the vault is never touched (install.md).

Capabilities

  • Preferences that learn and expire. Signals, a deterministic dream pass, confidence and retirement: how it works.

  • Reversible changes. Brain mutations take a snapshot first; o2b brain rollback restores it: snapshots.

  • Attributed writes. o2b brain writes lists every note write by agent and device, writes revert plans a restore, o2b brain freeze stops all writers, and o2b brain log verify checks the hash-chained log: Brain CLI.

  • Search. Keyword search with an optional semantic lane, result explanations, recall profiles and compact cards; an opt-in iterative loop (search_iterative_recall_enabled, default off) keeps querying when coverage gaps survive, pausing with a plan the caller answers through brain_search's round trip; and an opt-in per-source-session cap (search_pool_max_per_session, default off) keeps one long imported session from filling the candidate window, so diversity spreads across sessions: search CLI, iterative recall, pool cap, ranking.

  • Session recall. Import agent transcripts already on disk, search them, or select whole-thread coverage under a character budget (o2b brain session-grep --operation coverage --char-budget <n>, with --entity-key seeding the fan-out from mined entity mentions): session logs, coverage.

  • Hygiene. o2b brain hygiene scan finds contested, duplicate, stale and unused memories; apply runs only the findings you pick: maintenance.

  • Claim ledger. o2b brain truth ingest records an entity claim with an optional validity window (--valid-from/--valid-until; with the flags absent, a window on the source record's frontmatter is adopted and frozen at ingest), truth events recalls a windowed slice of the ledger, and truth state grounds an agent-stated claim with an anchoring verdict - committed, or reported back with reason codes. truth state accepts an optional opaque --sample-id naming the context-pack sample the claim drew on, and claim reads report the receipt and outcome provenance behind it at the caller's reach - "receipt known, outcome unknown", never an invented verdict: entity truth CLI.

  • Corrections. o2b brain lifecycle correct <target> reports a correction's blast radius and retires the affected records - validity-closed by default, tombstoned with --flatly-wrong, dry run by default and --apply to write. A retired record that recall can still serve is served only beside its correction, never alone: belief lifecycle CLI.

  • Sharing and backup. Knowledge packs export a chosen subset; o2b brain bank-export writes a one-file backup bundle: knowledge packs.

  • Secret custody. Credentials live encrypted in the vault's state dir behind a capability-gated store; $secret:NAME references resolve at the use sites, o2b brain secret unlock wraps the keyfile under a passphrase, and secret export/import move the store between installs as one passphrase-encrypted bundle: custody CLI.

  • Today view and note markers. o2b brain today, @osb loop and @osb set markers: Brain CLI.

  • Self-inspection. o2b version, o2b install --friction and o2b state status report what is installed and where state lives: CLI reference.

  • MCP surface. Tools, tool profiles for hosts with tool limits, and the always-loaded writer server: docs/mcp.md.

Optional features

  • Semantic search: an embedding provider plus sqlite-vec; the embeddings-setup skill walks through it: skills/embeddings-setup/SKILL.md.

  • Decision models: a typed judgment model that can rerank search and filter candidates, off by default per use: docs/decision-models.md.

  • Deep relational recall: a fourth search arm over typed links, off by default. Its traversal runs under width budgets - 8 seeds, 4 edges per node, 16 nodes in total, and a hub above 12 walked edges is reached but not expanded - each overridable through an OPEN_SECOND_BRAIN_SEARCH_TRAVERSAL_* environment variable or a search_traversal_* config key; entity co-occurrence bridges join the walk by default and switch off separately (OPEN_SECOND_BRAIN_SEARCH_ENTITY_BRIDGES): retrieval quality.

  • Staged review for agent writes, off by default. With no key set every write publishes exactly as before. write_approval.notes / OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED and write_approval.ingest / OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED (each falling back to the write_approval.enabled master, default off) stage note creates and ingest summary pages into Brain/pending/ beside the staged signals, where they stay out of the search index until an operator runs o2b brain pending list and applies or rejects them; write-path integrity.

  • A permissions document, absent by default. Brain/_permissions.yaml (an operator-edited vault file) resolves allow/ask/deny per agent, role and target for the write, ingest and owner-write actions; with the file absent every check behaves exactly as today. ask stages the write, deny refuses with the write-refused token and the next command o2b brain permissions show, and every ask/deny verdict lands in a queryable decision ledger under Brain/logs/decisions/; o2b brain permissions show dry-runs the decision table, ledger reads the rows; Brain CLI.

  • The owner-write gate, off by default. integrity.owner_scope_writes in Brain/_brain.yaml (off | warn | fail, default off) refuses a caller-named owner that differs from the caller's resolved identity on the preference and note lanes (warn allows and records one decision-ledger row); a document owner_write verdict composes most-restrictive-wins with the gate; write-time integrity.

  • Named MCP tokens, optional. o2b mcp token mint|rotate|revoke|list keeps a hash-at-rest token per agent (.open-second-brain/secrets/mcp-tokens.json; material shown exactly once). Over HTTP a valid token authenticates as its agent per request, the shared --api-key stays valid as the operator master credential, and mcp_tokens_required / OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED (default false) makes a non-empty token map refuse credential-less requests. o2b bootstrap --target <harness> [--token] [--rotate] [--check] provisions MCP registration, token and receipt in one idempotent command, and o2b bootstrap --remove <harness> tears a provision down again; the minted token authenticates HTTP clients configured by hand, while the registered stdio harness presents no credential and keeps its config-derived identity; core CLI.

  • Ambient capture consent, opt-out. guardrails.ambient_writeback: false suppresses the ambient extraction lane with a counted ambient-withheld event (absent keeps today's behavior), and guardrails.ambient_ttl_days stamps an expiration_date on ambient-extracted signals so reads drop them after the window (absent stamps nothing); a TTL-stamped signal still stages when the review gate is on and survives apply verbatim.

  • Open decisions. o2b brain decision open --title <t> --question <q> --option <o> [...] parks a question with enumerated options at Brain/decisions/open-<slug>.md; resolve mints the real type: decision page, discard closes without deciding, and the morning brief renders up to five open questions: belief lifecycle.

What is new

Open Second Brain 1.98.0 is the agent-presence and self-improvement release. o2b install --target <host> --apply now writes the brain-usage block into the agent-instruction file the installed host reads at the vault root (AGENTS.md for codex, cursor, opencode and aider; GEMINI.md for gemini-cli) under the same consent prompt as the config writes, preserving every byte outside the block and recording its ownership so uninstall removes exactly the pair the tool wrote; the dream pass gains an operator-approved exit into those same files, where a lesson promotes into a managed lessons block only with a fresh approval pinned to the approved text, and the doctor grades every block state it can name. The skill lane gains a benchmark-gated rewrite loop: a skill is rewritten only after a candidate beats the incumbent on scored dev tasks with full coverage and survives the one held-out check - Open Second Brain never calls a model, the scorer reads structural predicates and nothing the agent claims about its own work, and the accept stays an operator step on the command line. And ChatGPT desktop is named where its operators look for it: it reads the shared Codex config, so one codex registration serves both. Every release is described in the CHANGELOG.

Documentation

TopicPage
Mental model, vault layout, dream pass, safety propertiesdocs/how-it-works.md
Every CLI verb and flagdocs/cli-reference.md
MCP protocol, tools, writer splitdocs/mcp.md
Brain store: setup, sync, migration, the human viewdocs/brain-store.md
Architecture and configuration modeldocs/architecture.md
Updating (o2b update) and upgrade notesdocs/updating.md
Observability eventsdocs/observability.md
Metrics data contractdocs/metrics.md
Cross-project pointerdocs/cross-project-pointer.md
Scheduled Brain digest (Hermes cron)docs/hermes-cron.md
Stability policydocs/stability.md
Origin of the ideadocs/idea.md

Project

CHANGELOG · Security policy · Source on GitHub · MIT License

docs/how-it-works.md

How Open Second Brain Works

A working guide for engineers and agents to the mechanics of the observing memory layer. Read this when you want to understand what the system does, not what to configure.

Mental model

Open Second Brain accumulates preferences and learns from real usage. Three responsibilities:

  • Capture. Agents and humans drop taste signals into Brain/inbox/.

  • Accretion. A deterministic dream pass turns repeat signals into rules.

  • Application. Agents record whether they applied or violated each rule when producing durable artifacts.

The LLM lives outside the system: agents use it to detect signals in conversation and to apply rules during work. The system uses counters, thresholds, and atomic file operations — no LLM inside the algorithm, no surprise, no hallucinated memory.

Vault layout

The agent owns one top-level directory for its own artefacts: Brain/. Everything it generates unprompted lives there. The one surface that writes user space is the note-lifecycle family, which acts on a path the caller names and can move a note to a second top-level directory, Archive/ — see architecture.md.

<vault>/
├── Brain/                          # agent-writable
│   ├── _brain.yaml                 # schema, thresholds, retention, vault scope, notes.read_paths
│   ├── _BRAIN.md                   # operating manual for agents
│   ├── active.md                   # derived: confirmed + quarantine + recently retired
│   ├── inbox/                      # raw taste signals
│   │   ├── sig-<date>-<slug>.md
│   │   ├── processed/              # signals already folded into rules
│   │   └── archived/               # unconsumed signals past the contradiction window
│   ├── preferences/                # active rules
│   │   └── pref-<slug>.md          # status: unconfirmed | confirmed | quarantine
│   ├── retired/                    # archived rules
│   │   └── ret-<slug>.md           # retired_reason: stale-no-evidence | expired-unconfirmed | rebutted | user-rejected | quarantine-violated | superseded-by-context
│   ├── log/                        # daily event log
│   │   └── YYYY-MM-DD.md           # append-only, typed events
│   └── .snapshots/                 # pre-dream snapshots
│       └── dream-<run-id>.tar.zst
│
├── .open-second-brain/             # derived search index (rebuildable)
│   └── brain.sqlite                # SQLite + FTS5 + optional sqlite-vec
│
└── Daily/                          # OPERATOR-OWNED user notes (any folder name)
    └── 2026-05-14.md               # listed in notes.read_paths for @osb scanning

Brain/active.md and .open-second-brain/brain.sqlite are derived — both can be deleted and rebuilt at any time (o2b brain dream and o2b search reindex respectively). They are excluded from the agent's write contract.

flowchart LR
    Agent[Agent / MCP client]
    subgraph Vault
        Brain[Brain/]
        Notes[("Operator notes<br/>(any folder)")]
    end

    Agent -- "brain_* (read + write)" --> Brain
    Agent -- "scan-inline (read-only)" --> Notes

Brain/ is the only area where the agent writes. User-authored notes (daily journals, weekly notes, etc.) live in folders the operator names — wherever they want them. List those folders under notes.read_paths in _brain.yaml to opt the agent into scanning them for @osb markers.

A preference's lifecycle

A preference moves between five states from first signal to retirement:

stateDiagram-v2
    [*] --> Inbox: brain_feedback
    Inbox --> Unconfirmed: dream (≥candidate_threshold<br/>same-sign signals on topic)
    Unconfirmed --> Confirmed: brain_apply_evidence applied<br/>(first such event)
    Unconfirmed --> Retired_Expired: dream (now > unconfirmed_until,<br/>no applied evidence)
    Confirmed --> Quarantine: dream (violated_count ≥ applied_count<br/>AND applied_count > low_max_applied)
    Quarantine --> Confirmed: dream (applied_count > violated_count)
    Quarantine --> Retired_Quarantine: dream (one further violated)
    Confirmed --> Retired_Stale: dream (last_evidence_at<br/>older than stale_evidence_days)
    Confirmed --> Retired_Rebutted: dream (≥candidate_threshold<br/>opposite-sign signals collected)
    Confirmed --> Retired_Superseded: dream (one apply-evidence with<br/>result: outdated)
    Quarantine --> Retired_Superseded: dream (one apply-evidence with<br/>result: outdated)
    Confirmed --> Retired_UserRejected: o2b brain reject
    Quarantine --> Retired_UserRejected: o2b brain reject
    Unconfirmed --> Retired_UserRejected: o2b brain reject
    Retired_Expired --> [*]
    Retired_Stale --> [*]
    Retired_Rebutted --> [*]
    Retired_Quarantine --> [*]
    Retired_Superseded --> [*]
    Retired_UserRejected --> [*]

    note right of Confirmed
        Pinned preferences (pinned: true)
        skip the three "natural decay"
        retire transitions (stale,
        expired, rebutted) and the
        quarantine downgrade — dream
        emits retain-pinned instead.
        A context-driven outdated still
        retires a pinned pref; only
        o2b brain reject and outdated
        evidence can move it.
    end note

Inbox is not really a state of the preference — it is the staging area for the signals that will eventually create one. The first real state is Unconfirmed: the rule exists but has not yet been applied in real work.

Quarantine (added in v0.9.1) is a probation state for confirmed rules whose recent evidence has turned dominantly negative without yet crossing the rebuttal threshold. A quarantined rule is still active — the agent reads it — but one further violated retires it with quarantine-violated, and recovery happens automatically when applied events overtake the violated count.

End-to-end signal → rule flow

A typical sequence from a user remark to a confirmed preference:

sequenceDiagram
    autonumber
    participant User
    participant Agent
    participant MCP as MCP layer
    participant Core as Brain core
    participant Vault

    User->>Agent: do not use internal abbreviations
    Agent->>MCP: brain_feedback (topic, signal negative, principle, agent)
    MCP->>Core: writeSignal
    Core->>Vault: atomic write Brain/inbox/sig file
    Core->>Vault: append Brain/log/today.md feedback event
    MCP-->>Agent: signal_path and signal_id
    Agent-->>User: confirmation

    Note over Vault: days pass and two more matching signals arrive on the same topic

    Note over Core: scheduled dream pass (cron or manual)
    Core->>Vault: scan inbox, preferences, log
    Core->>Core: group signals by topic
    Core->>Vault: createSnapshot to .snapshots/dream-run.tar.zst
    Core->>Vault: write Brain/preferences/pref file status unconfirmed
    Core->>Vault: move inbox signals into inbox/processed
    Core->>Vault: append log dream event
    Core->>Vault: pruneSnapshots to retention_count

    Note over Agent,User: later the agent produces a blog draft

    Agent->>Agent: write durable artifact
    Agent->>MCP: brain_apply_evidence (pref_id, artifact, result applied, agent)
    MCP->>Core: appendApplyEvidence
    Core->>Vault: append log apply-evidence event
    MCP-->>Agent: logged_at and log_path

    Note over Core: next dream pass
    Core->>Vault: scan log and count applied or violated per pref
    Core->>Vault: pref file status unconfirmed becomes confirmed
    Core->>Vault: pref file applied_count plus one, last_evidence_at, confidence
    Core->>Vault: append log dream event with confirmed list

Two important properties of this flow:

  • The dream pass is the only writer of state transitions (unconfirmed → confirmed, anything → retired). Signals and apply-evidence events are append-only side inputs.

  • Every state change is durable on disk before dream returns. There is no in-memory buffer that could be lost on crash.

Capture surfaces

Four independent paths land a signal in Brain/inbox/:

  • Live - the agent calls brain_feedback (MCP) or o2b brain feedback (CLI) the moment the rule is formulated. This is the path the end-to-end sequence above documents.

  • Inline - the user (or agent) writes an @osb marker into any vault Markdown file. o2b brain scan-inline finds every marker, creates the corresponding signal, and annotates the source file with @osb✓ [[sig-...]] so a re-run is a no-op. Two marker shapes: a single line @osb feedback negative topic=... principle="..." or a fenced ```osb block with YAML inside.

  • Session import - o2b brain import-session <path> reads a Claude Code / Codex CLI / Hermes session JSONL and extracts both @osb markers from message text and replays of brain_feedback tool-use calls. Useful when MCP was not available at recording time or the agent did not make the call live. Since v1.50.0 the same verb also sweeps the machine: --status reports per-runtime coverage over the session roots this build declares, --discover lists the logs that have never been imported, and --discover --all imports them through the same code path a named path takes. A content-digest ledger under <vault>/.open-second-brain/ keeps a re-run from re-parsing a log whose bytes have not changed.

  • Lifecycle hook - Claude Code / Codex hooks call o2b brain session-hook through hooks/session-capture.ts. UserPromptSubmit prompt markers and PostToolUse brain_feedback inputs are captured immediately through the same signal dedup hash, while SessionStart / Stop / SessionEnd / PostCompact observations append non-blocking lifecycle audit/log rows.

Session capture is role-filterable (since v0.37.0): session_capture_roles in the config (comma-separated subset of user,assistant,system,tool,meta) supplies the default --filter-role set for brain import-session; an explicit flag wins, an absent key captures every role, and an unknown role name fails fast.

SessionEnd carries two more lifecycle duties (since v0.37.0). It clears the ending session's bound search focus (search-focus/<scope>.json) so a finished session's steering never leaks into the next one - cleanup runs regardless of the capture boundary. And when session_handoff is "true" (default off), it writes an operator-readable handoff note to Brain/handoffs/<date>-<scope>.md - request, completed work, changed files, learned context, next steps - extracted from the recorded transcript by deterministic regex (o2b brain handoff <session-file> is the manual equivalent). Alongside the scope-free Brain/pinned.md scratchpad, scoped current-intention chains live at Brain/intentions/<scope>.md (o2b brain intention set|show|list|move, MCP brain_intention): every update bumps version and appends the superseded text to an in-file history trail, and move retires the chain into Brain/intentions/history/.

All four paths share a normalised payload hash so the same rule captured twice from different surfaces dedups automatically. Pinned preferences are exempt from automatic retire (stale-no-evidence, expired-unconfirmed, rebutted); only o2b brain reject can retire them.

Session mining has a memory of its own (since v1.86.0). When o2b brain extract-signals (or the brain_extract_signals tool) commits a mine that ends with nothing written and nothing staged - an empty answer, every item already deduped, every item refused by the durability gate, or any mix - it records a zero-yield tombstone for the session in a mining ledger beside the import ledger, keyed on a SHA-256 of the mined turn material (turn id, text, timestamp). The next plan over that session returns the recorded breakdown instead of an envelope, so an unchanged transcript is never re-offered to the model; a re-import that changes the turns changes the hash and re-opens the session with no clearing step. Because removing a downstream preference does not change a session's turns, the tombstone never lifts on its own: --force (MCP force) bypasses the gate for exactly one plan call when the session should be re-proposed. A commit that succeeds but cannot record its tombstone still succeeds and names the failure, so a ledger that stopped being written is never a silent fact.

o2b brain watchdog is the recovery surface for this layout. It checks the Brain config, required directories, and rebuildable search index, emits exponential backoff metadata for schedulers, and writes an audit record. By default it only reports a remediation plan; --remediate may create missing Brain directories, but search repair remains a recommendation (o2b search reindex) and snapshot restore is refused unless --restore <run_id> and --force-restore are both explicit.

Cross-project setup

When coding work happens in a project directory that is not the vault itself, add a pointer snippet to your project's CLAUDE.md or AGENTS.md so the agent knows where to read preferences from. The canonical snippet, the rules for multi-device Syncthing setups, and the o2b brain set-primary invocation are in cross-project-pointer.md.

A vault shared across hosts should declare a single primary_agent in Brain/_brain.yaml - the runtime that owns the dream cron. Dream runs from a different agent emit a non-fatal warning (stderr for CLI, warnings array for MCP) and tag the dream summary log with non_primary_agent: <caller>.

The dream pass in detail

A single dream invocation is a deterministic pipeline:

flowchart TD
    Start([o2b brain dream]) --> Load[Load _brain.yaml]
    Load --> Scan[Scan inbox/, preferences/, retired/, log/]
    Scan --> Intent[Build read-only intent_reviews<br/>for active signal clusters]
    Intent --> Plan[Plan transitions:<br/>new unconfirmed, promotions,<br/>quarantine in/out, retires, signal moves]
    Plan --> Changed{Any changes planned?}
    Changed -- no --> NoOp([regenerate active.md<br/>return changed: false])
    Changed -- yes --> Snap["createSnapshot to<br/>.snapshots/dream-RUNID.tar.zst"]
    Snap --> SnapErr{Snapshot OK?}
    SnapErr -- fail --> Abort([throw; no state changed])
    SnapErr -- ok --> Apply

    subgraph Apply [Apply transitions]
        direction TB
        A1[Per topic: signals → unconfirmed pref<br/>if threshold met] --> A2
        A2[Per pref: count log evidence,<br/>compute applied_count / violated_count]
        A2 --> A3{Unconfirmed with applied ≥ 1?}
        A3 -- yes --> A4[Promote to confirmed,<br/>stamp confirmed_at]
        A3 -- no --> A5
        A4 --> A5[Recompute confidence]
        A5 --> A6{outdated evidence?}
        A6 -- yes --> A6r[Move to retired/<br/>reason: superseded-by-context]
        A6 -- no --> A7{Pinned?}
        A7 -- yes --> A7l[Skip auto-retire + quarantine,<br/>log retain-pinned]
        A7 -- no --> A8{Quarantine conditions?}
        A8 -- enter --> A8q[status: quarantine]
        A8 -- exit --> A8c[status: confirmed]
        A8 -- already-quarantine + violated --> A8r[Move to retired/<br/>reason: quarantine-violated]
        A8 -- none --> A9{Retire conditions?}
        A9 -- stale --> A10[Move to retired/<br/>reason: stale-no-evidence]
        A9 -- expired --> A11[Move to retired/<br/>reason: expired-unconfirmed]
        A9 -- rebutted --> A12[Move to retired/<br/>reason: rebutted]
        A9 -- none --> A13[Leave in preferences/]
        A6r --> A14
        A7l --> A14
        A8q --> A14
        A8c --> A14
        A8r --> A14
        A10 --> A14
        A11 --> A14
        A12 --> A14
        A13 --> A14
        A14[Move consumed signals to processed/]
        A14 --> A15[Archive unconsumed signals older than the window to archived/]
    end

    Apply --> LogEvent["Append dream event to log/today.md"]
    LogEvent --> ActMd[regenerate Brain/active.md]
    ActMd --> Prune[pruneSnapshots to retention_count]
    Prune --> Done([return changed: true with summary])

Key rules baked into the pipeline:

  • Threshold by dominant sign. New unconfirmed preferences are created only when candidate_threshold (default 3) same-sign signals on one topic appear within contradiction_window_days. Mixed signals cancel and the rule does not form.

  • Inbox archive. A signal outside contradiction_window_days counts toward nothing, and never will again, so a signal the run did not consume and that is older than the window moves byte for byte to Brain/inbox/archived/ (archived_signals in the summary and the log event; --dry-run previews it). A stale file stays in the inbox only when it is tombstoned, does not parse as a signal (including an unparseable created_at), or its name is already taken in archived/ (a warning names it); a past expiration_date does not archive a signal still inside the window, because the pass still counts it. The rule runs against the earlier of --now and the real time, so a pass with a future --now archives nothing still inside the window today. A staged bundle does not treat newly archivable signals as drift. Archived signals stay readable: the pass reads them as history (a preference evidenced by one keeps its sign), and query, backlinks, sources, expiration, forget, the claim graph and the doctor record checks include them. They also stay in the capture dedup index, so re-importing an old session does not re-create them. dream.archive_stale_signals: false keeps them in the inbox; raising contradiction_window_days later does not bring archived signals back, move them by hand if that is wanted. o2b brain doctor reports inbox-archivable while a pass is due, counting the signals the pass selects for the archive (a --dry-run archives the same set less any signal it consumes instead).

  • Intent review is audit data. Each run computes intent_reviews before mutation so operators can see topics that are ready, weak, or conflicted. The review is exposed by dream, brain_review_candidates, o2b brain intent-review, and brain_intent_review; it preserves the existing transition outcomes by default.

  • Pre-run snapshot before any mutation. If snapshot fails, the run aborts without writing anything; safety net cannot be bypassed.

  • Idempotency. A second dream run on unchanged inputs is a no-op: no snapshot, no log entry, no file modifications.

  • Corrupted YAML is tolerated. A single unparseable signal or preference is logged as a skip-corrupted-frontmatter event; the rest of the run proceeds.

Lifecycle suite (v0.21.0)

The same pipeline is named as five explicit ordered phases - close (scan) -> reconcile (contradictions) -> synthesize (promote / confirm) -> heal (auto-retire stale, optional enrichment) -> log

  • each emitting a workrun checkpoint and a structured entry in DreamRunSummary.phases. The internals are unchanged; the phases are labels over the existing seams, so every invariant above still holds.

  • Reconcile classification. Each contradiction is bucketed by structural signal shape (claims / entity / decisions / source-freshness). Only source-freshness with a decisive recency gap auto-resolves - and even then it is recorded as a reconcile log event, never a sub-threshold state mutation. Everything else surfaces in open_questions for operator review rather than being force-merged.

  • Per-preference audit. Every mutation (create / promote / update / retire / merge) appends one line to Brain/log/pref-audit/<pref-id>.jsonl with the agent, reason, and revision + content-hash before/after. Counter-only churn is suppressed, so a no-op run writes no audit line.

  • Temporal extraction. On promotion an empty valid_from / valid_until is filled from the source signal - explicit bi-temporal fields first, else formal ISO-8601 tokens parsed from the signal text (no localized month/day names).

  • Heal enrichment (opt-in via dream.heal_enrich_enabled, default off). The heal phase links exact title/alias mentions across user vault pages outside the Brain root. Disabled by default so the install stays byte-identical.

  • Salience gate (opt-in via dream.salience_threshold, unset by default). The pass's only model delegation is the count-triggered rollup ladder, so the gate governs exactly one thing: which facts are counted into the ladder's fold set. The score is a deterministic weighted sum of the decayed outcome mass, the Wilson-bound confidence and the observed-reuse rate - no model, no cache. Unset, the gate is open, scores nothing, reads nothing, and the run is what it always was; set, every held-back fact is named with its score in the run summary.

Vault portability + session economy (v0.22.0)

A portability/ subsystem of deterministic primitives, all opt-in or no-op by default:

  • Session codec. A pure lossless compress/expand (expand(compress(x)) === x for all input). Token savings come from reversibly collapsing whitespace/blank-line runs behind a Private-Use-Area marker; code and structured tokens are preserved byte-for-byte. Opt-in on the signal store (gated by a _raw_codec marker the reader keys off) and exposed as o2b brain codec.

  • Sources dashboard. o2b brain sources / brain_sources aggregate the brain's signals by (agent, source_type) - a read-only projection.

  • Vault-map tokens. {{role}} tokens resolve to user content folders via an optional Brain/_vault-map.yaml (defaults otherwise), wired into scan-inline read paths and graph import. The fixed Brain/* layout is never routed through the resolver.

  • Multi-vault profiles. A profiles.json registry beside the config; resolveVault prefers the active profile over the bare vault key. Activation is a pointer (no symlinks - they sync inconsistently).

  • Graph export/import. A stable graph.json of the user's pages (wikilinks + typed relations) and an importer with skip/overwrite/merge modes, every write guarded by ensureInsideVault.

Confidence formula

Confidence is computed for every active preference on every dream pass:

flowchart LR
    Inputs[applied_count, violated_count,<br/>last_evidence_at, age] --> Q1
    Q1{applied ≤ low_max_applied<br/>OR<br/>violated ≥ applied?}
    Q1 -- yes --> Low[low]
    Q1 -- no --> Q2{applied ≥ high_min_applied<br/>AND violated = 0<br/>AND fresh?}
    Q2 -- yes --> High[high]
    Q2 -- no --> Medium[medium]

Defaults from _brain.yaml:

  • low_max_applied: 2 — rules with two or fewer applications stay low until they prove themselves.

  • high_min_applied: 10 — high confidence requires ten clean applications.

  • high_freshness_factor: 0.8 — "fresh" means now - last_evidence_at < stale_evidence_days * 0.8.

  • stale_evidence_days: 90 — the boundary for fresh / stale.

  • medium_min: 0.40, high_min: 0.75 — derived-band thresholds on the numeric confidence_value (see below).

All six are tunable per vault in Brain/_brain.yaml.

Numeric confidence_value

Each preference also carries a continuous _confidence_value: 0.0–1.0 field, computed alongside the band on every dream refresh. The value is the Wilson 95% lower bound on applied / (applied + violated) modulated by freshness decay that runs linearly from 1.0 at age 0 to 0.0 at stale_evidence_days. The band is the maximum of the legacy step-function and a numeric-threshold view (medium_min, high_min) — so legacy boundaries stay intact and numeric tuning can only lift bands, never demote them. The digest's ## Confidence shifts section uses the value to spot drops between runs; the MCP brain_query response and Brain/active.md both expose the number alongside the band for inspection.

Active preferences injection

A confirmed rule is useless if the agent does not see it during work. Four cooperating surfaces close that gap (the first three since v0.9.1, the fourth — brain_context — since v0.10.10), all derived from the same source of truth (Brain/preferences/):

flowchart LR
    Dream[dream pass] -- regenerate --> Active[Brain/active.md]
    Pin[o2b brain pin / unpin] -- regenerate --> Active
    Active -- "SessionStart hook<br/>(startup | resume | clear | compact)" --> ClaudeStart["Claude Code / Codex<br/>session start + after /compact"]
    Active -- "osb://preferences/active" --> MCPHost["MCP host (Hermes, others)"]
    Active -- "brain_context tool" --> PullRT["Cursor / Aider / raw API<br/>(no SessionStart hook)"]
    Pinned[Brain/pinned.md] -- "brain_context tool" --> PullRT
  • Brain/active.md is a derived Markdown digest: confirmed preferences (id, scope, confidence, principle), a Most-applied (Nd) section ranking confirmed/quarantine rules by apply-evidence (result: applied) events in the trailing window (defaults to 30 days / top-10; configurable via active.most_applied_window_days, active.most_applied_limit, and the SessionStart injection budget active.inject_budget_chars in _brain.yaml), quarantined preferences with their applied / violated counters, and the three most recently retired entries. The writer is idempotent — if the rendered body matches the file on disk, no I/O happens. The same window / limit drive a mirrored Most-applied (Nd) section in brain_digest output.

  • Brain/lessons.md is a second derived digest that folds the positive-knowledge (preferences + their apply-evidence applied rows) and negative-knowledge (violated / outdated rows and Brain/dead-ends/) into ONE outcome-tagged corpus scored by a signed, recency-decayed weight (shared 30-day half-life from working-memory continuity; tunable via lessons.half_life_days). Each lesson lands in a tier: preferred (net-positive and corroborated by ≥N distinct results — lessons.corroboration_min, default 2), tentative (net-positive but under the corroboration floor), contested (both positive and negative recent evidence — the shown stance follows the sign of the decayed score, so recency wins), or avoid (dead-ends and rules whose recent evidence is net-negative). Rendered top-N (lessons.limit, default 20), ranked by decayed salience. Regenerated at the tail of every dream pass alongside active.md, with the same idempotent write.

  • Brain/standing-rules.md is the one preamble lane the operator writes by hand. It is injected first, ahead of the runtime notices and every recalled body, under a header stating that it takes precedence over them. It is exempt from the injection budget and capped by its own active.standing_rules_max_chars (default 4,000); a capped block says how many lines and characters survived out of how many, and names the file. Because it is read outside the fail-open memory load it survives a memory-layer failure and is never written to the inject cache, so a stale copy can never be served in its place. If the file cannot be read, the block says so and states that no standing rules are in force - it never degrades to silence. Two mechanisms, not one, keep it out of an agent's reach, and the claim is exactly as wide as they are: the file sits under Brain/, whose first path segment the note-target resolver refuses for the four caller-named note-write tools (brain_create_note, brain_update_note, brain_append_note, brain_write_batch), and brain_labels - which takes a caller-named path but resolves it through the weaker containment-only resolver - is refused by a named guard that knows this one file (and, since v1.70.0, the Brain/standing-rules/ directory beside it). Nothing generates or rewrites it.

  • SessionStart hook (startup | resume | clear | compact) injects the body as additionalContext so the agent sees current rules at the start of every session and again after /compact - the compact matcher replaced the former PostCompact injection, whose event no longer exists in current Claude Code. The injected body is budgeted (active.inject_budget_chars, default 8,000 chars): sections first degrade to headline tiers - a section below the keep-guard priority (the preamble and the confirmed rules are exempt) keeps its heading, its lead-in lines and its top three bullets, ranked by the inline application/confidence tags (a section whose lines carry none of the known tags keeps its first three in render order) - and then drop deterministically (recently retired first, then quarantine, then most-applied). A one-line notice names the sections that were reduced to headlines and the sections that were dropped, how many characters survived out of how many, and points the agent at brain_context for the full set. When Brain/lessons.md exists, its (separately budgeted) body is appended so the unified lessons corpus loads on the same surface. Fails soft - every error path in the memory lane exits 0 and the runtime proceeds unaffected, emitting no memory context. The standing-rules block is the one exception, by design: it is read outside that boundary, so a vault with an operator rules file still speaks even when the memory layer is down, and a rules file that cannot be read is stated rather than skipped. The hook emits nothing at all only when there are NEITHER standing rules NOR memory. A subagent-inject carrier closes the one gap this session-start event cannot: a delegated sub-agent turn never saw the SessionStart payload, so the carrier delivers the same composition - the operator standing-rules block, the scoped rules and the budgeted active/lessons digest - on the SubagentStart event, before the sub-agent's first prompt, and again if the sub-agent compacts; on runtimes without that event the same hook falls back to the write-shaped PostToolUse call, once per sub-agent per session (the delivery ledger keeps the most recent 200 agent ids, so a session that spawns more than that re-delivers to an evicted id) and only when the composed payload is non-empty - the agent doing the writes is the one agent that must not run without them.

  • End-of-turn hygiene digest (opt-in): with hygiene_digest_enabled in the machine config (~/.config/open-second-brain/config.yaml, the same discovery the reground_parts_enabled flag reads; it has no _brain.yaml template entry), default off, env override OPEN_SECOND_BRAIN_HYGIENE_DIGEST_ENABLED, the Stop hook runs the default hygiene detector sweep plus the index-backed dangling-link count after any turn that wrote an artifact and folds the warning- and action-severity findings into ONE line, so small maintenance does not wait for an explicit brain_hygiene call. A hash ledger beside the hook-state directory keeps an unchanged finding set silent - once per change, not once per turn; zero eligible findings and flag-off installs emit nothing. The line rides Claude Code's non-blocking Stop feedback channel only; on runtimes where a Stop block would force a continuation turn the hook stays silent.

  • MCP Resources expose the same content for hosts that prefer pull access (osb://preferences/active and friends in the table above). The MCP initialize reply advertises the resources capability so clients know to enumerate them.

  • brain_context MCP tool (v0.10.10) lives in the always-loaded writer-scope MCP server. Runtimes that lack a SessionStart hook and do not auto-load MCP resources (Cursor, Aider, raw Claude API) can fetch the same active.md body, pinned current-task context, and active-preference counts with a single tool call. The standing-rules block leads that content too, and rides an optional standing_rules key (path, content, truncated) omitted when the file is absent.

  • Brain/pinned.md is a transient scratchpad for current-task facts. Agents update it through brain_pinned_context when a fact should survive context rotation but should not become a durable preference. Clearing it leaves the permanent Brain history untouched.

active.md is not edited by hand — o2b brain dream and pin / unpin re-derive it from preferences/ and retired/. Deleting the file is harmless: the next dream regenerates it.

Scoped operator rules

Since v1.70.0 the operator can also write rules that hold for one project, one harness or one machine, one file per scope value, beside the vault-wide Brain/standing-rules.md:

FileMatches when
Brain/standing-rules/project/<key>.mdthe session runs in a project linked to the vault whose key is <key>
Brain/standing-rules/harness/<harness id>.mdthe MCP server was launched for that harness
Brain/standing-rules/host/<device id>.mdthe session runs on the device with that id

The scope identity is resolved by the server from facts the operator or the packager wrote, never from anything a caller names about itself:

  • Project: the directory holding the nearest .o2b-vault.json pointer found from the workspace directory (the pointer o2b brain project link writes), by its basename. The hook uses the payload's cwd (else its own working directory), the MCP server the directory it was started in. Only a pointer that names the vault serving the session counts: a pointer naming another vault, or one that cannot be read, is skipped and the walk continues upward. No such pointer matches no project file.

  • Harness: the --harness <id> option of o2b mcp, falling back to --host-target. The Claude Code plugin registers both of its servers with --harness claude-code and the Hermes plugin launches its bridge with --harness hermes; install adapters already write --host-target. A server launched with neither matches no harness file.

  • Host: the device id (device_id in the config, or O2B_DEVICE_ID). An empty device id is the explicit opt-out and matches no host file. A device id that cannot be read applies no host file either, and when Brain/standing-rules/host/ holds a file the block ends with one sentence saying so and naming o2b brain doctor for the cause.

A project or device name becomes a key by lowercasing it in Unicode normal form NFC and turning every run of characters that is not a letter, a combining mark or a digit, in any script, into one -, without leading or trailing - and at most 64 characters: a project folder My_Repo.v2 keys to my-repo-v2, and a name written with vowel signs or accents keeps them, so Café and café share the key café.

The matching files are joined under one ## Scoped operator rules header, which states the precedence: the operator standing rules above take precedence over them, and they take precedence over every recalled preference, lesson and context pack that follows. Each file gets a ### Project: <key>, ### Harness: <key> or ### Host: <key> subheading, and its own text follows as written. A file that cannot be read is replaced by one line, UNAVAILABLE: <vault-relative path> could not be read (<error code>)., and so is a file or an axis folder that is a symbolic link leading out of the vault, with the code ESCAPE; an empty or missing file is no rule.

Where each layer renders:

  • SessionStart hook: project and host files, right after the operator standing rules and before the active body. Harness files do not render in the hook in this release, because the hook has no launch-time harness of its own.

  • brain_context: project, harness and host files, after the standing-rules block and before the memory body, plus an optional scoped_rules key with the resolved scope and the matched files (see mcp.md).

  • Reach: the layer renders only for a local caller. Below local reach the server's working directory and device are not the caller's, so neither the block nor the key appears and the device id is not read.

The block's sections are capped by active.scoped_rules_max_chars (default 2,000, at least 200); the cap covers the sections only, not the header or the notices. When the cap cuts anything the host file goes first, then the harness file, then the project file, and the block ends with a notice of the characters kept and the files dropped; a file whose rules cannot fit at all is dropped whole rather than left as a bare subheading. Unlike the vault-wide file, the block is charged against the injection budget in the SessionStart hook: there the header and room for the notices ride inside active.inject_budget_chars, so the section cap is the smaller of scoped_rules_max_chars and what the budget leaves after them (never below zero), the rendered block is subtracted from inject_budget_chars before the active body is budgeted, and the block's length is recorded as scoped_rules_chars on the hook's receipt (see observability.md). The files are read on every render and never cached, so one project's rules are never replayed in another.

The directory is as far out of an agent's reach as the vault-wide file: every write path that refuses Brain/standing-rules.md refuses any path inside Brain/standing-rules/ too, compared as written and after resolving symbolic links, and nothing generates or rewrites the files.

CLI / MCP surface

The same operations are reachable through two channels. Read columns are mirrored in MCP; destructive operations are CLI-only by design.

OperationCLI verbMCP toolSide effect
Bootstrap layero2b brain init—creates Brain/ skeleton
Record taste signalo2b brain feedbackbrain_feedbackwrites signal + log event (sanitised)
Consolidation passo2b brain dreambrain_dreammutates preferences/retired, atomic snapshot, regenerates Brain/active.md
Record applicationo2b brain apply-evidencebrain_apply_evidenceappends log event; result ∈ {applied, violated, outdated}; artifact supports [[file:120-145]] ranges
Record milestoneo2b brain note <text>brain_noteappends one note log event to Brain/log/<today>.md plus the JSONL sidecar. Multi-line collapses to one line. CLI mirror exists for cron / shell.
Pin current-task context— (use MCP)brain_pinned_contextwrites/appends/clears Brain/pinned.md; transient context, not a learned preference
Pull active context— (use MCP)brain_contextread-only; returns Brain/active.md body, pinned context + counts. Always-loaded on the writer MCP server so runtimes without a SessionStart hook (Cursor, Aider, raw Claude API) can fetch it at session start.
Render summaryo2b brain digest [--window Nd]brain_digestread-only; includes top_applied, top_referenced, and agent_summary sections
Pre-dream intent reviewo2b brain intent-reviewbrain_intent_reviewread-only; classifies active signal clusters as ready, needing evidence, or conflicted before the next dream
Lifecycle retention reviewo2b brain retentionbrain_retentionread-only; recommends keep / improve / park / prune for retired preferences and processed signals; no artifact is deleted or moved
Monthly synthesiso2b brain monthlybrain_monthly_reviewread-only; month-level counters for events, status transitions, retirements, contradictions, and neglected areas
Inspect stateo2b brain querybrain_queryread-only
Computed backlinkso2b brain backlinks <id>brain_backlinksread-only; inverted reference map across preferences/, retired/, log/
Validate invariantso2b brain doctorbrain_doctorread-only; structural lints + semantic-health findings; --remediate [--dry-run] applies auto-safe content-hash re-stamps
Semantic healtho2b brain healthbrain_healthread-only; contradictions, concept gaps, stale claims + clean/watch/investigate verdict
Preference edit-historyo2b brain history <slug>— (CLI-only)read-only; one entry per content mutation (principle / scope / status before -> after)
Full-text searcho2b search "<query>"brain_searchFTS5 + optional semantic; write-classified MCP tool (persists access and opt-in reinforce/telemetry ledgers under Brain/search/)
Manage search indexo2b search index | reindex | status | check— (CLI-only)builds / inspects <vault>/.open-second-brain/brain.sqlite. search check ends with a recommendations: block on missing pieces (key, sqlite-vec, first reindex)
Cron template for reindexo2b search reindex --cron-template [--interval N]— (CLI-only)prints a watchdog script, native crontab line, and hermes cron create recipe to stdout (writes nothing)
Operational snapshoto2b statussecond_brain_statusread-only; brain.* + search.* blocks
Retire manuallyo2b brain reject— (CLI-only)requires --reason "<text>"; subsequent signals on the same topic are suppressed
Merge near-duplicate prefso2b brain merge <keep> <drop>— (CLI-only)folds evidenced_by and counters into keep; drop retires with reason merged-into; surfaced as candidates in brain_digest
Toggle pino2b brain pin / unpin— (CLI-only)flips pinned field; regenerates Brain/active.md
Protect Brain/o2b brain protect / unprotect— (CLI-only)machine-enforced deny rules for claudecode / codex runtimes; sidecar manifest at .open-second-brain/protect.lock.json
Restore snapshoto2b brain rollback— (CLI-only)overwrites Brain/ from snapshot; from v0.10.6 aborts on drift unless --force-rollback, see Snapshots and rollback
Upgrade managed fileso2b brain upgrade— (CLI-only)migrates the three release-owned files (_brain.yaml, _BRAIN.md, _OPEN_SECOND_BRAIN.md) forward. _brain.yaml is text-merged additively (user values, comments, and ordering preserved); the other two are byte-compared and overwritten. --dry-run prints a per-file plan; --check exits 2 on pending updates (CI-friendly); --apply --yes applies the plan it showed: a file edited after the plan was read is refused before the upgrade-<ts> snapshot, and again at each write, instead of being overwritten. After a plugin update the MCP server and the SessionStart hook run the same upgrade in a detached worker, off the start-up path, and back off after a failure (see docs/updating.md).
Export active prefso2b brain export --format json|llms-txt— (CLI-only)read-only dump of confirmed | unconfirmed | quarantine preferences from Brain/preferences/. --out <path> writes a file (refuses to overwrite without --force); default sink is stdout. Retired and signal artifacts are deliberately excluded.
Export a transcript corpuso2b brain export --format transcripts-jsonl --transcripts <file|dir>— (CLI-only)since v1.50.0: one JSON object per line, one per CONVERSATION, built from recorded session logs. Reads no vault. Messages carry role, text and the NAMES of the tools a turn called, never the tool inputs. --runtime filters by adapter; --since/--until select whole conversations by their start. Guarded record by record; a secret-shaped identifier refuses the whole export and writes nothing. Full contract in cli-reference.md.
Force-directed explorero2b brain explorer [--port | --export]— (CLI-only)live HTTP on 127.0.0.1 (default :7777) or single-file HTML at <path>; renders preferences + retired as a graph; zero backend. Keyboard-accessible <ul role="listbox"> mirror of visible nodes (ArrowUp/Down/Home/End/Enter/Escape); layout + filter state persisted to localStorage under osb-explorer-layout:<vault_basename>; "Reset layout" button clears the key.
Import Claude memoryo2b brain import-claude-memory— (CLI-only)imports metadata.type=feedback entries from a Claude Code memory directory into Brain/preferences/. --dry-run prints a per-file plan; --apply --yes takes an import-claude-memory-<ts> snapshot before writing. Sidecar manifest Brain/.imports/claude-memory.json keys idempotency on body sha256. UPDATE preserves the eight evidence-related frontmatter fields. CONFLICT (existing pref outside the manifest) lands the safe writes and exits 2.
Daily discipline reporto2b discipline {report|install|uninstall}— (CLI-only)renders a deterministic Telegram MarkdownV2 block comparing per-agent brain-events vs runtime-agnostic activity (git/mtime/vault delta) plus a complexity-to-thinking ratio. install writes a Hermes cron entry (--telegram-target required; --weekly installs a Monday digest; uninstall --weekly removes only the weekly job). alert fires when taste events (feedback+apply_evidence) are zero while activity is non-zero, or when structural churn grows much faster than recorded thinking.

Operations that change the protected set (pin, unpin, reject, rollback) and the index lifecycle (search index, reindex, check) are kept off the MCP surface — protected-set moves so an autonomous agent cannot quietly alter what is shielded from automatic retire, and index management because it is operator business, never agent business.

In addition to the tools above, MCP hosts can pull structured Brain content as resources (added in v0.9.1):

Resource URIBody
osb://preferences/activeBrain/active.md (auto-regenerated on first read if missing)
osb://digest/latestmost recent brain_digest rendering
osb://statusMarkdown form of the second_brain_status.brain block
osb://preference/{id}one pref- / ret- file
osb://topic/{slug}every signal + active/retired pref for a topic
osb://log/{date}one day's Brain/log/YYYY-MM-DD.md (a parallel YYYY-MM-DD.jsonl sidecar carries the same events as one JSON row per line for machine consumers)
osb://backlinks/{id}inverted reference map for <id>

Resources are pure read; mutating verbs stay on tools/call.

Snapshots and rollback

A snapshot is taken before any state-changing dream run:

flowchart TD
    Trigger[dream wants to mutate] --> CS[createSnapshot]
    CS -- failure --> Abort([abort; nothing changed])
    CS -- success --> Mutate[apply mutations]
    Mutate --> Log[append dream log event]
    Log --> Prune[pruneSnapshots to retention_count]

    RB["o2b brain rollback RUNID"] --> Restore[restoreSnapshot]
    Restore --> Extract[extract .tar.zst into tmp]
    Extract --> Replace[replace Brain/ entries<br/>excluding .snapshots/]
    Replace --> LogR[append rollback log event]

A snapshot captures every file under Brain/ except .snapshots/ and .artifacts/ — the first because rollback would otherwise erase any snapshots taken after this one, the second because it is TTL'd MCP tool output documented as never backed up (archiving and hashing it made unrelated cache churn trip the drift gate). Retention defaults to ten newest archives.

The archive is streamed: tar writes a staging file and the compressor reads it, so the size of Brain/ sets no memory ceiling. The compressor writes a partial file in .snapshots/, and the archive appears under its final <run_id>.tar.zst name only once it is complete, published by an exclusive hard link that refuses a name already taken; the manifest is written after that. A snapshot interrupted during compression therefore leaves no recovery point behind, only a <run_id>.tar.zst.partial-* file that nothing lists; retention removes it once it is a day old.

From v0.10.6 every snapshot ships with a SHA-256 sidecar manifest (Brain/.snapshots/<run_id>.manifest.json) listing every regular file the snapshot covers — the same tree, minus the two excluded entries named above — and its hash. Hashing a file the archive does not contain and the restore does not replace would fire the drift gate on churn no rollback could ever undo. o2b brain rollback reads the sidecar back, rebuilds a fresh manifest from the live tree, and compares: any added / removed / changed entry aborts the rollback with exit code 2 and a compact drift report on stderr. The intent is to refuse silent overwrites of Syncthing-delivered edits made on another device between snapshot and rollback. Pass --force-rollback to override; the resulting rollback log row records drift_overridden: true. Snapshots predating v0.10.6 have no sidecar — rollback emits a stderr warning, skips the drift check, and falls through to the legacy direct-restore path so old archives still recover cleanly. The same sidecar primitive backs o2b brain upgrade --dry-run's per-file diff: both features share src/core/brain/manifest.ts as the single source of truth for "what does Brain/ look like right now".

Derived-store coverage

The derived SQLite store (<vault>/.open-second-brain/brain.sqlite) is a sibling of Brain/, not a member of it, and it used to be in no snapshot, no manifest and no rollback — with nothing saying so, so a pre-restore diff rendered a complete-looking picture while the embeddings silently stayed at whatever the live store held.

What coverage protects is spend, not information. Feedback, activation and tuning are replayable JSON folds inside Brain/; only the embeddings and a tier baseline are database-only, so a lost store costs an embedding bill and a reindex, never a fact. Against that: retention keeps ten archives and .snapshots/ rides the same peer-to-peer replication as the rest of the vault, so every retained copy lands on every device. Hence the three-part answer:

  • Opt-in. snapshots.include_derived_store defaults to false.

  • A ceiling that refuses. snapshots.derived_store_max_bytes (256 MiB by default) is compared against the live store and the snapshot throws when it is exceeded, naming the measured size. Half a database is not a recovery point, so it is never truncated.

  • The live store size in every manifest, included or not, so an operator who has never enabled coverage still sees what it would cost.

When coverage is on, createSnapshot resolves the store path through the search layer's resolver, refuses when the file is absent, takes the same writer lock every indexer serialises on, runs the structural integrity scanner and refuses a condemned store rather than archiving it over a good snapshot, checks the ceiling, then VACUUM INTOs a temporary file and compresses it to Brain/.snapshots/<run_id>.store.sqlite.zst. VACUUM INTO rather than a file copy because the store is a live SQLite database - WAL mode on a local backing, DELETE on a remote-backed file - and the runtime exposes no online-backup API. The archive sits beside the tar rather than inside it: the extractor requires a Brain/ root and the restore is defined as "live Brain/ equals archive Brain/ minus the excluded entries", so a second top-level tar member would break both contracts. Keeping it in the same directory keeps listing and retention one family with one loop.

Any of those steps failing is a refusal, not a skip: createSnapshot throws and leaves no tar behind, so the destructive-snapshot gate — which already aborts before the operation when the snapshot throws — simply does not run the mutation it could not fully protect.

The manifest records what happened under an additive derived_store key at the existing schema version (bumping it would make every older peer silently lose drift detection on new snapshots): whether it was included, the resolved source path, the archive name, the digest over the archived bytes, the compressed size, the live store size always, and one of four named exclusion reasons — not-requested, absent, integrity-fault, over-size-ceiling. A sidecar with no derived_store key predates the feature and renders as unknown, never as excluded.

Retention deletes the store archive alongside the tar and the sidecar. rollback replaces the store only when the manifest says it was included, decompressing to a sibling temp file and renaming it into place under the writer lock — the same swap discipline the indexer uses — and prints in every case which answer applied: replaced, not restored with the recorded reason, record missing when there is no record but a store archive is sitting beside the tar (the sidecar write is non-fatal, so coverage can run and leave nothing behind saying so), or unknown when there is neither a record nor an archive. The third and fourth used to share one sentence that named the feature as absent while the feature's own archive was on disk.

Why a recovery point was taken

Two histories existed here and did not join. The Brain event log is richly typed, per-device sharded and machine-primary, and it recorded a rollback; the snapshot family is the only revertible history, and every entry in it was an opaque run id plus an mtime. The reasons existed de facto — as run-id prefixes at five call sites, three of them inline string literals — and nothing parsed them back, so an operator could not ask which recovery point covers a given boundary, could not filter the revertible history by why it happened, and could not walk from a logged event to the archive that would undo it.

Every snapshot now carries a typed reason from one closed vocabulary (BRAIN_SNAPSHOT_REASON): dream, upgrade, import-claude-memory, delete-by-source, entity-prune, note-delete, note-revert, payload-gc for the eight destructive call sites, plus manual for the recovery point a rollback takes of the live tree it is about to discard (restoreSnapshotWithRecoveryPoint in snapshot-gate.ts, reached from o2b brain rollback). Three members have no producer in this release — session-boundary, plan-boundary and decision-boundary, for boundaries a later release may snapshot at. No CLI verb and no MCP tool mints a recovery point on demand: the take-snapshot entry point is reached only by the destructive-operation gate, the rollback's own recovery point included. The reason is required by createSnapshot, doubles as the run-id prefix so the filename and the recorded provenance can never disagree, and is stamped into the manifest sidecar as an additive snapshot_reason key at the existing schema version — same reasoning as the derived-store key: bumping it would make every older peer silently lose drift detection on new snapshots.

A sidecar with no reason reads as unknown and the reason is never reconstructed from the run-id prefix that happens to spell it. That inference would look correct on almost every archive this project writes, and would manufacture provenance for an unstamped, hand-named or third-party archive at exactly the moment an operator is deciding whether to overwrite their live tree with it. An unregistered reason fails the whole manifest closed, as one malformed file entry already does.

Each created recovery point also emits one snapshot log event carrying the run id, the reason and the archive size — the counterpart the log has been missing since rollback shipped, which recorded the restore while the point it restores to left no trace but a filename. The append is best-effort: createSnapshot runs before the mutation the destructive-snapshot gate protects, so a throw would turn a lost audit line into a refused mutation.

Taking snapshots at session, plan and decision boundaries is deliberately not shipped. That changes how often snapshots happen, which interacts with retention and with the optional derived-store archive. An on-demand snapshot verb is not shipped either, for its own reasons: every recovery point this build takes exists to protect a specific mutation that is about to run, and an operator-triggered snapshot is a separate decision about retention pressure and about what an operator does with a point nothing was going to overwrite. The vocabulary carries all four members anyway so this build can read a sidecar a later release writes and replicates back — which also means snapshot log --reason manual is a valid filter over the recovery points o2b brain rollback takes of the tree it replaces. Those are local archives and the operator's way back out of a rollback, not foreign ones.

Read-only inspectors over the snapshot family

With snapshot log the family is complete: log / diff / revert.

  • o2b brain snapshot log — newest-first listing of every recovery point (by archive mtime, because a hand-named run id carries no timestamp to sort on): run id, created-at, reason, archive size, whether a drift manifest is present, and the derived-store record. --reason <reason> filters by why it happened and rejects an unregistered value with a usage exit (2) rather than an empty listing, which would say "you have no such snapshots" when the truth is "that is not a reason". --limit <n> caps the listing, --json yields the structured rows. An empty snapshots directory exits 0; a snapshots directory that exists and cannot be read exits non-zero naming the path, because "no snapshots available" over an unenumerable directory is the same substitution one line up.

Two further surfaces share the same diff renderer over the snapshot extraction primitive (extractSnapshotToTemp), so previewing and auditing stay byte-equal:

  • o2b brain rollback <run_id> --dry-run — extract the archive into a sibling tmp dir, compute the live → snapshot diff, print it, drop the tmp dir. Mutually exclusive with --yes since preview-vs-execute is contradictory. No live writes; no log entry.

  • o2b brain snapshot diff <run_id_a> [<run_id_b>] — same renderer, but compares snapshot ↔ snapshot when both ids are supplied (and snapshot ↔ live when only one is). --json yields the structured BrainTreeDiff payload for scripting.

rollback --list, the confirmation prompt and the --json result all name the reason too — it is the one fact that distinguishes two archives minted seconds apart.

The diff classifies every file under each root into six artifact kinds (preference, retired, signal, log, config, other). Preference and retired files receive a typed field-level diff for the derived counters (_status, _applied_count, _violated_count, _confidence, _confidence_value, pinned, and a few identity adjuncts); other kinds compare by byte equality and surface as (body changed).

The log kind covers every shape the log path helpers write — <date>.md, <date>.jsonl and their per-device <date>.<deviceId>.… shards — and it decides that through the recogniser in log-jsonl.ts, the module whose contract is that the shard layout lives in exactly one place. The differ used to carry a pattern of its own, which is how it came to predate sharding and classify both the per-device markdown and the machine-primary JSONL as other, mislabelling the surface a rollback --dry-run is most likely to be showing. Ledgers and subdirectories under log/ (capture-decisions[.<deviceId>].jsonl, dream-runs/, pref-audit/) are not days of the log and stay in the catch-all class.

Every append-only ledger that several synced machines write keeps one file per device, named <stem>[.<deviceId>].jsonl: the fixed-name Brain/log/ ledgers (query-demand, recurrence-support, capture-decisions), the verifier-rejection ledger under Brain/skill-proposals/, each preference's pref-<slug>.history, each repository's git commits and the maintenance journal .open-second-brain/maintenance-runs (merged by row time on read). The ISO-week audit directories (session-lifecycle/, hygiene/, secret-custody/, schema-mutations/, watchdog/ and the hook and watchdog audit roots under .open-second-brain/) write <YYYY-Www>[.<deviceId>].jsonl. O2B_DEVICE_ID / device_id select the shard; the empty device id writes the legacy un-suffixed name, and a legacy file stays readable without any migration. The full contract is in observability.md.

Primary agent declaration

Brain/_brain.yaml carries an optional primary_agent: <name> | null key. When set, dream runs invoked from a different agent emit:

  • a stderr warning of the form warning: non-primary-dream-run: …,

  • a warnings array entry on the MCP brain_dream response,

  • a non_primary_agent: <caller> payload row in the dream summary log event.

The dream pass still completes — the declaration is observability, not access control. Set / clear via o2b brain set-primary <name> or o2b brain set-primary --clear. A vault initialised with o2b brain init --primary-agent <name> writes the value into the fresh _brain.yaml. The full multi-device walkthrough is in docs/cross-project-pointer.md.

Hygiene: sanitisation and lints

Two cheap, deterministic layers keep the data clean.

Input sanitisation (v0.9.1). Every Brain writer routes text fields through src/core/redactor.ts plus a normaliseTextField / sanitiseTextField pair:

  • C0 control characters are stripped (except \t and \n); U+2028 and U+2029 collapse to \n; text is NFC-normalised.

  • Length caps per field: principle ≤ 512 (single-line), scope ≤ 128, raw ≤ 4096, source[] items ≤ 512, artifact ≤ 512 (single-line), note ≤ 4096.

  • Secret-shaped substrings (api_key=…, token: …, bearer …, authorization: …, password=…, private_key=…, etc.) are masked with ***REDACTED***. The raw field is the only verbatim-quote surface and is intentionally not redacted — it carries the original user phrasing.

  • Explicit <private>...</private> regions are stripped before secret assignment redaction. An unclosed <private> tag strips from the tag to the end of the field, which is conservative for accidental paste leaks.

  • Inputs that sanitise down to empty fall into the existing "missing field" error branch, so the writer never persists a placeholder signal.

Doctor lints (o2b brain doctor, brain_doctor MCP). All are pure functions over the on-disk Brain state — no LLM, no network. The first group are structural; the last three (v0.14.0) are semantic and also populate the semantic_health report (see brain_health below):

CodeTriggers when
broken-backlinksa [[pref-…]] / [[ret-…]] / [[sig-…]] reference exists in preferences/, retired/, inbox/, or log/, but the target file does not
duplicate-preferencespairwise Jaccard ≥ 0.7 on principle tokens within the same (topic, scope) bucket
low-evidence-confirmedstatus: confirmed with applied_count ≤ low_max_applied AND confirmed_at older than unconfirmed_window_days
pinned-without-recent-evidencepinned: true with no evidence or evidence older than stale_evidence_days
malformed-evidence-rangeapply-evidence artifact uses [[file:…]] range syntax but fails validation (:abc-def, :120-100, bare :)
orphan-evidenceapply-evidence artifact wikilink does not resolve to any file in the vault
contradictory-preferencestwo confirmed preferences share a subject (principle Jaccard ≥ health.contradiction_jaccard) but carry an opposite sign of record
concept-gapan entity recurs across ≥ health.concept_gap_min_frequency corpus entries with no covering preference topic
stale-claima confirmed preference's newest evidence is older than health.stale_claim_max_age_days

That table is a selection, not the registry. Later releases added codes that are documented alongside the feature that produces them in docs/cli-reference.md - for example symlink-escape, entity-label-malformed, brain-root-absent, and the v1.46.0 recall-channel-silent / recall-channel-unmeasured pair. The complete enumeration lives in the source (src/core/brain/diagnostics.ts for codes with a registered next command, src/core/brain/doctor-exits.ts for those without) and is CI-enforced by tests/core/brain/doctor-exit-census.test.ts, which fails on a code that appears in neither table.

Since v1.69.0 the orphan-evidence and malformed-evidence-range findings carry sources (the preference the evidence is about) and removed-tool-reference carries path (vault-relative on the MCP wire), so every finding names its record structurally and brain_doctor leaves out a finding about a record the caller cannot read at its reach.

With --strict, warnings demote ok to false so CI can gate on hygiene. brain_doctor itself stays read-only — auto-modifying state on a plain doctor run would break the "explicit-driven" invariant. The explicit opt-in o2b brain doctor --remediate [--dry-run] is the only writer: it plans a dependency-ordered repair and applies the auto-safe fixes (currently the lossless content-hash re-stamp), bounded by health.remediation_step_cap.

flowchart LR
    BrainRoot[Brain/]
    BrainRoot --> Yaml[_brain.yaml]
    BrainRoot --> Manual[_BRAIN.md]
    BrainRoot --> Inbox[inbox/]
    BrainRoot --> Prefs[preferences/]
    BrainRoot --> Ret[retired/]
    BrainRoot --> LogD[log/]
    BrainRoot --> Snaps[.snapshots/]
    Yaml --> Archive((snapshot.tar.zst))
    Manual --> Archive
    Inbox --> Archive
    Prefs --> Archive
    Ret --> Archive
    LogD --> Archive
    Snaps -. excluded .-> Archive

Integration with agent runtimes

The same MCP tools are advertised to every runtime; only the wiring differs:

graph LR
    Hermes -- "mcp_servers.yaml" --> Stdio["o2b mcp (stdio)"]
    ClaudeCode["Claude Code"] -- "plugin.json mcpServers" --> Stdio
    Codex -- "codex mcp add" --> Stdio
    GrokBuild["Grok Build"] -- "config.toml [mcp_servers]" --> Stdio
    OpenClaw -- "native JS plugin" --> InProc["in-process tools"]
    Stdio --> Vault[(Brain/ on disk)]
    InProc --> Vault
  • Hermes loads the MCP server via mcp_servers: in ~/.hermes/config.yaml. The agent surface also needs the brain-memory skill enabled in the active profile (via hermes-skills-sync enable <profile> brain-memory) so the LLM recognises preference triggers in conversation.

  • Claude Code picks up the mcpServers declared in the bundled .claude-plugin/plugin.json and the plugin-shipped brain-memory/SKILL.md automatically.

  • Codex registers the MCP server with codex mcp add; the same skill bundle is loaded automatically.

  • Grok Build registers MCP in ~/.grok/config.toml [mcp_servers.*] (grok's primary source) and lifecycle hooks in ~/.grok/hooks/ (its native hooks dir), both with an absolute bun run …/main.ts command, via o2b install --target grok. Verified against live grok 0.2.45: the bare o2b command does not resolve on grok's session-spawn PATH, so the absolute form is required. grok sessions (~/.grok/sessions/.../updates.jsonl, an ACP stream) import via the grok session adapter. See install/grok.md.

  • OpenClaw runs tools natively in the plugin's Node.js process (no subprocess, by security-scanner requirement).

v0.10.0 adds a deterministic search layer over the entire vault — not just Brain/. The index lives at <vault>/.open-second-brain/brain.sqlite (SQLite + FTS5) and is fully rebuildable from the Markdown files. Semantic search is optional and pluggable: sqlite-vec + any OpenAI-compatible /v1/embeddings endpoint when configured.

flowchart LR
    subgraph Build [o2b search index]
        direction LR
        Walker[walker<br/>vault-relative paths] --> Chunker[chunker<br/>structural split + token packer]
        Chunker --> Store[(SQLite store<br/>documents / chunks / links / embeddings)]
        Chunker --> Links[extract wikilinks /<br/>markdown links / tags]
        Links --> Store
        Store --> FTS[(chunk_fts<br/>FTS5 BM25<br/>content + heading_path)]
        Store -.optional.-> Vec[(chunk_vec<br/>sqlite-vec)]
        Chunker --> Ents[extract entities<br/>structural / Unicode]
        Ents --> Store
        Texts[chunk content] -. on --embeddings .-> Provider[OpenAI-compat<br/>embeddings provider]
        Provider --> Vec
    end

    subgraph Query [search]
        direction LR
        Q["o2b search '<q>'<br/>brain_search"] --> KW[FTS5 keyword<br/>limit × 3 candidates]
        Q -. semantic on .-> SemQ[provider.embed query]
        SemQ --> SemS[vec_search<br/>limit × 5 candidates]
        KW --> Ranker[ranker<br/>BM25 + cosine + link<br/>+ recency + entity boost<br/>+ why_retrieved]
        SemS --> Ranker
        Ranker --> Trav[link-graph traversal<br/>retrieve-then-walk]
        Trav --> MMR[MMR diversity rerank]
        MMR --> Results[ranked results]
    end

Key behaviours, all driven from Brain/_brain.yaml-free search_* / embedding_* config keys:

  • Chunker. Two passes: a structural split (headings, fenced code, lists, tables, frontmatter), then a token-budget packer with overlap. YAML frontmatter becomes synthetic chunk_index: 0 so tags: and other front-matter fields are searchable through FTS5 alongside the body.

  • Store boundary. src/core/search/store.ts is the single SQL home; every other module talks to it through a typed surface. WAL mode for concurrent reads - journal mode DELETE instead, announced once on the write open, when the index file sits on a remote filesystem (an index already in WAL keeps it while another connection has the file open, since leaving WAL needs exclusive access: the read open stays silent, the write open says so once and retries on its next open) - proper-lockfile on the index path for writer exclusivity (three attempts, 1 s backoff, then INDEX_LOCKED).

  • Embedding providers (v0.36.0). embedding_provider selects openai-compat (remote /v1/embeddings), local (an offline feature-hashing embedder: token unigrams + character trigrams hashed into a fixed unit-normalised vector, no cloud / key / model download), disabled, or a name registered through o2b search provider add (which resolves to openai-compat config after the built-ins). One signature.ts kernel canonicalises <provider>:<model>:<dimension> and prices it; embedding_cost_gate_usd refuses an over-budget run unless --force-cost.

  • Embedding spend (v1.72.0). A price quote names its source: builtin (the frozen table; the local embedder is builtin and free), operator (the embedding_price_model / embedding_price_usd_per_mtok pair, which wins over the table for the model it names) or unknown. One spend plan owns the pending census (optionally scoped by path prefix), the model resolution, the token estimate, the quote and the gate verdict. The embedding phase, the maintenance preview, the backfill dry run and the search status refresh estimate all read it, so what is announced, refused, spent and receipted is one computation. An unknown price stays unknown (null, printed price unknown) rather than reading as $0, and under a positive gate it refuses with EMBEDDING_COST_UNPRICED unless forced. Price is never part of the embedding identity.

  • Vector carry-over (v1.72.0). Replacing a document's chunks keeps the stored vector of every new chunk whose content hash matches an old chunk of the same document (duplicates match as a multiset), as long as the old row's model and dimension equal the identity index_state records; nothing is carried while that identity is unrecorded. Only the vectors not carried are purged, and the index run reports the count as embeddingsReused.

  • Fusion modes (v0.36.0). search_fusion_mode is linear (default, the weighted sum below) or rrf - Reciprocal Rank Fusion, which scores each candidate Σ w_lane/(search_rrf_k + rank_in_lane) across the keyword and semantic lanes, min-max-normalised to [0,1]. RRF ignores the configured search_keyword_weight / search_semantic_weight - those calibrate two score magnitudes and this fusion reads none - but w_lane does carry the per-query intent profile (and the learned recall weights), so a quoted phrase favours the lexical lane in both fusion modes. A query that declares no intent weights both lanes 1, which is the classic weightless term. RRF replaces only the relevance term; every boost below still applies, and linear is bit-identical to pre-v0.36.0 ranking.

  • Ranking. final_score = clamp01(keyword_weight·norm_BM25 + semantic_weight·cosine + link_boost + recency_boost + entity_boost) (linear mode; in rrf mode the first two terms are replaced by the fused reciprocal-rank relevance). BM25 is min-max-normalised within the candidate set; cosine is 1 - L2² / 2 on unit-normalised vectors; link boost rewards candidates that other candidates reference via [[wikilink]] / markdown link (capped at 0.03) or share a tag with (capped at 0.02); recency is a configurable Weibull decay curve on the content's authoring instant — authored_at when the document declares one the reading clock can already have observed, and storage mtime otherwise, so a batch of historical conversations imported today ages from when it was said rather than from when it was written to disk (search_recency_shape / search_recency_scale / search_recency_amplitude, v0.20.0), defaulting close to the prior step function and flooring effectively-stale content to zero; entity boost (capped at 0.04) rewards proper-noun overlap between query and chunk. Every result carries a why_retrieved list of the layers that fired.

  • Recall layers (v0.13.0). After ranking, link-graph traversal walks outbound wikilinks from the top hits and merges in related documents scored parent·hop_decay^hop (bounded by search_max_hops / search_max_expansion_per_hit); then MMR reranks the pool with a deterministic token-set similarity so near-duplicates do not crowd the top (search_mmr_lambda, 1 disables). An opt-in cap on the pool keeps one group from filling the window the caller draws from (search_pool_max_per_session, default 0 - off; since v1.83.0): a row's group is its source import session (session_ref on imported signal notes, session_id on handoff notes) or, when a note names neither, its own path - all chunks of one note form one group, so bench session notes and multi-chunk notes are capped too. The cap runs after the caller's own filters, so only filter-passing rows consume a group's quota and the cap never surfaces a row those filters would withhold; it widens the pool rather than truncating it; a value at or above the query's resolved pool width behaves as off; and when its drops leave the window under-filled, one wider assemble pass re-runs so the window refills from the groups the cap did not exhaust. A vault whose notes each fit in one chunk is byte-identical with the knob on or off. Header-anchored chunking indexes each chunk's heading breadcrumb in a dedicated FTS column (weighted below content) so a mid-document chunk keeps its topical anchor. Entity and heading layers populate on the next reindex.

  • Optional cross-encoder rerank (opt-in). A learned final reader step that re-scores the top-K fused candidates jointly against the query, appended after every heuristic rerank. Off by default (search_rerank_enabled); when on, it resolves an OpenAI-compatible /rerank endpoint (search_rerank_base_url / search_rerank_model / search_rerank_env_key, or a profile registered via o2b search rerank-provider add) and re-orders the search_rerank_top_k candidates by the returned relevance, promoting a genuinely-relevant hit the heuristic ranker placed deep. Zero HTTP cost and byte-identical ordering when disabled; a request-time endpoint error degrades to the heuristic ordering (a rerank_degraded: warning, never a throw). Since v1.66.0 the same failure is also recorded in the retrieval trail as rerank-provider-unavailable with a typed detail.category, and such an answer is never written to the query cache, so a transient endpoint failure is retried on the next identical query. A configured model whose announced decommission date has passed is not called at all (rerank-model-sunset). Most valuable on the thorough profile.

  • Query plan + recall economy (v0.20.0). A pure structural pass classifies each query's intent (neutral / exact / entity / broad) from its shape - quoted phrases, FTS wildcards, wikilinks, entity-token share, token count, no language word lists - and emits a bounded weight profile that re-weights the ranking layers (on by default, search_intent_enabled; neutral is bit-identical). Opt-in synonym expansion (search_synonym_enabled) broadens recall by OR-ing in terms that co-occur across the top candidates' own content - language-agnostic, suppressed for exact intent. An opt-in persistent query cache (search_cache_enabled, search_cache_ttl_seconds) serves an identical request while the corpus generation (embedding model + dimension + schema + a content-reindex revision) is unchanged; cache reads and writes are best-effort. brain_context_pack additionally accepts per-memory and total character caps, and brain_pre_compress_pack returns a budgeted top-preferences-plus-active.md addendum for pre-compression injection.

  • Score-aware tail trim (config-gated). With tail_trim_context_pack: "true" in the machine-local main config (or the env override OPEN_SECOND_BRAIN_TAIL_TRIM_CONTEXT_PACK; the default is off), brain_context_pack runs one score-aware trim over the unified tier-and-budget walk whenever the call carries a query: the top three delivered items are immune, any later item whose query-term coverage - the fraction of the query's tokens it carries - is at least 0.8 of the top delivered score is kept (it restates the question's terms nearly as fully as the top hit), and the rest drop whole with a tail-trim skip reason - never partially re-cut. The freed budget is then offered back to over-budget candidates in priority order, the only path by which a page can cross tiers, spending the tokens the trim freed and no more, and never reordering or dropping the synthesized attention item. The response carries a conditional tail_trim block (dropped, kept_by_query_terms, rescued, tokens_saved) only when the trim ran. With the key off - the default - packs are byte-identical, and the packing_policy branch never trims.

  • Semantic policy. Implicit semantic (config default) warns and falls back to keyword-only when sqlite-vec is unavailable, the key is missing, the provider is down, or no embeddings exist yet. Explicit --semantic / semantic: true on infrastructure failure throws a typed SearchError so a misconfigured run cannot hide. The data-state case (zero embeddings) always warns and skips — running o2b search index --embeddings is the right answer there.

  • Semantic belief order (v1.72.0). brain_context_pack with query_mode: "semantic" orders the curated belief notes by meaning rather than by shared words. The query is embedded once (with the query prefix, through the configured provider), and each belief note scores the maximum cosine over its stored chunk vectors; nothing new is embedded for the notes themselves. Only vectors written by the configured model at the query's dimension count, and the scores are read only for candidates that survive the reach filter. A note without a usable vector sorts after the scored notes of its tier and is named in the response's semantic.unembedded. The mode refuses by name when the semantic tier is blocked, when sqlite-vec is not loadable, and when no kept candidate has a usable vector (BELIEF_VECTORS_MISSING, naming o2b search vector-backfill --path Brain/preferences/ --path Brain/retired/ --apply). The query embed is disclosed (model, price source, tokens, estimate); a remote caller's embed is refused with EMBEDDING_COST_UNPRICED when the model has no known price and embedding_cost_gate_usd is positive, while a local caller's embed is not gated, as for search.

  • One query-embed gateway (v1.73.0). The search lane and the semantic belief order ask the same gateway whether, and with what text, a query is embedded. It applies the caller's reach and the price gate before any provider exists (a caller that is not local, under a positive embedding_cost_gate_usd, on an unpriced model, is refused), then fits the query, instruction prefix included, to the effective input window: embedding_input_window_tokens, then the curated model table, then unknown, which cuts nothing. The cut is at a code-point boundary under the ceiling token estimate, so it never sends more than the window and treats every language alike. A refused or cut query is disclosed by trail code (semantic-cost-unpriced, semantic-query-truncated, and semantic-query-empty-fit when the instruction prefix alone fills the window). A refused answer is never cached; a cut one is cached under a key that carries the effective window and the query prefix, so a changed window re-keys it.

  • Atomic reindex. o2b search reindex writes to brain.sqlite.new, renames to brain.sqlite, and keeps the previous file as brain.sqlite.bak. If the main file is missing on open and a .bak exists, it is auto-restored with a stderr notice.

  • Embedding model fingerprint. Model + dimension are recorded in the index. Changing either drops the embeddings and chunk_vec tables on next open, logs one line, and preserves chunks and chunk_fts; the next o2b search index --embeddings repopulates vectors.

The MCP brain_search tool returns at most 50 results with each chunk's content capped at 600 characters - a window centred on the first significant query term the chunk contains, marked with an ellipsis on whichever side was cut, and the head of the chunk when it contains none; diagnostic score components (keywordScore, semanticScore, linkBoost, recencyBoost) are intentionally absent from the MCP shape — they live in the CLI's --verbose output only, to keep the agent context small. Index-management verbs (index, reindex, check) are CLI-only.

Since v1.46.0 both surfaces also report why an answer narrowed. A search that returned fewer rows than it might have, or none at all, carries a retrieval_trail naming the lanes that degraded and - when nothing degraded and nothing matched - what the corpus can honestly be said to hold. The codes are a closed vocabulary and the human transcript names the cause on its no-results line instead of printing a bare (no results). The vocabulary is in cli-reference.md.

Full design and migration notes: docs/plans/2026-05-16-brain-search-design.md and the matching implementation plan docs/plans/2026-05-16-brain-search-impl.md.

Keeping the index current (since v1.76.0)

The index is kept current by the reads that need it. When a search or a session start finds last_indexed_at older than search_freshen_interval_s (60 s by default), it answers from the index as it is and starts one detached, low-priority incremental run (o2b search index --freshen); an exclusive claim next to the index keeps concurrent readers from starting one each, and a failed run backs off from one minute to an hour. Nothing stays resident: with no agent working, nothing indexes. Two indexer changes make frequent runs cheap: a run that changed no document skips link and alias resolution, and a changed document keeps every chunk whose position and content did not change in place (row, full-text entry, entities, vector), so appending to a 1 MB daily log costs about 0.2 MB of writes instead of about 9 MB. Details: "Freshen on read" in docs/cli-reference.md.

Workspace reach and proactive insight (since v0.38.0)

Two suites extend where the Brain can be reached and what it volunteers.

Workspace reach: a .o2b-vault.json pointer file written by o2b brain project link binds any project directory (repo, monorepo package, worktree) to its owning vault; resolveVault walks up from the working directory and honours the nearest pointer after the VAULT_DIR env override and before the profile chain, so commands run from a linked tree resolve the right Brain with zero per-command flags. External vaults attach as read-only recall sources (o2b brain source add), and o2b search <query> --global unions the active vault, profile vaults, and sources with per-result origin labels - reads only, never a write or an index build inside an external vault. For shell-first use, o2b brain profile materializes a compact Brain/profile.md digest plus a .o2bfs root marker, and o2b brain sgrep answers with grep-shaped path:line: lines.

Proactive insight: o2b brain trigger scan converts semantic-health and retention findings into Markdown trigger records under Brain/triggers/ with a strict lifecycle (pending, delivered, acknowledged, acted, dismissed, expired, suppressed). Stable cooldown keys make scans idempotent, and the morning brief delivers pending triggers at most once per cooldown window - the Brain remembers what the operator already saw, dismissed, or acted on instead of rediscovering it every run.

dismissed and acted carry a clock: after trigger_cooldown_days the same finding may return. suppressed carries none, and it is the answer for a finding the operator has judged structurally benign - o2b brain trigger suppress <id> silences that cooldown key indefinitely, from any state including an expired or already-acted one. It is reversible: suppressing stamps the status it interrupted and leaves the delivery and resolution instants untouched, so unsuppress restores the original state together with its original cooldown arithmetic rather than reconstructing them. A suppressed trigger is terminal, so it is hidden from list, shown in history, and never reaches the morning brief; list prints how many triggers are currently suppressed so silence is always accounted for. Silence is also audited: a scan whose candidate an existing record silenced - suppressed, cooling down, still open, or dropped by the per-kind cap - increments occurrences and stamps last_seen_at on that record, so a suppressed finding that keeps firing is distinguishable from one that never fired again. The count is per scan, not per candidate: one scan seeing the same finding twice counts once, and a candidate silenced before any record for its cooldown key existed has no ledger to write to and is reported in the scan's skipped list instead.

A record nobody can parse is confined to itself. A hand-edited field is still refused rather than degraded, but the refusal now names the file and the field and stops there, so list, history, the brief, delivery, a new scan and the transitions all keep working on the records that read cleanly - the operator can still dismiss or suppress a healthy trigger while a sibling is broken. Every read reports the records it could not read next to the ones it could, and the morning brief says the queue is unreadable rather than rendering the pending-trigger section as absent, which used to be indistinguishable from an empty queue.

o2b brain deep-synthesis <topic> builds a deterministic topic dossier (agreements, contradictions, stale claims, knowledge gaps) and o2b brain ideas ranks next directions from open loops; both can enqueue their findings as triggers. Opt-in recall-gate telemetry records every automatic-recall decision with a hashed prompt for tuning, never the raw text.

Memory observability (since v0.39.0)

The continuity store is the Brain's flight recorder, and the Memory Observability Suite turns it into a contract. Every new record is stamped with a contract-wide schema version (o2b.continuity.v1); legacy records read as v1, nothing is migrated, and the dedup id deliberately excludes the stamp so identical records keep identical ids. Gated telemetry surfaces (context-pack receipts and telemetry, pre-compress, search, the recall gate) route through one lazy emit kernel: with the gate off the payload thunk never runs, and a broken continuity store can never fail the operation it was observing. A read-model normalizes stamped and legacy records identically, drops private records by default, and honours a caller-declared record scope - for the four consumers that read through it. Most readers call the store directly and see raw records; the split is asserted in tests/core/brain/continuity/reader-census.test.ts and detailed in docs/observability.md.

Two consumers ship with the suite. o2b brain continuity export renders the store as standard trajectory formats - ATOF JSONL events or ATIF v1.7 documents (one per session, memory-layer events marked as deterministic dispatch) - so recall traces feed existing replay and eval tooling. o2b brain bench memory measures recall quality against disposable fixture vaults: checkpointed phases with resume by run id, deterministic no-network evaluation, and a report that keeps quality, latency, and context cost as separate families so a ranking change cannot hide a token-cost regression behind a quality win. The full contract - every event kind, its gate, correlation ids, payload safety, fail-open rules - lives in docs/observability.md.

Project history as memory (since v0.40.0)

A linked project's git history is durable project memory, and the Project History Suite makes it queryable without the repository present. o2b brain git ingest <repo-path> walks a worktree with a sanitized read-only reader (fixed argv, full-40-hex sha validation on everything a caller could supply) and lands commits, tags, and release ranges as structured records in Brain/projects/git/<repo-key>/commits[.<deviceId>].jsonl (one shard per device, merged by record time on read, one copy of each commit and of each tag even when two devices ingested the same history). Typed edges are record fields - touched files, author, the carrying release attributed by chronological tag ranges - and a watermark bounds every later run to <last>..HEAD, so incremental ingest is duplicate-free by construction. A force-pushed or tampered watermark degrades to a reported full re-scan, never to silent loss. Each repo gets one deterministic digest note (releases, recent commits, hot files), so full-text search can discover the history, while o2b brain git find answers "why and when did this file change" from the store alone.

Decision-shaped commits become first-class memory through o2b brain git mine: deterministic heuristics (conventional breaking markers, BREAKING CHANGE footers, word-boundary decision keywords, revert shape) surface draft ADR candidates under Brain/decisions/candidates/ with matched-signal provenance. A candidate's identity is its commit sha: re-runs never duplicate, and an existing file is never touched, so operator curation owns the draft from the moment it exists.

Architecture knowledge gets the same treatment via o2b brain architect <project-path>: a scanner that uses the built-in runtime only, no dependency, derives structural facts (module layout, language mix, entry points, manifests, test layout - no LLM, no network) and renders an overview plus per-module notes under Brain/projects/arch/<repo-key>/. Generated content lives between paired <!-- o2b:begin <id> --> / <!-- o2b:end <id> --> sentinels: regeneration replaces only generated bodies, operator prose outside regions survives byte-for-byte, and corrupted markers fail closed before any write - every note of a run is decided before the first one is written, so an abort leaves no half-refreshed prefix. The tree is walked exactly once, and the walk enters no dot-directory and no build or dependency output (node_modules, dist, build, out, coverage, vendor, target, venv, __pycache__); architecture notes describe source, not tooling state. A run reports its two stages - walk, a counter with no denominator, and render, which knows its note count - through the progress spine, and honours a safeguard deadline at each directory read. The same release also closes the observability gap v0.39.0 left open: brain_query now emits opt-in recall telemetry with a kind-only payload, so the supplied preference id, topic, or timestamp never lands in a continuity record.

Since v1.68.0 the scan reads dependency manifests at the project root and at each detected module: package.json, pyproject.toml (PEP 621 and Poetry), Cargo.toml and go.mod. pom.xml, build.gradle, Gemfile and composer.json are detected and reported unsupported by name. The walk skips symlinks, so a manifest that is a symlink is not read. Every manifest gets one status - read, malformed (with a fixed reason such as invalid JSON or invalid TOML, never the manifest's own text), unreadable (with the error code, or because it is not a regular file or is larger than 1 MiB, which is not read) or unsupported - and one bad manifest never aborts the run. The overview's dependencies region lists every manifest of the root and the modules with its status, and the runtime dependencies they declare per ecosystem, leaving out a name that is the manifest name of exactly one other module (those edges are drawn in module-dependencies; a name two modules share binds no edge and stays listed), one canonical name each (PyPI names normalised per PEP 503, a renamed Cargo dependency by its real crate name), and counts the dev, build, optional, peer and indirect groups on one line per ecosystem instead of listing them. A declared name that is not a plausible package name (one holding a line break, a space or a bracket) is not written into a note: it is counted on the same line as unrepresentable. The project's name, version and description are written on one line, with [[ escaped so they cannot open a link. The project name comes from the first root manifest read that names a project, in the order package.json, pyproject.toml, Cargo.toml, go.mod.

A module whose manifest declares a runtime dependency on the manifest name of exactly one other module gets a declared edge; a name two modules carry binds nothing. The edges render in their own module-dependencies region (a Mermaid diagram under its own claim: declared by manifests, not measured from imports), in a dependencies region on each module note, and as a depends_on frontmatter key on the module note, a YAML list of wikilinks to the target module notes, which the indexer turns into typed depends_on links. A module whose directory name holds a character a link cannot carry (a control character, [, ], |, # or ^) is named once on the overview's module list and left out of every link and edge. The generator owns that one key: it rewrites it on every run, removes it when a module has no edge, overwrites any value typed under it, and never touches another frontmatter key. The module-map region stays containment only. On an existing overview or module note the new regions are appended at the end of the note, once, so the first run after an upgrade reports those notes updated.

Since v1.69.0, when modules declare edges but every one of them touches such a module, the module-dependencies region says so in a fixed sentence and lists the dropped edges as code spans instead of claiming that no module depends on another; with some edges drawn, one line under the diagram names the dropped ones. A module note names the modules it depends on but cannot link on a Not linked: line, and its depends_on key still holds only links.

The agent write contract (since v0.41.0)

The Brain core stays deterministic - no LLM ever runs inside it. The write-session protocol is how that rule survives contact with agents that need to PROPOSE structured artifacts: schema-typed notes, handoffs, curated summaries, panel deliberations.

A calling agent opens a session and receives a JSON envelope: what to generate (prompt), what shape it must satisfy (schema_hints), and what already occupies the target (existing with byte length, content hash, and first heading). The agent generates and submits; the Brain validates fail-closed. A failing submit returns machine-readable {code, path, message} errors plus a compact correction prompt - and the session keeps its state, so the agent fixes and resubmits without losing the target, the schema, or its attempt budget (default 3, then terminal failed). A clean submit commits atomically, or parks at needs-review when the session was opened with the operator gate. Sessions are plain JSON files under Brain/.sessions/write/ with lazy TTL: they survive process restarts, sync with the vault, and expire on read instead of by daemon.

Three rules are enforced at the commit boundary, not by convention: create intent never overwrites an existing file, merge appends a session-stamped delimited section without touching existing bytes, and a target outside the agent page lanes (sources/, reports/, distillations/, notes/, decisions/panels/ under Brain/) is refused before a session even opens, then again at commit against where the bytes actually land. Every terminal transition - committed, failed, abandoned, approved - lands exactly one write-session audit event in the Brain log.

The decision panel is the first consumer of the kernel: persona steps (operator-curated notes under Brain/personas/, or the built-in technical / strategic / risk / user-experience set) walk in declared order, the synthesis step sees every accepted answer, and the committed decision note under Brain/decisions/panels/ records who argued what through which lens. The Brain contributes sequencing, validation, and the commit; every word of analysis comes from the calling agent.

Two adjacent seams complete the contract. The memory backend boundary (memory_backend config key) routes memory import through a format-adapter registry - the Claude Code adapter is the default and byte-identical to the pre-seam behavior, and a second runtime becomes one self-contained module. The shared namespace (shared_namespace config key) mirrors explicit remember-writes into a second vault after the primary write succeeds, attributed by agent and origin vault; mirroring is fail-soft by contract, so a broken shared vault degrades to a reported mirror: "failed" and the primary write never notices.

Time-aware recall and activation (since v0.42.0)

Recency used to be one Weibull curve over file mtime: a constantly-useful preference and an untouched note of equal age ranked identically, and since/until filters confused when a file was written with when its content happened. The Time-Aware Recall & Activation Suite splits those concerns into deterministic, bounded layers.

Activation. Every CLI/MCP search that surfaces results records one small JSON event under Brain/search/activation/ - the query as an FNV-1a hash (never raw text) plus the surfaced paths. The derived activation state is a pure fold over the retained events (delete it, refold, identical), and at query time each candidate's stored strength decays by a content-type half-life: preferences, decisions, and antipatterns never decay, projects fade over ~120 days, handoffs and sessions over ~30, plain notes over 60. The result is a capped activation boost that keeps habitually-recalled memories hot without ever floating an irrelevant one. Recording happens AFTER ranking at the orchestrator edge - a query is never affected by its own recording, cache hits never record, and a vault with no events ranks bit-identically to before. Documents repeatedly surfaced together also accumulate co-access pairs in the same fold; when companions land in one candidate pool they reinforce each other with a capped co_access boost (single co-occurrences are noise and ignored).

Freshness trends. The dream refresh classifies every preference's evidence time distribution - recent 30 days against the prior 30 - into new | strengthening | stable | weakening | stale and stamps the label into frontmatter. Recall reads the stamp off preference pages and multiplies the relevance portion (strengthening 1.05, weakening 0.93, stale 0.85), so a rule that keeps earning applied events outranks one drifting toward retirement. The belief-evolution envelope carries the live classification with the windowed counts.

Event time over storage time. A document declaring valid_from / valid_until passes a since/until filter when its validity interval OVERLAPS the query window - storage mtime is only the fallback for documents without event time. An old file describing yesterday's migration window is found by --since 7d; a freshly-synced file describing a long-closed event is not. With a time range active, link-graph traversal applies the same discipline: an expansion document bridges in only when its event time falls within a padded neighbourhood of the window (default 7 days), scored by temporal proximity with a temporal_bridge reason - causes and consequences, not arbitrary link leakage.

Two-pass recovery. Keyword matching is implicit-AND, so a multi-term evidence query whose terms live in different documents can return nothing. In evidence-pack mode that dead end now triggers exactly one broadened retry - the first significant term stays the base group, the rest OR in as alternatives - and the recovered results flow through the normal ranking and a recomputed evidence pack, labelled second_pass with a secondPass envelope report. Plain searches never broaden; search_two_pass_enabled: false turns the retry off entirely.

Entity truth and the self-improving dream (since v0.43.0)

The Brain recorded facts but had no notion of which one is true NOW: two contradictory claims about the same entity coexisted silently, dedup could merge notes about different people, and the dream pass trusted self-reported evidence with no outcome signal. The Entity Truth & Self-Improving Dream Suite closes both gaps with the same fold-over-append-only discipline the activation store proved.

The claim ledger. Claims are append-only JSONL events under Brain/truth/ (one shard per device, Syncthing-safe), each a versioned (entity, aspect, value, source, agent, ts) line parsed fail-closed. A deterministic, order-insensitive fold projects them into addressable slots: the current value of employer of alice mason, every superseded prior value with provenance, and value_conflict records when two distinct values land within the conflict window from independent sources - resolution is always ask_user, never automatic, while a later value outside the window supersedes silently because that is fact evolution, not contradiction. Atomic-fact decomposition feeds the ledger deterministically (markdown structure plus a sentence splitter with an abbreviation guard - no model calls), the actor-framed quantity family captures I spent 120 USD shapes, and aggregation sums only exact (entity, action, unit) tuples so nearby numbers never pollute a total. The same ledger powers two guards: merges refuse to collapse claims about disjoint people/orgs, and synthesized notes asserting entities their cited sources never mention are flagged as contaminated. Two agents independently writing claims about one entity within 14 days surface push-mode through the trigger queue.

The self-improving dream. Apply-evidence events now optionally carry a downstream outcome (success | failure | unknown); a rule whose applied events keep co-occurring with failures looks confirmed while actively hurting, so the dream refresh stages an outcome_regressions finding with a deterministic confidence penalty - staged for the operator, never silently retired. Failed approaches persist as markdown dead-ends (Brain/dead-ends/) that FTS indexes like any note, so recall surfaces "avoid X" beside "prefer Y". Inbox signals rank by surprisal - mean embedding distance to their nearest indexed neighbours, computed over the existing sqlite-vec index with zero provider calls - so review attention lands on genuinely new signals first. The weekly synthesis nominates the single most-developable note of the window, and the foresight fold projects forward for the first time: recurrence cadences yield next-due dates, recent open commitments and questions surface with sources, and an empty vault folds to an empty envelope.

Write-time integrity and governance (since v0.44.0)

Until now almost nothing about a write into the Brain was declared: any frontmatter field could be hand-edited including the join keys the index depends on, labels were free-form strings that fragment, typed relations connected any two page types, credentials could only be redacted out of memory but never safely used, and the heavy dream/reindex pass could land on top of a live interactive recall. The Write-Time Integrity & Governance Suite makes the schema pack the single declarative ontology and enforces each contract at its existing seam - no new framework layer.

The ontology lives in the schema pack. Four additive fields - labels (controlled-vocabulary dimensions with fixed enum values), link_constraints (allowed source->target page-type pairs per link type), attributes (per-type fields with natural-language descriptions), and frontmatter_tiers (per-kind field tier overrides) - parse, render, and mutate through the same audited machinery the schema pack always had. A vault that declares none of them behaves bit-identically.

Enforcement at the seam, fail-closed with guidance. Label assignment rejects an unknown dimension or value WITH the declared vocabulary in the error; attribute validation lists the declared fields with their descriptions, so the error message teaches the domain language instead of just refusing. Link constraints enforce at index materialization: each page's declared type is persisted, a post-pass recomputes every typed edge's blocked flag from the current pack on every run - a violating edge falls back to an untyped link instead of feeding typed-relation recall, schema lint lists each violation, and removing the constraint restores the edges without touching any file.

The tier guard never fights the editor. Four tiers (identity / system / business / user) map onto the fields framework writers actually emit; the preference files' _-prefix convention becomes an explicit system-tier rule. Framework rewrites merge through the tier model - a field a human added by hand survives, and a changed identity join key throws instead of being silently re-accepted - while hand-edits in Obsidian or Vim are never write-denied: the index pass snapshots identity fields, stages a drift finding the snapshot never absorbs, and the operator resolves it through tiers check | restore | accept. The doctor warns with the open count.

Custody, not just redaction. Secrets live as per-value AES-256-GCM ciphertext beside a 0600 keyfile in the vault-local state dir (.open-second-brain/secrets/), never returned by any surface. That directory carries a .gitignore so a vault that is a git repository never commits it; Syncthing does not read .gitignore, so a vault inside a Syncthing folder needs /<path-to-vault>/.open-second-brain/secrets in the folder's root .stignore - o2b brain doctor (secrets-sync-exposed) and secret set warn with the exact line when it is missing, and never edit .stignore themselves. An agent uses a credential by running an allowlisted command with the value injected into the subprocess env; captured output comes back with the value scrubbed as a known literal, the material enters only via the operator's stdin, and every operation lands a no-values audit record under Brain/log/secret-custody/. The threat model is stated honestly: this protects against context leakage, git exposure, and casual reads - sync exposure only once the syncer's own ignore list covers the directory - and not against root.

The keyfile can be wrapped under a passphrase. o2b brain secret unlock verifies a passphrase (read from stdin or --passphrase-from-env, never argv) and - on a store whose keyfile is still raw - wraps the 32-byte key into a versioned scrypt envelope at the same path, then holds the unwrapped key in memory for that one process only; lock overwrites and drops it. There is no daemon: every other CLI invocation and the MCP server stay locked, and a key-bearing operation over a locked store refuses with a named error whose remedy is the unlock command (the refusal's prose carries no paths - the structured field does, for the surfaces allowed to name them). Every unlock and lock lands a no-values custody record. A credential reference, $secret:NAME, resolves store-first with environment fallback at the use sites that already name it (the exec allowlist, the embedding and rerank keys, the decision-model key, the Telegram capture token, the research keys); a locked store surfaces the named refusal rather than silently answering from the environment. secret export writes every entry as one passphrase-encrypted bundle to an operator-named file - the values re-encrypted under a key the passphrase derives, the names, env-var mappings and allowlists traveling in the clear past the shared egress redactor - and secret import decrypts and validates every entry (names, env-var mappings, allow patterns, all under the store's own rules) before writing anything. The honesty clause is the same for the keyfile envelope and for bundles: the passphrase is never persisted, logged or audited, and a lost one is unrecoverable - every stored value and every exported bundle stays unreadable forever, and nothing recovers them.

Heavy work waits its turn. The maintenance lane runs dream and reindex behind five gates: a tz-aware local-time window, a busy gate over recent recall telemetry, an optional host-pressure gate, and - since v1.85.0, when a storage cap is configured - a capacity gate that skips a vault past its storage.pause_percent line (since v1.86.0; the line defaults to the warn band, and interactive ingest keeps working until the critical tier refuses it outright) or measuring at its critical tier, before the failure-streak limit refuses a task that keeps failing; under them all sits an expiring SQLite lease in its own database so holding it never contends with the search index writer lock. Force bypasses the soft gates but never the lease, tasks run stale-first, and every attempt - including each gate refusal - lands in a bounded journal, because a lane you cannot audit is a lane you cannot trust.

A second brain's value compounds through connections, but until now the graph only knew edges someone wrote by hand, and recall quality was an unmeasured feeling. The Link & Recall Intelligence Suite makes the graph self-organizing and the recall measurable - deterministically, with every output reviewable before it touches a note.

Aliases resolve where edges materialize. Obsidian vaults lean on frontmatter aliases: for shorthand, but the search index resolved wikilink targets by exact path only - [[PA]] to a note titled "Project Alpha" stayed an unresolved string. The indexer now extracts every note's aliases into the index (schema v7) and a shadowing-safe post-pass materializes the target id: exact paths always win, a real document basename is never shadowed by someone else's alias, and collisions resolve deterministically. Traversal, backlinks, and link constraints all see the edge from then on.

Bridges surface the connections nobody wrote. Traversal follows existing edges; unlinked-mentions catches textual name-drops. The third kind of latent structure - two notes that are embedding-near but never name each other - is found by an orphan-first pass over the vec index: weakly connected notes get scanned before well-wired hubs, chunk KNN similarity aggregates to document level, and pairs that already share an edge stay out. The output is a PROPOSAL artifact (Brain/proposals/bridges.md), regenerated per run; nothing touches a user note until someone runs accept, which writes exactly one related: wikilink, idempotently, under the schema pack's link constraints. Dismissals persist so re-runs stay quiet.

Communities become visible structure. Deterministic label propagation (sorted sweeps, lowest-label tie-break, a hard iteration cap so bipartite stars cannot oscillate forever) discovers graph-wide communities; each one of four or more notes materializes a derived digest under Brain/clusters/ - members by internal degree, shared entities, link density. No LLM prose: synthesis belongs to the reading agent, the digest is a projection. Stale generated notes are removed on the next run; a hand-written file in the same directory is never touched.

Recall quality gets a number. A fixed query/expected-result dataset scores the live hybrid pipeline - hit@k and MRR per query and aggregate. The repository pins thresholds over a committed fixture vault with the deterministic local embedding provider, so a ranking regression fails CI instead of shipping silently; the same runner scores any operator vault on demand. And that number becomes an objective function: the self-tuner grid-evaluates a BOUNDED parameter space (keyword pool multiplier, traversal depth, learned weights, expansion) against it and persists the winner with every evaluated score and the dataset hash. Search applies the tuned point only under an explicit opt-in flag, re-validates it against the grid bounds on every read, and deleting the file restores defaults - the same replayable-fold philosophy as learned weights.

Expansion without a model. A bare query can become a structured lex/vec/hyde document locally: stopword-stripped lex terms (the FTS lane is implicit AND, so one stopword absent from the target note kills the match), an entity-context line anchored to the vault's own registry, one template hyde passage for embedding retrieval. Opt-in per call, never silently active, so cached queries and benchmark runs stay comparable.

Every pass reports to one place. Each feature appends one run-level record to Brain/metrics/<surface>.jsonl (o2b.metrics.v1 envelope) - the stable contract a dashboard reads without importing internals, documented in docs/metrics.md.

Stability and trust (since v1.0.0)

The first major release froze the public contracts - the advertised MCP tool surface, the CLI verb tree, configuration keys, the search schema ladder, and every on-disk schema string - under docs/stability.md, and spent its one breaking change on deleting the 18 hidden alias tools from the token-diet era. A stale client that calls a removed name gets a tombstone naming the exact replacement, and brain_doctor flags vault-side text that still references one.

Three hardening layers ship alongside the freeze. A cooperative safeguard gives every long pass (dream, reindex, bridge discovery, communities, the maintenance lane) a deadline checked at natural iteration boundaries: Bun runs SQLite synchronously, so the honest contract is to abort BETWEEN atomic writes rather than pretend an async cancellation. The staged dream pipeline turns the self-improvement loop inspectable before anything lands: stage persists the dry-run plan as a discardable bundle, validate recomputes it and reports drift, apply re-validates with the clock it runs with and then executes the one and only promotion engine - determinism guarantees the live run performs exactly the reviewed plan. And the reporting surfaces become self-comparing: with snapshots enabled, each digest / daily / weekly run persists a machine-diffable JSON snapshot and reports a deterministic "since last run" delta keyed on stable identities, never on array order. Presentation grows timezone awareness on the same principle the rest of the system follows - storage stays canonical UTC, conversion happens at the boundary, and an unconfigured vault renders byte-identically.

Checked quotes and capture scope (since v1.67.0)

A distillation page presents itself as a faithful record of its source, so two things it asserts are now checked or recorded rather than assumed.

Quotes are verified at write time. When an agent submits claims to brain_distill_source or o2b brain distill, every span between quotation marks is compared with the block the claim cites, or with the whole source when it cites none. The comparison is deterministic and language-neutral: quotation marks are recognised by their Unicode property, paired by position, and both sides are normalised the same way for the comparison only. A span that does not verify loses its quotation marks and is named in the result, so the page never shows an unverified quote; a caller that prefers a refusal passes strict_quotes and gets the registered error code quote_unverified with nothing written. The bytes checked are the bytes hashed into source_hash, read once.

Every page says how much of its source the vault holds. The capture scope has three values:

  • full-local: the source is a file this vault holds;

  • bounded-local: the source has no local bytes, but the page stores a verbatim excerpt the writer read, with its digest (excerpt_hash);

  • url-only: the page rests on a locator only - a URL, or a path with no file behind it.

The scope is stamped at write time on distillation, ingest summary and research report pages (capture_scope, or capture_scopes per source on a report), and only when it is not full-local, so pages over local sources stay byte-identical. An absent stamp means "full-local, or written before this release", and readers re-derive rather than assume: the default-on capture-scope hygiene detector re-classifies each cited source from its identity at scan time, without reading its bytes. A retrievable page of active knowledge - a distillation, an ingest summary, a research report or a canonical entity - whose every cited source is currently url-only gets a warning finding with proposed_action: review. A local source that was deleted therefore surfaces as url-only, a bounded-local page counts as backed only while its excerpt still matches its digest, and quarantined pages are skipped: they already carry the untrusted_source marker, which names the same condition (and which the retrieval trust gate excludes under search_trust_gate_mode: enforce or the legacy search_trust_gate_enabled: true; shadow only measures).

Non-Markdown sources (since v1.69.0)

Ingest used to be text only: a file that was not Markdown or plain text was counted per extension and set aside. One format registry now maps a file's extension to a format and each format to an extractor. Markdown and the lightweight markups are read as they are; CSV, TSV and HTML are extracted; PDF, the Office formats, EPUB, RTF and images are named but not extracted, so the ingest plan lists each such file with reason format-not-extractable and its format, the hook a caller that runs its own converter or OCR routes on. Open Second Brain adds no parser dependency and runs no external program for this.

The calling agent still writes the summary of a source. What the kernel adds is structure it can derive without judgement, written onto the summary page, because the summary page is what search indexes: the source file itself is not a Markdown page. brain_ingest_source does this by format, for a file in the vault, in the trusted lane, after the same reach check every reader applies, and reads the bytes once to derive the section and its source_content_hash.

For HTML, a linear scanner (no regular expression over unbounded input) reduces the page to text: comments, scripts, styles and embedded SVG or MathML are dropped, entities are decoded, a <private> region becomes its placeholder and no attribute value is ever kept. The headings become parts, each with its level, its trail of ancestor headings and its line span in the extracted text. Only the parts reach the page, under ## Parts inside one fenced block, so a heading that holds [[...]] or #word adds no link and no tag while full-text search still finds it. Each heading and the title are redacted (key=value credentials and URL userinfo) before they are capped, over their first 4,096 code units (a longer one ends in …), and in the parts list a backslash is written as \\ and a | as \|, so a heading cannot forge a line span. The text itself stays out of the page; o2b brain extract --json returns it.

For CSV and TSV, the first record is the header. Quoted fields follow RFC 4180 leniently, a semicolon export is recognised from its first record (at least two fields on semicolons and more than on commas, so a header such as name;"price, eur";qty reads as three columns), and an unterminated quote is refused by name with the record number. A leading frontmatter block in the source is skipped for HTML and tables alike, so it is never read as data. The rows are rendered as fenced plain text, not a Markdown table, in groups that each repeat the header and stay under one search chunk, so every chunk of the page carries the column names and a Table > Rows a-b heading path. Rows, columns, cell length and section size are capped, and every cut is named; only the first 4,096 code units of a cell are read for redaction, and a longer cell counts as cut. A <private> region is hidden before the records are split, so a region spanning records hides every row between its tags. A column whose header names a credential has its values replaced, and every other cell goes through the output redactor. Inside a cell a backslash, |, a backtick, a line feed, a carriage return and a tab are escaped (\\, \|, \` , \n, \r, \t), and every other control character (C0, DEL and C1) is written as \u{XXXX} with its code point in four uppercase hexadecimal digits, so no row line can open or close a fence and no escape sequence reaches a terminal. One limit of the chunk alignment remains, and it loses no content: a single row longer than one group's token budget forms a group of its own and can still be split across search chunks.

The redactor does not catch everything in a cell. It replaces whole columns under a credential-named header, key=value and key: value pairs, JSON entries, Bearer tokens, JWTs, private regions and URL userinfo. It does not redact a bare vendor token (an access key with a vendor prefix and nothing naming it), a base64 or other high-entropy blob, a PEM block inside a quoted multi-line cell, or a credential-like value under a header that does not name a credential. Bare tokens are left on purpose, because a token pass would also erase order ids, SKUs and hashes. Such a value reaches the summary page, full-text search and the embeddings, as it already sits in the source file in the vault.

A summary page that carries a derived section is at most as visible as its source: it takes on the source's own visibility tokens, so a reader that cannot read the source cannot read what was derived from it. When the page already had a visibility, it gets the audience both the operator and the source allow; when they share none, the page is withheld below local reach.

A Markdown or text source gets nothing new: its page is byte for byte what it was.

Brain store dual writes (phase 1 trial)

For setting up the store on one or more machines, syncing its partitions, the human view, moving an existing install out of the vault and the threat model, see the brain store guide. This section describes the phase 1 dual-write trial.

Open Second Brain is moving its machine data out of the vault into the brain store: an append-only folder outside the vault whose format is documented in FORMAT.md inside the store (source: src/core/store/FORMAT.md). In this phase the store is a trial. Nothing reads from it yet, and the vault tree under Brain/ stays the source of truth.

The store is created once, by an explicit command, on the first machine:

o2b store init                      # both partitions
o2b store init --partitions shared  # a machine that must not hold private records

It writes one store id into every partition it creates, and the format document into the root and each partition. Every other machine receives the partitions through sync; sync each partition folder as its own sync folder, never the store root. A second run refuses an existing store. A run that finds some requested partitions already present (a first run that stopped half way, or a machine adding private later) adds the missing ones with the same store id. A partition folder that holds store files but no store.json is refused by name rather than given a new id.

o2b store path prints where the store is on this machine: the store root, each partition and whether this machine holds it, the device-local state directory, and the folder the human view is rendered into (none when view_target is not set), each with the setting that put it there (env, config or default) and whether it exists. It creates nothing, so it also answers before o2b store init has run. second_brain_status carries the same answer in its store block, so an agent never has to know a path by heart.

The store_dual_write setting turns the trial on. It is off by default. Set store_dual_write: true in the machine config, or set the environment variable OPEN_SECOND_BRAIN_STORE_DUAL_WRITE=1, which wins over the config. While it is on, the existing writers also append the same data to the store:

Legacy writeStore record
A signal (feedback, inline and extracted signals)signal in events
A Brain log event (notes, apply-evidence, every other event)log-event in log
A preference write, retire or pinpreference-set in events
Apply-evidencepreference-evidence in events, besides its log event
Pinned context write, append, batch or clearpinned-set or pinned-clear in events
A row of a per-device ledgerledger-row in ledgers/<name>

Preference and pinned records carry who decided them. An operator running o2b decides as human; an agent calling a tool, a bulk import and the dream pass decide as agent (dream is automatic even when an operator starts it); a decision-model verdict is model. No legacy writer acts on a decision-model verdict yet, so model appears only when a caller passes it. Records of a page whose frontmatter reserves it as private go to the store's private partition. The rule is the one search and remote reads use: a visibility that includes private, or an informal marker such as access: private, confidentiality: secret or sensitivity: confidential. A page that exists but cannot be read counts as private. The apply-evidence log event, the audit rows and the edit-history rows about a private preference follow it into private.

Truth-claim ledger rows go to private when this machine holds that partition and to shared when it does not, since a claim can be about a note of any visibility. Audit rows follow the same rule: the preference audit trail and every reliability audit trail (hygiene, secret custody, hook audit, watchdog and the others under Brain/log/) go to private when this machine holds it and to shared when it does not. An audit row about a private preference goes to private only; a machine without that partition refuses it and counts a failed dual write, the legacy row is kept.

Rules of the trial:

  • With the setting off, nothing is written to the store and the legacy files are byte-identical to what they were before the store existed.

  • A store failure never fails the legacy write. The first failure in a process prints one warning; every failure is counted in dual-write.json in the device-local state directory (${XDG_STATE_HOME:-~/.local/state}/open-second-brain/stores/ on Linux and macOS, %LOCALAPPDATA%\open-second-brain\stores\ on Windows).

  • Dual writes never create the store. With no store at the root they are skipped, counted and warned about once; doctor says to run o2b store init.

  • The store needs a real device id. With O2B_DEVICE_ID set to the empty string, or when the device id cannot be resolved (for example an unreadable or unwritable machine config), the dual writes are skipped, counted and warned about once. A missing device_id is generated and saved to the machine config on first use, as for the sharded logs.

  • The store root comes from OPEN_SECOND_BRAIN_STORE_ROOT, then the store_root config key, then a per-user default (${XDG_DATA_HOME:-~/.local/share}/open-second-brain/store). A root inside the vault is refused.

  • A machine that holds only the shared partition refuses private records; that counts as a failure, and the legacy write is kept.

o2b brain doctor has a store section. It stays silent while the store is not in use (the setting is off and no store root is configured). When the store is in use it reports failed or skipped dual writes, a store that has not been created yet, a store root it cannot use, state digests that disagree between machines over the same records, devices that stopped publishing digests, record ids that arrived with different content, and records whose clock is more than one day ahead of now (per device, with the newest stamp). Such records are still folded, and they win ties on the later clock, so the fix is the clock of the named device.

Search and brain_changes catch the index up with the store before they read. To keep a burst of reads cheap, one process looks at the store at most once per second; a record another machine adds inside that window is found by the next read after it. The environment variable OPEN_SECOND_BRAIN_STORE_CATCHUP_INTERVAL_MS sets the window in whole milliseconds (0 looks on every read); any other value keeps the default.

Writers that edit preference files directly rather than through the preference writer (for example protection marks, expiration dates, the Claude memory import and batch writes) are not covered in this phase.

Safety properties

These are invariants of the system, not configuration to enable.

  • Filesystem-first. Every Brain artifact is a Markdown file with YAML frontmatter. cp -r Brain/ is a complete backup; tar -czf is a portable bundle.

  • Deterministic. The dream pass is a pure function of (signals, preferences, retired, log, configuration, current time). Given identical inputs and a fixed --now, two runs produce byte-identical output.

  • Idempotent. Re-running dream without new input or expired timestamps is a no-op. Safe to schedule at any frequency.

  • Atomic per-file writes. Every mutation goes through write-temp + rename; an interrupted run never leaves partial files.

  • Audit-traceable. Every state change emits a typed event in Brain/log/<day>.md plus a structured <day>.jsonl sidecar (machine readers prefer the JSONL and fall back to parsing the markdown for historical days). The log is append-only; the dream log entry for a run records exactly which preferences moved and why.

  • Reversible. Pre-run snapshots plus o2b brain rollback <run-id> let you undo any single dream pass.

  • Path-safe. Every writer routes through a vault-boundary check; Brain/ operations cannot escape the configured vault root.

  • Local MCP transport. o2b mcp is a stdio subprocess of the client by default. The optional --transport http binds 127.0.0.1, checks Host and Origin on every request against DNS rebinding, and refuses a non-loopback --host without --api-key (or OPEN_SECOND_BRAIN_MCP_API_KEY). Shutdown drains in-flight calls, see mcp.md.

  • Guarded injected context. Automatically surfaced Brain context passes a prompt-injection guard. With guardrails.untrusted_source_delimiting enabled in _brain.yaml, the context pack (brain_context_pack) wraps untrusted spans in a provenance-carrying delimiter instead of filtering them by a word list. The pre-compress pack (brain_pre_compress_pack) does not apply the delimiter yet and uses the guard only.

  • No LLM inside the algorithm. Semantic merging of similar but differently-slugged topics is left to external agents who can call the CLI / MCP surface directly — the dream pass itself only does counting and atomic file moves.

Sample lifecycle of one preference

A worked example spanning a hundred days of vault activity:

sequenceDiagram
    autonumber
    actor User
    participant Agent
    participant Brain
    participant Dream as dream pass

    User->>Agent: day 0 — "don't use internal abbreviations"
    Agent->>Brain: brain_feedback (neg, topic)
    Brain-->>Brain: sig #1 in inbox/

    User->>Agent: day 1 — same complaint
    Agent->>Brain: brain_feedback
    Brain-->>Brain: sig #2

    User->>Agent: day 3 — third complaint
    Agent->>Brain: brain_feedback
    Brain-->>Brain: sig #3 (threshold met)

    Note over Dream: day 3 dream pass
    Dream->>Brain: create pref-X status: unconfirmed<br/>(trial: 14 days)
    Dream->>Brain: move sig #1..3 → processed/

    Agent->>Agent: day 5 — produces public blog post
    Agent->>Brain: brain_apply_evidence applied

    Note over Dream: day 5 dream pass
    Dream->>Brain: pref-X status: confirmed,<br/>applied_count=1, confidence=low

    Agent->>Brain: days 6..30 — nine more applied evidences

    Note over Dream: day 30 dream pass
    Dream->>Brain: pref-X applied_count=10,<br/>confidence=high

    Note over Dream: days 31..120 — silence on this topic

    Note over Dream: day 120 dream pass (90 days past last evidence)
    Dream->>Brain: move pref-X → retired/<br/>reason: stale-no-evidence

At day 120 the rule has retired itself. The retired note keeps the full origin (the three signals, the confirmation timestamp, the ten evidence applications) so its history is auditable forever; only the active-rule attention budget is freed.

How a new vault gets bootstrapped

flowchart LR
    User[operator] --> Init([o2b brain init])
    Init --> Skel["Create Brain/inbox, preferences, retired, log, .snapshots"]
    Init --> Yaml[Render Brain/_brain.yaml with defaults]
    Init --> Manual[Render Brain/_BRAIN.md operating manual]

Brain/_brain.yaml defaults are sensible for most uses; tune them in place when real usage reveals different timing. Brain/_BRAIN.md is the per-vault contract for agents — agents read it at the top of any session that interacts with this vault, and o2b brain init --force re-renders it from the current template.

Where to go next

  • docs/observability.md — the observability contract: every log and continuity event kind, the always-on vs opt-in matrix, correlation IDs, payload safety, fail-open rules, and the o2b.continuity.v1 schema version with its evolution rule.

  • docs/architecture.md — layered system architecture beyond Brain (vault model, runtime adapters, configuration model).

  • docs/plans/2026-05-15-brain-observing-memory.md — the Brain design document, including the full file-format specs and test strategy.

  • docs/plans/2026-05-15-brain-roadmap.md — trigger-based roadmap of future capabilities (BRAIN-FUT-NNN entries).

  • docs/plans/2026-05-16-brain-search-design.md and docs/plans/2026-05-16-brain-search-impl.md — design and implementation plan for the v0.10.0 full-text search layer.

  • docs/mcp.md — protocol, schemas, lifecycle, and resource surface of the MCP server.

  • Brain/_BRAIN.md (in any initialised vault) — the operating manual for agents working with that specific vault.

512 files · 486 not served here

Advantages

  • Human-readable and editable memory stored as standard Markdown files in an Obsidian vault. evidence: README.md
  • Deterministic dream pass ensures rules are derived from repeated signals without requiring additional model runs. evidence: README.md
  • Supports reversible changes through snapshots and rollback capabilities. evidence: README.md

Limitations

Security

Risk level: Low risk

This brain runs

MCP servers (2)

  • .claude-plugin/plugin.json (open-second-brain): ${CLAUDE_PLUGIN_ROOT}/scripts/o2b mcp --harness claude-code
  • .claude-plugin/plugin.json (open-second-brain-writer): ${CLAUDE_PLUGIN_ROOT}/scripts/o2b mcp --scope writer --harness claude-code

Git hooks (2)

  • .githooks/pre-commit (pre-commit): runs on git pre-commit
  • .githooks/pre-push (pre-push): runs on git pre-push

Agent instructions that run or fetch something (46)

  • CLAUDE.md: bun run scripts/sync-version.ts
  • CLAUDE.md: CI gates on `bun run scripts/sync-version.ts --check` (the `validate` job, step "Verify manifest version sync").
  • CLAUDE.md: Run the `--check` form locally before pushing a version change.
  • CLAUDE.md: Bump `package.json` (and run `sync-version.ts`) on the feature branch and include it in the SAME pull request as the feature, before you push.
  • CLAUDE.md: Bump `package.json` to `X.Y.Z` and run `bun run scripts/sync-version.ts`.
  • CLAUDE.md: After changing a skill or `hooks/hooks.json`, run:
  • CLAUDE.md: bun run sync-plugin-mirrors
  • CLAUDE.md: CI gates on `bun run sync-plugin-mirrors:check` (the `validate` and `windows` jobs), and the pre-commit hook runs the same check.
  • docs/legacy-skills/agent-event-log/SKILL.md: Read-only queries (running `git log`, `o2b status`, `vault_health`, `second_brain_query`).
  • docs/legacy-skills/agent-event-log/SKILL.md: **Technical identifiers stay verbatim regardless of surrounding language**: command names (`bun test`, `o2b doctor`), file paths (`src/core/event-log.ts`), package and library names (`proper-lockfile`, `@anthropic-ai/sdk`), API symbols, error messages quoted from logs.
  • plugins/codex/skills/brain-memory/SKILL.md: Read-only inspection (running `git log`, `o2b status`, `vault_health`).
  • plugins/codex/skills/brain-memory/SKILL.md: After scan, the source line becomes `@osb✓ [[sig-...]]` so a second run is idempotent.
  • plugins/codex/skills/codegraph-partner/SKILL.md: Run these checks in order.
  • plugins/codex/skills/codegraph-partner/SKILL.md: No install talk.
  • plugins/codex/skills/codegraph-partner/SKILL.md: | | not_indexed (CLI present, no `.codegraph/` or `initialized: false`) | Offer one line: "run `codegraph init <path>` to index this repo".
  • plugins/codex/skills/codegraph-partner/SKILL.md: Whenever you run `codegraph init` in any repository on the user's behalf, immediately read that repo's `.gitignore` and append a `.codegraph/` line if one is not already present.
  • plugins/codex/skills/decision-model-setup/SKILL.md: That is the operator's command, and only the operator can run it: the key is operator-only, so the `brain_settings` tool refuses an agent's write to it with `operator_only_setting` on every machine, switched or not.
  • plugins/codex/skills/decision-model-setup/SKILL.md: Ask the operator to run the command;
  • plugins/codex/skills/decision-model-setup/SKILL.md: Does not install servers or download model weights.
  • plugins/codex/skills/embeddings-setup/SKILL.md: Every run that uses it prints one stderr warning per endpoint.
  • plugins/codex/skills/embeddings-setup/SKILL.md: If the install path missed it, force a rebuild:
  • plugins/codex/skills/embeddings-setup/SKILL.md: bun pm ls | grep sqlite-vec
  • plugins/codex/skills/embeddings-setup/SKILL.md: bun install --force
  • plugins/codex/skills/embeddings-setup/SKILL.md: When the index already exists and only some chunks lack vectors, price the work first with the dry run, then apply it:
  • plugins/codex/skills/embeddings-setup/SKILL.md: Does not invoke `brew install` on the user's behalf.
  • plugins/codex/skills/schema-author/SKILL.md: Preview the batch first with `schema_apply_mutations` (`dry_run=true`) or `o2b brain schema apply --dry-run --mutation ...`: it returns the pack that would result and its diff and writes nothing at all - no config write, no audit record, no lock file.
  • plugins/codex/skills/schema-author/SKILL.md: It does not run the two checks that exist only because the apply writes: the vault-identity write guard, and the atomic writer's re-parse of the rendered YAML.
  • plugins/codex/skills/schema-author/SKILL.md: Re-run lint/stats after mutation and report changed tokens plus any remaining findings.
  • skills/brain-memory/SKILL.md: Read-only inspection (running `git log`, `o2b status`, `vault_health`).
  • skills/brain-memory/SKILL.md: After scan, the source line becomes `@osb✓ [[sig-...]]` so a second run is idempotent.
  • skills/codegraph-partner/SKILL.md: Run these checks in order.
  • skills/codegraph-partner/SKILL.md: No install talk.
  • skills/codegraph-partner/SKILL.md: | | not_indexed (CLI present, no `.codegraph/` or `initialized: false`) | Offer one line: "run `codegraph init <path>` to index this repo".
  • skills/codegraph-partner/SKILL.md: Whenever you run `codegraph init` in any repository on the user's behalf, immediately read that repo's `.gitignore` and append a `.codegraph/` line if one is not already present.
  • skills/decision-model-setup/SKILL.md: That is the operator's command, and only the operator can run it: the key is operator-only, so the `brain_settings` tool refuses an agent's write to it with `operator_only_setting` on every machine, switched or not.
  • skills/decision-model-setup/SKILL.md: Ask the operator to run the command;
  • skills/decision-model-setup/SKILL.md: Does not install servers or download model weights.
  • skills/embeddings-setup/SKILL.md: Every run that uses it prints one stderr warning per endpoint.
  • skills/embeddings-setup/SKILL.md: If the install path missed it, force a rebuild:
  • skills/embeddings-setup/SKILL.md: bun pm ls | grep sqlite-vec
  • skills/embeddings-setup/SKILL.md: bun install --force
  • skills/embeddings-setup/SKILL.md: When the index already exists and only some chunks lack vectors, price the work first with the dry run, then apply it:
  • skills/embeddings-setup/SKILL.md: Does not invoke `brew install` on the user's behalf.
  • skills/schema-author/SKILL.md: Preview the batch first with `schema_apply_mutations` (`dry_run=true`) or `o2b brain schema apply --dry-run --mutation ...`: it returns the pack that would result and its diff and writes nothing at all - no config write, no audit record, no lock file.
  • skills/schema-author/SKILL.md: It does not run the two checks that exist only because the apply writes: the vault-identity write guard, and the atomic writer's re-parse of the rendered YAML.
  • skills/schema-author/SKILL.md: Re-run lint/stats after mutation and report changed tokens plus any remaining findings.

Hooks (54)

  • hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" post-write-reminder; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook post-write-reminder; exit 0
  • hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
  • hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
  • hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
  • hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-agenda; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-agenda; exit 0
  • hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" backlog-nudge; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook backlog-nudge; exit 0
  • hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" log-catchup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook log-catchup; exit 0
  • hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" view-render; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook view-render; exit 0
  • hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" recall-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook recall-inject; exit 0
  • hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" nav-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook nav-inject; exit 0
  • hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" counterparty-brief; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook counterparty-brief; exit 0
  • hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
  • hooks/hooks.json (PreToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-orient; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-orient; exit 0
  • hooks/hooks.json (PreToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-host-backup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-host-backup; exit 0
  • hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" stop-log-guardrail; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook stop-log-guardrail; exit 0
  • hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" answer-quote-check; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook answer-quote-check; exit 0
  • hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" hygiene-digest; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook hygiene-digest; exit 0
  • hooks/hooks.json (SubagentStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
  • hooks/hooks.json (SubagentStop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • hooks/hooks.json (SessionEnd): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • hooks/hooks.json (SessionEnd): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-promote; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-promote; exit 0
  • hooks/hooks.json (PostCompact): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
  • hooks/hooks.json (PostCompact): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" post-write-reminder; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook post-write-reminder; exit 0
  • plugins/codex/hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
  • plugins/codex/hooks/hooks.json (PostToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
  • plugins/codex/hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
  • plugins/codex/hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-agenda; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-agenda; exit 0
  • plugins/codex/hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" backlog-nudge; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook backlog-nudge; exit 0
  • plugins/codex/hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" log-catchup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook log-catchup; exit 0
  • plugins/codex/hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" view-render; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook view-render; exit 0
  • plugins/codex/hooks/hooks.json (SessionStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" recall-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook recall-inject; exit 0
  • plugins/codex/hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" nav-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook nav-inject; exit 0
  • plugins/codex/hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" counterparty-brief; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook counterparty-brief; exit 0
  • plugins/codex/hooks/hooks.json (UserPromptSubmit): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" reground-deliver; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook reground-deliver; exit 0
  • plugins/codex/hooks/hooks.json (PreToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-orient; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-orient; exit 0
  • plugins/codex/hooks/hooks.json (PreToolUse): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" pretool-host-backup; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-host-backup; exit 0
  • plugins/codex/hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" stop-log-guardrail; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook stop-log-guardrail; exit 0
  • plugins/codex/hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" answer-quote-check; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook answer-quote-check; exit 0
  • plugins/codex/hooks/hooks.json (Stop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" hygiene-digest; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook hygiene-digest; exit 0
  • plugins/codex/hooks/hooks.json (SubagentStart): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" subagent-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook subagent-inject; exit 0
  • plugins/codex/hooks/hooks.json (SubagentStop): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (SessionEnd): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0
  • plugins/codex/hooks/hooks.json (SessionEnd): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" gap-promote; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook gap-promote; exit 0
  • plugins/codex/hooks/hooks.json (PostCompact): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" active-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook active-inject; exit 0
  • plugins/codex/hooks/hooks.json (PostCompact): r="$CLAUDE_PLUGIN_ROOT"; if [ -n "$r" ] && [ -x "$r/scripts/o2b-hook" ]; then exec "$r/scripts/o2b-hook" session-capture; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook session-capture; exit 0

Package scripts (1)

  • package.json (prepare): bun scripts/prepare.ts || exit 0
  • Low risk: scripts-present (.githooks/pre-commit)
  • Low risk: scripts-present (.githooks/pre-push)
  • Low risk: network-instructions (plugins/codex/skills/codegraph-partner/SKILL.md:115)
  • Low risk: scripts-present (scripts/prepare.ts)
  • Low risk: network-instructions (skills/codegraph-partner/SKILL.md:115)

Attribution and licence

First committed by
GitHub avatar of hermes-vps-agenthermes-vps-agent
Declared authors
  • Open Second Brain contributors (declared in .claude-plugin/plugin.json)
  • Open Second Brain contributors (declared in pyproject.toml)
Publisher
Serg Eroshe (Community)
Source
itechmeat/open-second-brain · commit 233b91d
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Contributors
solaitken, itechmeat, Yori940619, hermes-vps-agent, DanBennettUK

AutoGineer is not affiliated with or endorsed by hermes-vps-agent or Serg Eroshe.

Install from source

486 of its files are not served here, so there is no ZIP. itechmeat/open-second-brain at 233b91d