Changelog¶
User-facing release notes. For full commit-level detail see GitHub Releases.
v1.7.24 — 2026-05-16 — embeddings.md BYOM callout + 5 devDep bumps¶
Polish release. No code behavior changes.
Docs¶
docs/embeddings.md now includes an "Auto-pull on first boot" admonition next to the Ollama setup snippet, pointing at Models → BYOM Ollama auto-pull for the full allowlist table. v1.7.23 covered the BYOM gate in models.md and troubleshooting.md, but a user reading top-down through embeddings.md wouldn't see the gate until hitting an error. This closes the discoverability gap.
Dependency bumps¶
@types/node25.6.2 → 25.8.0 (dev, type definitions only)@vitest/coverage-v84.1.5 → 4.1.6 (dev, patch)fast-check4.7.0 → 4.8.0 (dev, property-based testing — minor, test-scope only)tsx4.21.0 → 4.22.0 (dev, TS execution — patch)vitest4.1.5 → 4.1.6 (dev, test runner — patch)
Intentionally NOT bumped: better-sqlite3 12.9.0 → 12.10.0. The 12.10.0 release removed Node.js v20 prebuilt binaries (per WiseLibs/better-sqlite3#1468). Our package.json declares "node": ">=20.19.0", so taking 12.10.0 would force Node-20 users to compile from source on npm install (requires gcc/make/python). The Node-minimum bump belongs in v1.8.0 alongside other potentially-breaking changes.
Test counts¶
Unchanged from v1.7.23: 1010 passed + 1 skipped (1011 total), 103 test files. Preflight 12/12.
v1.7.23 — 2026-05-16 — BYOM Ollama auto-pull gate + logger sweep + SIGTERM unit test¶
Closes three follow-ups from the v1.7.22 audit cycle: a BYOM-aware auto-pull gate so users supplying EMBEDDING_MODEL=user/custom-fork aren't surprised by silent downloads of arbitrary artifacts, completion of the v1.7.22 NDJSON logger migration across the remaining embedding files, and a focused unit test locking in the SIGTERM-before-ctx race fix.
Features¶
BYOM Ollama auto-pull — allowlist + opt-in env var¶
v1.7.21 added auto-pull for Ollama models, but the gate was a single env-var check that didn't distinguish preset-known models from BYOM. A user setting EMBEDDING_MODEL=user/custom-fork would either get a less-helpful "HTTP 404 — try: ollama pull" error (the v1.7.20 path) OR, depending on env, an arbitrary download from a third-party namespace with no trust gate.
v1.7.23 rewrites OllamaEmbedder.shouldAutoPull() as a layered gate:
- Master kill —
OBSIDIAN_BRAIN_OLLAMA_AUTO_PULL=0→ never pull. Preserves v1.7.21 opt-out. - Preset-known —
EMBEDDING_PRESET=multilingual-ollamaetc. → always pull. Preserves v1.7.21 default-ON. - BYOM allowlist —
EMBEDDING_MODEL=nomic-embed-textorlibrary/llama3(bare id orlibrary/-prefixed) → pull. Ollama's official namespace is safe-by-default. - BYOM opt-in —
OBSIDIAN_BRAIN_OLLAMA_BYOM_AUTO_PULL=1→ pull anything the user names. Explicit consent. - Otherwise (BYOM third-party, opt-in off) — throw an actionable error naming all three escape hatches.
The actionable error message surfaces through THREE existing paths uniformly (no extra plumbing): dimensions() rethrow (v1.7.19), describeEmbedderPreparing helper → search/reindex envelopes (v1.7.22), and ensureEmbedderReady() throw to CLI stderr.
Why an allowlist: Ollama has no built-in "verified model" trust gate. ollama/ollama#11941 proposes "Secure Mode" but it hasn't shipped; CVE-2024-37032 was a real path-traversal exploit via crafted manifests. obsidian-brain therefore refuses to silently pull from third-party namespaces unless the user explicitly opts in.
presetName plumbing — PresetConfig.presetName (already existed since v1.5.0) is now passed from the factory to OllamaEmbedder's constructor as a 5th argument, captured in a private field, and read by the gate. A regression test in test/embeddings/factory.test.ts locks in the wire so a future refactor can't accidentally drop the arg.
Two new helpers exported from src/embeddings/ollama.ts:
isOllamaOfficialNamespace(modelId)— pure function; true for bare ids andlibrary/-prefixed idsollamaNamespaceOf(modelId)— extracts the namespace for diagnostic messages
8 new tests in test/embeddings/ollama.test.ts cover all 5 gate branches + the helper pure function + the actionable error format + the phase getter integration with describeEmbedderPreparing.
Logger migration sweep (Wave A complete)¶
The remaining 22 process.stderr.write / console.warn call-sites across src/embeddings/{prefetch, seed-loader, presets, overrides, errors}.ts migrate to logger.{info, warn, error}. hf-metadata/index.ts was already clean (pure HTTP client, no stderr writes). Off-limits fs.writeSync(2, …) crash-path sites untouched.
After this sweep, all stderr emission from src/embeddings/ (and the strategic 14 files from v1.7.22) flows through the unified logger. Operators using OBSIDIAN_BRAIN_LOG_FORMAT=ndjson now get structured JSON for every informational line emitted by the indexer, embedder, and metadata-resolver paths.
SIGTERM-before-ctx unit test¶
The race fixed in v1.7.22's 046f226 (handlers armed AFTER await createContext() on cold Linux CI runners → kernel signal-kill → exit code null) was only verified by the existing v1.6.10 integration test indirectly. New test/server-shutdown.test.ts adds focused unit coverage:
- Stubs
createContextto never resolve (simulates slow cold-boot dlopen) - Asserts SIGTERM + SIGINT handlers are armed within a single event-loop tick of
startServer()being called - Calls the captured handler with
ctx === null, asserts no throw andprocess.exitCode === 0
Locks in the "armed pre-await" invariant. A future refactor that moves handler registration back below the await createContext() line would fail this test.
Prefetch native-loader constraint — enforcement added¶
A late-stage v1.7.23 CI regression revealed a coverage gap: scripts/prefetch-test-models.mjs is invoked as node scripts/... (not tsx) in the CI prefetch warm-up step. Node 24's native ESM + TS strip-only loader runs the .mjs script, then loads the imported src/embeddings/prefetch.ts — but the strip-only loader refuses to rewrite .js → .ts in import specifiers, unlike vitest's Vite loader and tsx. Wave A's logger migration added import { logger } from '../util/logger.js' to prefetch.ts despite an existing warning comment that named the failure mode using debug-log.js as the example.
Three enforcement mechanisms now lock this in:
--dry-runflag onscripts/prefetch-test-models.mjs— loadsprefetch.ts(exercising the native loader) and exits before the model download. Runs in ~1s on a warm system.- New preflight step
prefetch native-loader (dry-run)— runs the dry-run as part ofnpm run preflight. Catches future regressions locally without a CI round-trip. - New vitest regression test
test/embeddings/prefetch-native-loader.test.ts— spawns the dry-run vianodeand asserts noERR_MODULE_NOT_FOUND. Vitest's Vite loader can't catch this directly (Vite rewrites.js→.ts), so the test must shell out.
Warning comment in src/embeddings/prefetch.ts generalised from "do NOT add debug-log.js" to "do NOT add ANY .js-extensioned internal import" + pointers at the two enforcement paths.
Docs¶
docs/troubleshooting.md— new "Ollama BYOM model not pulling" entry citing the actionable error verbatim + three escape hatches, plus a "Failed to parse GITHUB_REGISTRIES_PROXY" entry pointing at dependabot/dependabot-core#13328 (benign upstream warning, no repo-side fix).docs/models.md— new "BYOM Ollama auto-pull" subsection with the allowlist table and three escape hatches.docs/getting-started.md— newOBSIDIAN_BRAIN_OLLAMA_BYOM_AUTO_PULLrow in the env-var table; reframedOBSIDIAN_BRAIN_OLLAMA_AUTO_PULLdescription as the master kill-switch.docs/configuration.md— auto-regenerated fromserver.json(gen-docs); picks up the new env var declaration.
Test counts¶
v1.7.22 baseline: 996 passed + 1 skipped (997 total). v1.7.23: 1010 passed + 1 skipped (1011 total) — net +14 new tests across test/embeddings/ollama.test.ts (BYOM gate + helper + error format + phase getter), test/embeddings/factory.test.ts (preset plumbing), test/server-shutdown.test.ts (new file, 2 tests), and test/embeddings/prefetch-native-loader.test.ts (new file, 1 test — regression lock for the native-loader constraint). The 1 skipped is the env-gated L1 integration test (unchanged from v1.7.22). Preflight goes from 11 → 12 steps with the new dry-run check.
v1.7.22 — 2026-05-15 — structured stderr (NDJSON) + Ollama preparing-state + dependabot security bumps + SIGTERM drain integration test¶
Bundles the four structural follow-ups deferred from v1.7.21 (V4, O3/N2, L1, O7 verification) plus six Dependabot dependency bumps including the protobufjs critical CVE.
Fixes¶
Dependabot security + patch bumps (6 PRs)¶
Six PRs merged from main into dev:
- protobufjs 7.5.5 → 7.5.8 (CVSS 9.4 critical, GHSA-xq3m-2v4x-88gg). Pulled in transitively via
@huggingface/transformers→ onnxruntime. Direct user-facing impact is low (protobufjs is used to read local ONNX model files, not parse network data), but bumping closes the published advisory. - @protobufjs/utf8 1.1.0 → 1.1.1 (companion to the protobufjs root bump; auto-resolved by the main bump).
- ip-address + express-rate-limit security group.
- hono 4.12.14 → 4.12.18 (patch).
- fast-uri 3.1.0 → 3.1.2 (patch).
- dev minor-and-patch group (2 dev-dependency updates).
All 996 tests still pass with the bumped lockfile.
Features¶
V4 — Optional NDJSON stderr (OBSIDIAN_BRAIN_LOG_FORMAT=ndjson)¶
For operators piping the MCP daemon's stderr into log aggregators (Datadog / Loki / etc.). Default stays plain-text (obsidian-brain: <message>\n) — no breaking change.
Set OBSIDIAN_BRAIN_LOG_FORMAT=ndjson to switch every routine stderr line to one JSON object per line:
Implementation:
- New
src/util/logger.ts(82 LOC) exports aLoggerinterface withinfo / warn / errormethods. ReadsOBSIDIAN_BRAIN_LOG_FORMATon every call so tests can override per-describe-block. - 37 call-sites across 14 files migrated:
embeddings/{ollama, embedder, auto-recommend, metadata-resolver}.ts,pipeline/{indexer/index, indexer/self-heal, watcher}.ts,store/db.ts,context.ts,server.ts,auto-heal.ts,tools/background-reindex.ts,vault/{parser, wiki-links}.ts. - Structured fields surface as separate JSON keys instead of being interpolated into the message string. E.g.
logger.info('indexed', { count: 42, durationMs: 1234 })lets log aggregators filter oncount > 0directly. - OFF-LIMITS — intentionally NOT migrated: every
fs.writeSync(2, …)crash-path write insrc/preflight.ts(boot banner + recordCrash),src/global-handlers.ts(uncaught-exception / unhandled-rejection paths),src/server.ts:287(startup-failure fallback). These must be synchronous writes that survive process exit; routing them through the asyncprocess.stderr.write-based logger would risk losing the last line before crash. Plain text only on those sites. - CLI files (
src/cli/*.ts) also intentionally skipped — CLI output is human-facing, not part of the MCP daemon log stream.
10 logger tests at test/util/logger.test.ts cover both modes + control-char escaping in messages + envelope-key precedence + per-call env toggling.
O3 / N2 — Ollama "preparing" status path¶
Today the transformers.js side returns {status:'preparing', message:...} so MCP clients can poll gracefully during model load. The Ollama side previously either threw synchronously OR blocked on a multi-minute pull when reindex was called during init. Both broken.
New OllamaPhase discriminated union on OllamaEmbedder exposed via embedder.phase:
type OllamaPhase =
| { status: 'not-started' }
| { status: 'probing' }
| { status: 'pulling'; completedMb: number; totalMb: number; pct: number; phase: string }
| { status: 'ready' }
| { status: 'failed'; error: Error };
Phase mutates in-place on every /api/pull NDJSON progress line, so MCP clients polling embedder.phase see live MB / pct / Ollama-status updates without parsing log strings.
New shared src/tools/preparing.ts helper — describeEmbedderPreparing(ctx) returns the right envelope shape (preparing | failed | null) including the current pull progress when applicable. Both search and reindex now use it:
search({mode:'hybrid'|'semantic'})already had a preparing guard; it now also surfaces pull progress + a structuredphasefield alongside the human message.reindexkeeps its existing blocking contract — an explicit user call toreindexis explicit opt-in to wait. The earlier dev-branch experiment that madereindexreturnpreparingmid-pull was reverted before ship because it broke the synchronous "do the work and return stats" contract that the smoke harness and other callers rely on.
3 phase-transition tests in test/embeddings/ollama.test.ts (not-started → ready, → failed, getter typing).
L1 — SIGTERM-mid-reindex integration test¶
The v1.7.19 shutdown-drain fix (src/server.ts awaiting ctx.pendingReindex before db.close()) had unit-test coverage with mocks but no end-to-end integration test. A typo removing the await Promise.race(...) would have still passed every unit test.
New test in test/integration/server-stdin-shutdown.test.ts (gated on OB_INTEGRATION_REAL_EMBEDDER=1 so CI stays fast):
- Spawns the real server child (
dist/cli/index.js server) against a 40-note temp vault with the local bge-small embedder. - Sends a
tools/call reindexJSON-RPC request over stdin. - Sends SIGTERM 1500 ms in (empirically: lands squarely in the embedding loop, not before model load or after completion).
- Asserts: exit code 0,
PRAGMA integrity_check = 'ok', ≥ 1 row innodes. Proves the drain commits cleanly and SQLite isn't corrupt.
When env is unset (the default), the heavy test is skipped via describe.skipIf — no CI overhead.
Verification (O7) — Qwen3 32k context not silently capped¶
External-audit item: does OLLAMA_NUM_CTX=8192 (our docs/server.json default reference) silently truncate qwen3-embedding:0.6b's 32 768-token context?
Verdict: no code change needed. OllamaEmbedder.effectiveNumCtx already does the right thing — precedence is explicit env > /api/show context_length > 8192 fallback. When env is unset (the default) and /api/show is reachable, the embedder sends num_ctx: 32768 for qwen3-embedding automatically. The 8192 cap only fires in the exceptional path (env unset AND /api/show unreachable), which is the right defensive default.
Sources verified: ollama/ollama#7008, #14259, #3727, the Qwen3-Embedding-0.6B HF model card.
One docs-only follow-up landed: server.json's OLLAMA_NUM_CTX description now explicitly flags the "set it manually and you may silently truncate" footgun. Auto-regenerated docs/configuration.md picked it up.
Docs cleanup¶
- v1.7.21 CHANGELOG entry: re-worded the install-flow fix sentence to remove personal attribution.
v1.7.21 — 2026-04-27 — install.sh vault-picker fix + auto ollama pull + docs/test polish¶
Closes the install-flow footgun on iCloud-synced Obsidian vaults, ships auto-pull for Ollama models so first-time multilingual-ollama setup works without a manual command, and rounds out the small-but-real audit follow-ups deferred from v1.7.20.
Fixes¶
install.sh — .obsidian/-aware vault picker (silent-data-loss-shaped bug)¶
Symptom: the one-line installer's vault picker enumerated each subfolder of iCloud~md~obsidian/Documents/ as a separate "vault" candidate (Archive, Resources, Project, Meetings, Ideas, …). For Obsidian's iCloud sync, Documents/ IS the vault — those are folders inside one vault. Picking any of the offered candidates set VAULT_PATH to a subfolder, so obsidian-brain indexed only that slice while the rest of the vault (root-level notes + the other category folders) became invisible to search. The script also offered .obsidian/ itself as a candidate (it's a folder; the enumeration didn't filter dotfiles).
Three layered fixes:
- Filter dotfiles from the candidate enumeration.
.obsidian/,.git/,.DS_Store/etc. are no longer offered as vault candidates. .obsidian/-aware detection. If a parent path itself contains.obsidian/, list the parent as the vault (the iCloud-Obsidian canonical layout). Otherwise enumerate non-dotfile subfolders, vault-shaped (those with.obsidian/) sorted first. Confirmed candidates are annotated(.obsidian/ found).normalize_path_input()in front ofvalidate_vault()— handles five separate macOS Terminal failure modes that turn the same-looking-path into bytewise-different inputs:- Backslash-escaped shell metacharacters from Finder drag-paste (
/Users/.../Mobile\ Documents/iCloud\~md\~obsidian/Documents). Generalised:\X→Xfor any non-alphanumericX. Covers,~,(,),',",`,$,&,;,<,>,?,[,],\,^,{,|,},*,#,!, etc. - Surrounding straight quotes (
'…'or"…") — common pattern for paths with spaces. - Surrounding smart quotes (
'…'/"…", UTF-8 U+2018/U+2019/U+201C/U+201D) from apps with smart-substitution enabled. - Trailing whitespace / CR / LF from copy-paste.
- Non-breaking space (U+00A0) from copying out of PDFs / web pages.
13 install-helper smoke tests cover all five normaliser branches plus three picker layouts (parent-is-vault, multi-vault subfolders, no-.obsidian/-anywhere fallback) plus the .obsidian/ dotfile filter.
Auto ollama pull on missing model (default ON)¶
Closes the multilingual-ollama UX loop. After v1.7.19's dimensions() rethrow + v1.7.20's seed-key fix, the path "I picked multilingual-ollama and it should just work" was still gated behind a manual ollama pull qwen3-embedding:0.6b step.
OllamaEmbedder.init() now detects HTTP 404 from /api/show (the canonical "model not pulled" signal), and unless OBSIDIAN_BRAIN_OLLAMA_AUTO_PULL=0 is set, kicks off /api/pull with stream: true, parses the NDJSON progress stream, prints throttled obsidian-brain: pulling qwen3-embedding:0.6b — N MB / M MB (P%) updates to stderr, and re-probes /api/show on success.
Default ON, opt-out via env var. Choosing an Ollama-backed preset is implicit consent to download its model (matches Ollama's own ergonomic — ollama run qwen3-embedding:0.6b auto-pulls). Users who want to manage pulls manually can set OBSIDIAN_BRAIN_OLLAMA_AUTO_PULL=0 to fall back to v1.7.20's actionable error path.
The auto-pull trigger is narrow: it fires only when /api/show returns 404 (HTTP), not on schema variation (/api/show returns 200 but no embedding_length). The latter still falls through to the legacy embed-probe path, preserving v1.7.20 behaviour for unusual model architectures.
3 unit tests cover: happy path (mock NDJSON success → embedder ready), failure (mock {"error":"…"} line → dimensions() rethrows), opt-out (OBSIDIAN_BRAIN_OLLAMA_AUTO_PULL=0 → no /api/pull attempted).
Docs bundle¶
- E1:
OBSIDIAN_BRAIN_NO_CATCHUP=1doc tightened. Now explicit that it skips theenqueueBackgroundReindexstartup catchup pass; watcher still starts (separateOBSIDIAN_BRAIN_NO_WATCH=1knob); first-time indexing on an empty DB is unaffected. - D3 / D4: new "Database schema reference" section in
docs/architecture.mdcoveringindex_metadataandembedder_capability. Each column annotated with provenance + meaning + when it's null. Useful when debugging "why does my preset use the wrong dim?" or "why isn't the prefix-strategy hash flipping?" - C12: new "Boot-time version banner" section in
docs/architecture.mddocumenting the deterministicobsidian-brain: starting (vX.Y.Z, Node vN.N.N, NODE_MODULE_VERSION ABI, npm vN.N.N, platform os-arch, debug=on|off)line so it can be cited stably in bug reports. - C9 / R2:
reindexresponse field semantics indocs/tools.md— clarified thatstubNodesCreated,stubsPruned,nodesIndexed, etc. are all deltas for this run, not totals. - G9:
find_connectionscontextenvelope sub-section indocs/tools.md— documentsstate.last_connections_root/state.last_connections_countand thenext_actions[]shape so MCP clients can route follow-up calls directly. OBSIDIAN_BRAIN_OLLAMA_AUTO_PULLadded to bothdocs/configuration.md(auto-generated fromserver.json) anddocs/getting-started.md.
Test-coverage hardening (G1 + C7)¶
Two test-coverage gaps from v1.7.19's fixes, now closed. Pure additions; no production code change.
- G1 —
refreshCommunitiesdirect test. Builds a graph with mixed real + stub nodes, runsKnowledgeGraph.fromStore→detectCommunities(the same pathIndexPipeline.refreshCommunities()uses atsrc/pipeline/indexer/index.ts:267), asserts no community has any_stub/*ID. Catches a regression where someone passes{ includeStubs: true }to that call site by mistake. A second test confirmsincludeStubs: trueopt-in DOES re-include stubs (so the filter is doing the work, not some unrelated path). - C7 —
rngoption forwarded to louvain. Two-test belt-and-braces: a vi.spyOn on thelouvainimport (best-effort under vitest's module cache), plus a behavioural identical-output check across two fresh DB instances (catches a graphology rename ofrng→random/seed/ etc., which would silently fall back toMath.randomwhile the existing determinism test in the suite happened to remain stable on small graphs).
Tests¶
9 new tests across all four fixes. Total: 100 test files, 983 tests passing (up from 974).
Out of scope (queued for v1.7.22)¶
- V4 — NDJSON stderr. 88 call-sites, 26 files. Mechanical refactor; deferred.
- O3 / N2 — Ollama "preparing" status path. Real state machine on
OllamaEmbedderplus poll-friendly tool semantics. With v1.7.21's auto-pull landed, the preparing-state UX matters less — most users will just wait through the pull. - L1 integration test for the v1.7.19 shutdown drain. Heavy (real embedder + reindex + SIGTERM mid-flight). Deferred to its own focused integration-test review.
- Auto
ollama pullfor BYOM models not on the canonical preset list. v1.7.21 only auto-pulls models the user picked via a known preset. BYOM auto-pull would need trust-on-first-use semantics.
v1.7.20 — 2026-04-27 — Ollama prefix-lookup bug + 13 audit polish items¶
Closes the remaining audit follow-ups from v1.7.18's external test-harness catalogue, plus a real Ollama bug discovered while verifying v1.7.19's multilingual-ollama story end-to-end.
The Ollama prefix-lookup bug (the real "is multilingual-ollama working?" answer)¶
Symptom: even on a fully-healthy Ollama setup (daemon up, model pulled, server boots clean), retrieval quality on asymmetric Ollama models (qwen3-embedding, mxbai-embed, e5-* via Ollama) was bad. v1.7.19's actionable error fixed the boot-crash story for users without the model pulled, but the path after the pull silently produced empty-prefix queries against asymmetric models.
Root cause: OllamaEmbedder.modelIdentifier() returns 'ollama:<model>' (e.g. 'ollama:qwen3-embedding:0.6b'); the bundled seed (data/seed-models.json) keys Ollama models bare ('qwen3-embedding:0.6b'). The resolver's seed.get() lookup missed for every Ollama model, fell through to HF (404), then to embedder-probe-fallback which wrote query_prefix = NULL, document_prefix = NULL to the cache. materialise coerced nulls to '', the embedder was told setMetadata({ queryPrefix: '', documentPrefix: '' }), and at embed time the non-null _metadata check bypassed the hardcoded getPrefix() qwen heuristic. Queries embedded with no prefix → asymmetric model retrieves poorly.
Fix:
- src/embeddings/metadata-resolver.ts — added a tiny seedKey() helper that strips ^ollama: for the lookup site only (3 sites: async resolver, sync resolver, promoteFromSeedIfStale). Cache rows still keyed on the prefixed identifier (no migration). Mirrors the existing pattern at src/embeddings/capacity.ts:297.
- src/embeddings/ollama.ts — embed-time getPrefix() heuristic is no longer bypassed when _metadata.prefixSource === 'fallback' or 'none'. Authoritative metadata (override/seed/HF/README) still wins even when its prefix is empty (correct for symmetric models like bge-m3); only the non-authoritative fallback rows fall through to the family heuristic. Covers BYOM Ollama models that aren't in the seed (e.g. user sets EMBEDDING_MODEL=qwen-custom-fork).
Self-healing for v1.7.19 users: existing stale embedder_capability rows keyed 'ollama:<model>' with NULL prefixes auto-promote on next boot. Same pattern as v1.7.19's S1 fix for transformers.js. No CLI command needed.
Audit follow-ups¶
- L2 (default behaviour, not opt-in) —
PRAGMA wal_checkpoint(TRUNCATE)on every clean shutdown. Folds the WAL into the main DB file before close so dirty exits don't leave a multi-MBkg.db-walsidecar. Wrapped in try/catch (non-fatal).src/server.ts. - N3 — central
ctx.initErrorwrite.initPromise.catch(err => ctx.initError ??= err)attached at promise construction insrc/context.ts. Tool handlers that hit a rejectedinitPromiseno longer leaveindex_status.initErrorreading null.server.ts:138retained for its operator-visible stderr line. - V1 — dedup ambiguous-link warnings via a per-
parseVaultSet<string>. Drops 1224 stderr lines on a 10k-vault to one line per distinct ambiguous target. - V2 — same-folder preference in the wiki-link resolver.
[[shared]]fromA/note.mdresolves toA/shared.mdoverB/shared.md. Matches Obsidian UI's resolver. mtime-tiebreak is a follow-up (needs DB plumbing into the parser). - G2 — surface
modularityon everydetect_themesresponse, not only on the warning branch. Clients can monitor partition quality without parsing a warning string. - C5 — rephrased the misleading "wiping sync.mtime to retry on next boot" stderr line to say what's actually happening (sync timestamps cleared so notes retry on the next change-driven indexer pass).
- C6 (already in v1.7.19) —
reindexresolution optional. - C8 — explicit user-triggered reindexes now populate
lastReindexReasons.index_statusmerges manual reasons with bootstrap migration reasons. - O5 — stripped the obsolete
OLLAMA_EMBEDDING_DIMworkaround hint from the dimensions() generic error. v1.7.19's rethrow makes the underlying init error reach the user; pointing them at a workaround env var is misleading. - O8 —
models recommendis Ollama-aware. When a non-English vault is detected AND Ollama is reachable ANDqwen3-embeddingorbge-m3is already pulled, recommendsmultilingual-ollamaovermultilingual(better MTEB-multi score, longer context). Probe is bounded byAbortSignal.timeout(500); failure falls through to the existing path. - V5 — first-boot index-time hint deliberately vaguer ("typically under a minute for small vaults, a few minutes for thousands of notes") instead of mis-promising "30-60s" on 10k-note vaults that actually take ~3 min.
- E2 — per-tool timeout overrides via
OBSIDIAN_BRAIN_TOOL_TIMEOUT_MS_<TOOL>(uppercase, hyphens →_). Plus a built-in baseline:reindexdefaults to 10 minutes, not 30 seconds — covers 10k+ note vaults under transformers.js + Louvain without users having to know about the env var. Other tools still default to 30s. Env override wins over the baseline if set. - E3 — per-model max-chunk-tokens overrides via
OBSIDIAN_BRAIN_MAX_CHUNK_TOKENS_<MODEL_HASH>=N. Where<MODEL_HASH>is the same sha256-truncated digest used as theembedder_capabilityprimary key.
Test additions¶
20 new tests across the fixes. Total: 100 test files, 974 tests passing (up from 954).
metadata-resolver.test.ts(3 new) —'ollama:foo'resolves to bare seed key; stale null-prefix Ollama row promotes from seed; BYOM not-in-seed falls through to probe.ollama.test.ts(4 new) —getPrefix()heuristic fires when_metadata.prefixSource = 'fallback'; authoritative empty wins onseed/override; generic message no longer mentionsOLLAMA_EMBEDDING_DIM.context.test.ts(2 new) — rejectedinitPromisepopulatesctx.initErrorwithout an awaiter;??=makes first error win.wiki-links.test.ts(5 new) — V1 dedup; V2 same-folder; backwards-compat for both.detect-themes.test.ts(1 modified) —modularityalways surfaced.reindex.test.ts(1 modified) —lastManualReindexReasonset on user-triggered reindex.first-boot-recommend.test.ts(4 new) — Ollama-aware recommend with all the failure modes (probe success, unreachable, no-multilingual-pulled, English-vault skip).register.test.ts(2 new) — per-toolOBSIDIAN_BRAIN_TOOL_TIMEOUT_MS_<TOOL>overrides.
Out of scope (queued)¶
- V4 — NDJSON stderr. 88 call-sites across 26 files. Mechanical refactor; v1.7.21 candidate.
- O3 / N2 — Ollama "preparing" status path. Bigger than its line in the roadmap suggested — needs a state machine on
OllamaEmbedder. v1.7.21 candidate. - Auto
ollama pullon missing model. Today the user has to runollama pullmanually after hitting v1.7.19's actionable error. Worth doing as opt-in via env var; v1.7.21 candidate. - C2/C3/C10/C11/M1/M3 — already on the v1.8.0 roadmap (schema migrations, multi-tool refactors).
v1.7.19 — 2026-04-26 — Six fixes from the external test-harness audit + Apache 2.0 relicense¶
Triages the highest-impact items from a 1032-line external test-harness audit. Three of the six are bugs that affected advertised behaviour; the other three are correctness/quality improvements. Plus a license switch from MIT to Apache 2.0 to match the sibling apple-notes-brain project.
Fixes¶
S1 — Negative semantic-search scores on the default preset (CRITICAL)¶
Symptom: top semantic scores were universally negative on a freshly indexed vault. Reproduced against both this fork and obsidian-brain@1.7.6 from npm — pre-existing upstream bug, not a fork regression.
Root cause: the embedder-metadata cache (embedder_capability table) accepts rows with query_prefix = NULL and document_prefix = NULL. Such rows are written by the embedder-probe-fallback path (HF unreachable + embedder loaded) and by pre-v1.7.5 installs that predate the prefix columns. Once such a row is in the cache, every subsequent boot short-circuits at the cache-hit step and skips the bundled seed — so asymmetric models (MongoDB/mdbr-leaf-ir, BGE/E5 family, mdbr) embed queries with no prefix while documents go in with no prefix either, sending them to different regions of the model's latent space than it was trained for. Cosine drops to near-zero / negative.
Fix: the resolver now detects a stale-prefix cache row (queryPrefix === null && documentPrefix === null) and, if the bundled seed has the model with a non-null prefix, promotes the seed values over the cache row and writes them back. Self-healing on next boot for affected vaults — no manual cache-clear needed. Override entries are protected (the isCompleteOverride short-circuit fires earlier in the resolver and is unaffected; partial overrides still apply on top of the promoted seed).
Affected users on pre-fix vaults will see a one-time reindex on first boot of v1.7.19 because the prefix-strategy hash changes — surfaced as embedding model X uses different query/document prefixes than before — re-embedding for accurate search in stderr.
O1 / O2 / O3 / O9 — Ollama bootstrap masked the real error with a generic shim (HIGH)¶
Symptom: every Ollama preset and every Ollama BYOM model died at boot with OllamaEmbedder dimensions not known yet — call init() or embed() once first regardless of the underlying cause (model not pulled, daemon down, dim mismatch, …). The actionable error (e.g. HTTP 404 — try ollama pull qwen3-embedding:0.6b) was logged to stderr but never reached the MCP client because tools like index_status call embedder.dimensions() directly without going through ensureEmbedderReady().
Fix: OllamaEmbedder now stores the last error from init() / embed() on a sticky lastError field. dimensions() rethrows that error in preference to the generic shim message when no dim has been probed. The error clears on the next successful call. MCP clients now see the real cause regardless of which tool path they hit first.
G1 / G4 / G5 / G6 — Stub nodes poisoned every graph algorithm (HIGH)¶
Symptom:
- detect_themes returned ~5,160 micro-clusters on a 12k-node vault (two-thirds of "communities" were size-1 stub satellites)
- rank_notes(metric: 'influence') top results dominated by _stub/John.md, _stub/Bush.md, etc. — non-existent wikilink targets ranking as the most influential notes
- find_path_between returned empty for plausible note pairs because stubs were degree-1 dead ends in the undirected projection
- find_connections returned stub-only neighbour lists for notes whose only outgoing edges were to broken wikilinks
Root cause: KnowledgeGraph.fromStore(db) ran a bare SELECT id FROM nodes with no stub filter. Stubs (broken-wikilink targets, marked with frontmatter._stub: true) were therefore part of every graph that downstream algorithms consumed.
Fix: KnowledgeGraph.fromStore(db, options?) now accepts { includeStubs?: boolean } defaulting to false. The four graph-using tools (detect_themes, rank_notes, find_connections, find_path_between) gain a matching includeStubs parameter, also defaulting to false. Pass includeStubs: true to opt back into the legacy behaviour. The IndexPipeline.refreshCommunities Louvain run also picks up the new default automatically — so post-upgrade reindexes regenerate cluster data without stubs.
Migration: rank_notes's includeStubs default flips from true to false — clients depending on stub-inclusive results need to opt back in explicitly.
L1 — SIGTERM during reindex emitted noisy "database connection is not open" stderr (HIGH, fork-only regression)¶
Symptom: SIGTERM during an active background reindex produced 7+ lines of obsidian-brain: background reindex failed: TypeError: The database connection is not open because the shutdown handler called ctx.db.close() immediately without awaiting ctx.pendingReindex. In-flight community-detection / graph-rebuild writes then hit the closed handle.
Fix: the SIGTERM handler now awaits ctx.pendingReindex.catch(() => {}) with a 3-second cap (the existing 4-second hard-exit timer covers genuinely-stuck reindexes) before disposing the embedder and closing the DB.
C6 — reindex always reran Louvain even when the vault was unchanged (MEDIUM)¶
Symptom: bare reindex({}) on a fully-indexed vault took ~25 s on a 10k-note vault because community detection always ran, even when nodesIndexed === 0 && stubNodesCreated === 0 && deletionCount === 0.
Root cause: the reindex tool's Zod schema declared resolution: z.number().positive().default(1.0) — meaning the value was always present, which made explicitResolution = resolution !== undefined always true, which forced the indexer's no-op guard chain to fire community detection every time.
Fix: resolution is now .optional(). Bare reindex({}) on an unchanged vault completes in <1 s (skips Louvain). Calling reindex({ resolution: 1.5 }) still forces a Louvain rerun — the explicit-intent path.
C7 — Louvain community count drifted across identical-data runs (MEDIUM)¶
Symptom: running reindex repeatedly on the same data produced different community counts (5163, 5161, 5159, 5188, 5189) because graphology's louvain call defaults to Math.random for tie-breaking and node-ordering, and our compat wrapper didn't expose the rng option.
Fix: the wrapper now types and forwards rng?: () => number, and detectCommunities passes a freshly-seeded mulberry32 (constant seed) on every call. Identical input graphs now produce byte-identical community partitions across runs — reindex × N times → same communitiesDetected every time.
Migration: existing community partitions will shift once on the next reindex after upgrade, then stay stable forever.
Test additions¶
21 new tests across the six fixes. Total: 100 test files, 954 tests passing (up from 933).
metadata-resolver.test.ts— five S1 cases: stale null-prefix promoted, no-seed cache wins, healthy cache untouched, partial override overlays seed, sync path also promotes.ollama.test.ts— four O1/O9 cases: dimensions() rethrows captured embed error, rethrows captured init error, generic message when no error, lastError clears on successful retry.builder.test.ts— three G1+ cases: default excludes_stub: true, opt-in re-includes, outgoing edges from stubs also dropped.find-connections.test.ts,find-path-between.test.ts(new),rank-notes.test.ts,detect-themes.test.ts— default-exclude / opt-in coverage for the four downstream tools.context.test.ts— two L1 cases: shutdown await-pattern drains in-flight work, times out after the cap when work hangs.reindex.test.ts— three C6 cases: first-time triggers Louvain (nodesIndexed > 0 path), second no-op skips Louvain, explicit resolution forces rerun.communities.test.ts— two C7 cases: identical input → identical partition; determinism holds across many sequential runs.
License: MIT → Apache 2.0¶
LICENSE replaced with the Apache License 2.0 text. package.json license switches from MIT to Apache-2.0 (SPDX identifier). README badge + License section updated. Matches the sibling apple-notes-brain project's license. Copyright 2026 sweir1 preserved at the bottom appendix of LICENSE.
Self-healing for users stuck with negative semantic scores¶
If you were on v1.7.18 or earlier and saw negative semantic scores, the upgrade fixes itself on next boot — no manual action needed. The resolver detects the stale cache row, promotes the bundled seed prefixes, and triggers a one-time reindex. You'll see this in stderr:
obsidian-brain: embedding model MongoDB/mdbr-leaf-ir uses different query/document prefixes than before — re-embedding for accurate search
If you want to force the cache rewrite immediately without waiting for the boot path: obsidian-brain models refresh-cache.
v1.7.18 — 2026-04-26 — Modularize three large source files into folders + trim RELEASING.md¶
Internal refactor. No behaviour change, no public API change, no test changes. Every existing import keeps working via re-export facades.
What¶
src/cli/models.ts(703 lines) →src/cli/models/folder of 10 files (one file per Commander subcommand:list,recommend,prefetch,check,refresh-cache,add,override,fetch-seed, plusindex.tsaggregator and sharedoutput.tsfor theprintJsonhelper). The original file is now a 1-line re-export facade.src/pipeline/indexer.ts(661 lines) →src/pipeline/indexer/folder of 6 files. TheIndexPipelineclass stays as one cohesive orchestrator (its methods sharedb/embedder/capacitystate); the stateless helpers were extracted: error-classification regexes (embedder-errors.ts), title-fallback synthesis (fallback-text.ts), stub helpers (stubs.ts), the post-index self-heal SQL (self-heal.ts), and the result/stats interfaces (types.ts).src/embeddings/hf-metadata.ts(636 lines) →src/embeddings/hf-metadata/folder of 5 files: HTTP retry plumbing (http.ts), README YAML / language / script helpers + prefix fingerprinting (readme.ts), 3-tier prompt resolution (prompts.ts), file-private types (internal-types.ts), andgetEmbeddingMetadata+ public types (index.ts).- All three originals replaced with thin re-export facades — every consumer (13 indexer test files, 2 hf-metadata test files, 1 cli/models test,
src/context.ts,src/cli/index.ts,src/embeddings/metadata-resolver.ts) keeps its existing import path with zero edits.
Why¶
The three files had drifted past 600 lines each — hard to scan, harder to change without touching unrelated logic. Splitting along the clear comment-banner sections that already existed was a low-risk wins-on-arrival cleanup: no test changed, 933/933 still green, MCP smoke 18/18 still green, full TypeScript + build clean.
Side change: RELEASING.md trimmed 723 → 414 lines¶
Three staleness bugs corrected:
- Preflight step list was missing
gen-readme-recent --check(which actually runs as step 3 ofscripts/preflight.mjs). - The
release.ymlstep listing was missing the 5 MTEB seed-regen steps that sit betweenBuildandInstall mcp-publisher— the listing predated the switch from a Node-built seed to a Python/MTEB-built seed. - The "HF model cache key" section was entirely obsolete. The actual cache is the MTEB venv, keyed on
runner.os + arch + python-version + hash(scripts/requirements-build-seed.txt). Section deleted; replaced with a one-paragraph note in the relevant release.yml step.
Historical "since vX.Y.Z" / "Pre-v1.7.16…" / "(eliminated in v1.7.17)" prose was rewritten in present tense — those rot the moment a feature ships further back than the string remembers, and the CHANGELOG is the version history.
v1.7.17 — 2026-04-26 — Eliminate dev-shipped ref entirely; promote derives base from latest tag's cherry-pick trailer¶
Removes the dev-shipped tag/branch from the promote flow. No persistent state ref to maintain, advance, force-push, or drift. Future promotes (v1.7.18, v1.7.19, …) will work with zero special git refs anywhere.
Why¶
After v1.7.16 converted dev-shipped from a tag to a branch (eliminating force-push), the user pushed back: we don't need this ref at all. They were right. Every promote leaves enough information in immutable git history (release tags + cherry-pick -x trailers) to reconstruct "what was last shipped" — no separate ref required.
The fix¶
scripts/promote.mjs now derives the cherry-pick base via:
- Find the latest
vX.Y.Ztag (semver-sorted descending). - That tag points at the version-bump commit on main.
- The bump commit's first parent is the LAST cherry-pick of that release.
- With
git cherry-pick -x(already used by step 7), every cherry-pick has a(cherry picked from commit ORIGINAL_SHA)trailer. - Extract
ORIGINAL_SHA— that's the dev SHA that was last shipped. Use as base for the new pending range.
This is the canonical pattern for tracking promoted commits across branches — git-cherry-pick docs and Atlassian's release-branch tutorial both document the -x trailer for exactly this purpose. Verified empirically on this repo: extracting the trailer from v1.7.16^1 returns 4bd0aff (the actual v1.7.16 promote target).
Stacking scenario (the original ask)¶
User commits both v1.7.18 (c18) and v1.7.19 (c19) to dev before any promote, then runs:
npm run promote -- c18 # ships v1.7.18
# … merge-back lands; dev tip is now M_18, with c18 + c19 still in history
npm run promote -- c19 # ships v1.7.19
Both promotes work cleanly under v1.7.17:
| Promote | latest tag | base derived from | pending |
|---|---|---|---|
| v1.7.18 (target c18) | v1.7.16 | bump_16^1 trailer → 4bd0aff | [c18] ✓ |
| v1.7.19 (target c19) | v1.7.18 | bump_18^1 trailer → c18 | [c19] ✓ |
Pre-v1.7.17 with the v1.7.16 branch-based logic, this still worked but required maintaining the moving dev-shipped branch ref. Pre-v1.7.16 with the tag-based logic, it required a force-push to advance the tag every promote. v1.7.17 eliminates both.
Removed¶
refs/heads/dev-shippedbranch on origin (deleted in v1.7.17 cleanup) and locally.- The advance/push step at the end of every promote (~10 LOC).
- The stale-ref drift guard (~80 LOC) — drift is impossible without a ref to drift.
- All command output / error-message strings referencing
dev-shipped. - All RELEASING.md sections about advancing/maintaining
dev-shipped.
Bonus: docs venv cache (mirrors warm-mteb-venv)¶
pip install -r website/requirements.txt (mkdocs + plugins) ran on every CI workflow — ci.yml's validate job AND docs.yml's build job, on every PR + every dev/main push. Even with setup-python's cache: pip, pip still ran end-to-end (~30 s) to resolve + install into site-packages each time.
Applied the same pattern as warm-mteb-venv (v1.7.12) for these:
- Cache the entire populated venv at
~/.venv-docs(not just pip's wheel cache). Saves to main scope only — every other ref reads via cross-ref fallthrough. Skips pip invocation entirely on hit. - Cache key includes the FULL Python patch version (e.g.
py3.12.8) — venv shebangs and any wheel-bundled C extensions break on patch drift, so a runner image's Python bump triggers a clean rebuild rather than a partial restore that fails at runtime. - Manifest hash in the key (
hashFiles('website/requirements.txt')) — any dep change automatically invalidates the cache. No manual cache-buster needed. - No
restore-keysfallthrough — patch / manifest drift breaks venvs; clean miss → rebuild is the safe default. ~/.venv-docs/binprepended to$GITHUB_PATHso subsequentmkdocs ...steps find the venv binary (no--target-style activation needed).
Applied to both ci.yml's validate job (Setup Python → Restore venv → Install on miss → Save on main → PATH) AND docs.yml's build job (same pattern, with workspace-relative working-directory overrides where the job's defaults.run.working-directory: website would have broken the relative-path commands).
Expected speedup: ~30 s → ~1 s on hit per CI run, multiplied by every PR + every dev push + every main push + every release tag (release.yml's docs:build step also benefits via the same cross-ref fallthrough).
Force-push accounting (final)¶
Every promote step is now a plain push:
| Step | Operation | Force? |
|---|---|---|
| Push main | new commits + version bump (FF) | ✗ |
| Push release tag (vX.Y.Z) | new ref | ✗ |
| Push dev | merge-back commit (FF) | ✗ |
| (removed) | — |
Zero force-pushes. Forever (until proven otherwise).
v1.7.16 — 2026-04-26 — Stacking-safe README regen + strip stale version refs + plain-language bootstrap reasons + tag-→-branch refactor¶
Four-part hygiene release: (1) eliminate the last force-push in the promote flow by converting dev-shipped from a tag to a branch, (2) fix the merge-back-conflict footgun on stacked release commits, (3) strip stale obsidian-brain version refs from user-facing strings, (4) rewrite bootstrap reason strings in plain English.
1. dev-shipped tag → branch — zero force-pushes in normal promote flow¶
scripts/promote.mjs previously ended every release with:
The -f was required because git tags are immutable by default — the only way to "move" a tag is to overwrite it. RELEASING.md called this "safe" (rulesets target branches, not tags), but it conflicted with the user's blanket no-force-push policy and triggered repeated permission denials in agent-driven workflows.
The fix: dev-shipped is now a branch, not a tag. Branches are designed to move forward; advancing one is a fast-forward push (no -f needed) because dev-shipped only ever advances along dev's first-parent chain to a descendant of its previous SHA.
Changes in scripts/promote.mjs:
- Read base SHA from
refs/heads/dev-shippedfirst, falling back torefs/tags/dev-shippedfor the one-time legacy migration, falling back togit merge-base origin/main <target>if neither is seeded yet. - Final advance step replaced:
git branch -f dev-shipped <sha>+git push origin dev-shipped(plain push). The legacy tag is auto-deleted on first encounter (idempotent). - Stale-ref-detection error message updated to reference the branch advance command.
- Manual-fallback hints in the merge-back-conflict error message updated likewise.
After this release, the promote flow has zero force-pushes:
| Step | Before | After |
|---|---|---|
git push origin main |
plain | plain |
git push origin v1.7.X |
plain (new ref) | plain (new ref) |
git push origin dev |
plain (FF) | plain (FF) |
Advance dev-shipped |
git push -f origin refs/tags/dev-shipped |
git push origin dev-shipped |
Defence-in-depth: anyone trying to rewrite dev-shipped (the only thing the no-force-push rule was ever protecting against) gets rejected by GitHub's default "branches can only be fast-forwarded" semantics.
2. Tag-aware gen-readme-recent — stacking-safe promotes restored¶
The footgun: scripts/gen-readme-recent.mjs (added around v1.7.9) regenerates the entire 5-line "Recent releases" block in README.md from docs/CHANGELOG.md headers. Every release commit on dev rewrote the same 5 lines with a different shifted body. Two stacked release commits on dev = guaranteed conflict at merge-back time, because git's 3-way merge sees both sides modifying the same hunk with different content.
This made the long-standing "stack 30 commits then promote v1, v2, v3 in succession" workflow fail the moment two of those commits each carried a CHANGELOG entry + README regen — exactly what happened mid-session promoting v1.7.15 with v1.7.16 already stacked above it.
The fix:
scripts/gen-readme-recent.mjs— readgit tag -l v*and the currentpackage.jsonversion, and filter CHANGELOG entries to only versions that have actually shipped (have a tag) OR the in-flight version currently inpackage.json. In-flight CHANGELOG entries (added on dev for an unpromoted release) no longer leak into README. The--checkmode passes on dev with a stacked entry because the regenerated block correctly excludes it.package.jsonversionlifecycle hook — rungen-readme-recentandgit add README.mdalongside the existingsync-server-versionstep. Whennpm versionbumpspackage.jsonto the new release version, the hook fires, the regen seescurrentVersionmatches the in-flight version, and the new release is added to the README block. The README change is staged and committed as part of the version-bump commit (no follow-up commit needed).execFileSyncoverexecSyncfor thegit tag -l v*call so shell glob-expansion doesn't eat thev*pattern (zsh /nullglob-style configs surface unmatched globs as empty).
End result:
- Release commits on dev no longer touch the README "Recent releases" block. Just CHANGELOG.
- Two (or thirty) stacked release commits = no merge-back conflict. The diff between dev's tip and main's cherry-picked twins doesn't include README anymore — main's README only changes during the version-bump commit, which is reachable from both sides via the merge-back's common-ancestor logic.
- The "promote v1, v2, v3 in quick succession from a stacked dev" workflow that worked pre-v1.7.9 is restored.
3. Strip stale obsidian-brain version refs from user-facing strings¶
After v1.7.14 fixed the npx silent-crash and v1.7.15 deepened the debug trace, the surviving boot still emitted a few stderr messages whose phrasing was rotting — they referenced obsidian-brain release versions ((v1.4.0 upgrade), (first v1.5.1 boot)) that no longer mean anything to a user landing on v1.7.16 fresh.
Strings stripped:
User-facing strings where an obsidian-brain self-version ref had no current meaning:
src/pipeline/bootstrap.ts:266—'chunk table is empty — rebuilding per-chunk embeddings (v1.4.0 upgrade)'→ drops the parenthetical.src/pipeline/bootstrap.ts:334—'prefix strategy changed for ${model} (first v1.5.1 boot) — re-embedding…'→ drops the parenthetical.src/server.ts:111—'obsidian-brain: v1.4.0 upgrade: building per-chunk embeddings…'→'obsidian-brain: rebuilding per-chunk embeddings…'.src/tools/reindex.ts:11— drops'(including any left behind by pre-v1.5.8 move/delete bugs)'from the tool description.src/tools/search.ts:12— drops the leading'Since v1.4.0'from the chunk-level-search description.src/tools/base-query.ts:18— drops'Supported v1.4.0 subset'and'they ship in v1.4.1 / v1.4.2 / v1.4.3 patches'.src/cli/index.ts:80— drops'since v1.4.0 the bootstrap auto-detects'from the--drophelp text.src/obsidian/client.ts:188—'requires the companion plugin v1.4.0 or later'→'requires a recent obsidian-brain companion plugin'(the actual minimum surfaces in the discovery error already; the version literal in the message itself was load-bearing for nothing).
External-package compat refs preserved (still genuinely useful): Obsidian core version requirement (Obsidian 1.10.0+), companion-plugin Dataview compat (v0.2.0+ for dataview_query), HF model identifiers (Xenova/bge-small-en-v1.5 and friends — those are model names, not obsidian-brain versions).
4. Plain-language bootstrap reasons¶
The bootstrap module's reindex reasons (the lines starting with obsidian-brain: ... that print whenever the embedder identity, schema, or chunk-table state changes) were written for the implementer, not the user. Phrases like prefix strategy changed, FTS tokenizer changed: porter -> trigram; rebuilding nodes_fts, and switched to symmetric model — reindexing document chunks to drop stale query-prefix-assumed vectors told a user nothing useful. Rewritten:
embedder changed: X(384d) -> Y(768d)→embedding model changed: X → Y — re-embedding all notesembedder identity hash changed for X: weights swapped under the same model id (probably an \ollama pull` updated the tag) — re-embedding to match the new weights→model weights for X were updated (probably an `ollama pull`) — re-embedding to match the new weights`chunk table is empty — rebuilding per-chunk embeddings→first-time paragraph-level indexing — re-embedding all notesFTS tokenizer changed: porter -> trigram; rebuilding nodes_fts→full-text search tokenization updated (porter → trigram) — rebuilding the search indexschema version changed: 5 → 7→internal data layout updated — re-embedding to matchswitched to symmetric model — reindexing document chunks to drop stale query-prefix-assumed vectors→embedding model now treats queries and documents the same way — re-embedding to matchprefix strategy changed for X — re-embedding document chunks with correct prefix→embedding model X uses different query/document prefixes than before — re-embedding for accurate search
Same operational meaning, plain English. The technical detail (prefix strategies, FTS5 tokenizers, dim markers) lives in code comments where it belongs.
Clearer catchup message¶
src/server.ts — the "startup catchup — reindexed N notes modified while the server was down" message was misleading when bootstrap had wiped the sync table (model change, schema upgrade, prefix-strategy change). In that case every note gets reindexed, not just the externally-modified ones. Now branches:
- bootstrap forced reindex →
'startup catchup — reindexed N note(s) (re-embedded after model/schema change)' - normal incremental →
'startup catchup — reindexed N note(s) (modified while the server was down)'
Same one-line format, but the parenthetical now matches reality.
CI gate¶
test/docs/no-version-refs.test.ts — extended to scan src/**/*.ts string-literal content (not comments) for vX.Y(.Z) patterns. Allowlist preserves three legitimate compat-ref files: src/obsidian/client.ts (companion-plugin compat error), src/tools/dataview-query.ts (companion-plugin compat in tool description), src/store/db.ts (internal SQL DDL comments inside CREATE-TABLE template literals). The regex uses a negative lookbehind (?<![\w-]) so HF model names like bge-small-en-v1.5 don't trigger.
This means future PRs can't reintroduce since vX.Y / as of vX.Y / (vX.Y upgrade) cruft in tool descriptions or stderr without the test going red.
Test totals¶
939 → 941 vitest passing (unchanged from v1.7.15). Preflight 11/11 green.
v1.7.15 — 2026-04-26 — Close the remaining debug-trace gaps (isMainEntry, parseAsync, ensureEmbedderReady)¶
Diagnostic-only release. No runtime behaviour change. After the hunt that took us from v1.7.5 → v1.7.14, three thin spots remained in the OBSIDIAN_BRAIN_DEBUG=1 trace where a future silent crash could hide. v1.7.15 closes them.
Why these spots matter¶
Today's trace covers preflight (4 lines), all 26 module-loads (one per module), and most of the boot path inside server.ts/context.ts (~30 debugLog calls). But three sequences ran without any markers:
-
Inside
isMainEntry()(added in v1.7.14): no log of what realpathSync resolved each side to, no log of whether realpath threw and we fell back to raw comparison. If the v1.7.14 fix ever misbehaves under a new edge case (--preserve-symlinks, case-insensitive filesystem with mixed-case paths, symlink loops), we'd be blind to why. -
Around
parseAsync: we see the subcommand action fire (cli: 'server' subcommand action entered), but no log of "Commander dispatch beginning" or "parseAsync resolved cleanly". A hang inside Commander's argv parsing — rare, but possible — would leave the trace dangling. -
Inside
ensureEmbedderReady's init promise: four async steps run in sequence without markers between —embedder.init()(HF model download or Ollama probe),resolveModelMetadata()(cache → seed → HF lookup),bootstrap()(model/schema reconciliation),ensureVecTables()(sqlite-vec table creation). If any one of those hangs, the trace stops atbackground: ensureEmbedderReadywith no progress info.
What v1.7.15 adds¶
src/cli/index.ts:
- isMainEntry: logs the resolved paths from realpathSync on both sides plus the match=true|false outcome. Logs the fallback path explicitly when realpathSync throws.
- Around parseAsync: a "building program + invoking parseAsync" log before the call, and a "parseAsync resolved cleanly (subcommand handler returned)" log via .then() after.
src/context.ts ensureEmbedderReady:
- "first call — building init promise" before the IIFE
- "calling embedder.init() (may download model on first run)" / "embedder.init() OK (dim=N)"
- "resolving model metadata (cache → seed → HF)" / "metadata resolved, calling embedder.setMetadata"
- "calling bootstrap (model/schema reconciliation)" / "bootstrap returned (needsReindex=…)"
- "vec tables ensured, init COMPLETE"
That makes the silent-failure detective work for the embedder/model path identical to what we already get for the natives + module-load + main-entry path: every step logs a before-and-after, so the LAST line in the trace pinpoints the operation in flight when it stalled.
What this is NOT¶
- Not a behaviour change. Every new line is
debugLog(...)— gated onOBSIDIAN_BRAIN_DEBUG=1viasrc/util/debug-log.ts. With debug off (the default), zero output, zero overhead. Verified bytest/util/debug-log.test.ts's "returns without invoking writeSync when DEBUG unset" case. - Not a fix for an active bug. v1.7.14 fixed the active silent-crash bug. v1.7.15 hardens the diagnostic trace so the next silent crash (if there ever is one) localizes faster.
Test totals¶
939 → 941 vitest passing (+2 from v1.7.14's symlink-invocation.test.ts, unchanged here). Preflight 11/11 green.
v1.7.14 — 2026-04-26 — Fix the npx-symlink silent crash (the actual fix v1.7.5 → v1.7.13 was hunting)¶
This is the fix. v1.7.13's debug trace empirically pinpointed the bug in production npx invocation; v1.7.14 corrects the main-entry guard so the symlinked invocation path actually starts the server.
The bug, in one sentence¶
process.argv[1] === fileURLToPath(import.meta.url) is a structurally broken main-entry idiom under symlinked invocation: one side (argv[1]) is the symlink path Node was launched with, the other side (import.meta.url) is the real-file URL Node's ESM loader resolved through the symlink. They don't match. The if block is skipped. The event loop drains. The process exits with code 0. Claude Desktop sees stdio EOF and reports "transport closed unexpectedly" — with no error in the log because there was no error.
This affects every symlinked invocation path: npx (uses .bin/<name> symlinks), pnpm bin, yarn-link, manually-symlinked installs. It is independent of Node version and npm version — Talal hit it on Node 22.22.2 / npm 10.x; the user hit it on Node 24.14.1 / npm 11.12.1.
The fix¶
src/cli/index.ts — three changes:
-
Replace the inline strict-equality check with an
isMainEntry()helper that callsrealpathSyncon both sides before comparing. This normalizes any symlinked path on either side to the same target. -
Wrap realpathSync in try/catch with a raw-comparison fallback. realpathSync throws ENOENT if argv[1] points to a non-existent path; the fallback preserves pre-v1.7.14 behaviour for that pathological case rather than making it worse.
-
Realpath both sides, not just
argv[1]. Under--preserve-symlinks(deliberate Node opt-in)import.meta.urlis the symlink URL — a one-sided realpath would re-introduce the asymmetry in the opposite direction. Cost is negligible (~50 µs total).
function isMainEntry(): boolean {
const argv1 = process.argv[1];
if (!argv1) return false;
const modulePath = fileURLToPath(import.meta.url);
try {
return realpathSync(argv1) === realpathSync(modulePath);
} catch {
return argv1 === modulePath;
}
}
Why test imports stay broken (correctly)¶
vitest worker invokes the file as import('…/src/cli/index.ts'). argv[1] is the worker entrypoint, not cli/index.ts. realpath(worker) ≠ realpath(cli/index.ts), so isMainEntry() correctly returns false and parseAsync doesn't fire. Tests can import buildProgram() and snapshot help text without spawning the CLI. Existing test/cli/help-snapshot.test.ts exercises this path; it stayed green through the fix.
Regression test¶
New test/cli/symlink-invocation.test.ts — two cases:
- Direct node invocation:
node dist/cli/index.js --versionreturns the version string. - Symlink invocation: creates a temp symlink to
dist/cli/index.js, runsnode <symlink> --version, expects the version string.
The symlink case fails on v1.7.13 and earlier (timeout or empty output, depending on how the test harness handles a clean exit-0 with no stdout). Passes on v1.7.14.
What v1.7.5 → v1.7.13 actually fixed¶
Looking back, every release in the chain closed a real failure class — they were not wasted work, just not the failure mode that was firing in production:
- v1.7.5 — bundled seed + metadata cache (HF outage protection)
- v1.7.7 — preflight wrapper (catches native-module load crashes that fire before any try/catch is on the stack)
- v1.7.8 / v1.7.10 — release-pipeline cache hygiene (prevents the rebuild-fail-silently-then-ship-broken-tarball class)
- v1.7.11 — global error nets +
OBSIDIAN_BRAIN_DEBUG=1startup trace (closes any future async-error silent-crash class) - v1.7.12 — module-load markers in heavy import paths (pinpoint which module was being evaluated when a transitive native crash happens)
- v1.7.13 — argv-check diagnostic (the one that flushed THIS bug out)
Without that stack of layers, v1.7.14's diagnosis would have been impossible — every layer eliminated a different failure-mode hypothesis until only this one remained, and the final layer's debug log printed it character-for-character. All six layers stay in the codebase. They protect against future failure classes, even though THIS specific bug turned out to be one line of comparison logic at the bottom of cli/index.ts.
Test totals¶
939 → 941 vitest passing (+2 from symlink-invocation.test.ts). Preflight 11/11 green.
v1.7.13 — 2026-04-26 — Pinpoint the npx-symlink silent crash + 16 more module-load markers¶
Diagnostic-only release. No behaviour change for working installs. The new debug output, when OBSIDIAN_BRAIN_DEBUG=1 is set, empirically identifies the root cause of the silent-crash class that has dogged v1.7.5 → v1.7.12: under npx invocation, the cli/index.ts main-entry guard never fires.
The argv-check diagnostic (the big one)¶
The bottom of src/cli/index.ts has guarded the actual server bootstrap behind:
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
buildProgram().parseAsync(process.argv).catch(...)
}
The intent: only run as a CLI when executed directly, skip when imported by tests. Under npx -y obsidian-brain@latest server, npm creates a .bin/obsidian-brain symlink that points at <pkg>/dist/cli/index.js. Node sets process.argv[1] to the symlink path (the thing it was launched with), but fileURLToPath(import.meta.url) resolves through the symlink and returns the real path. They are not equal. The if block is skipped. No parseAsync. No async work. The event loop drains. The process exits cleanly with code 0. Claude Desktop sees stdio EOF and reports "transport closed unexpectedly, exiting early" — with no error in the log because there was no error.
This release adds debug logs on both branches of that check, so the trace shows exactly which branch was taken and the actual values of argv[1] vs fileURLToPath(import.meta.url):
cli: about to check main-entry — argv[1]="/Users/u/.npm/_npx/<hash>/node_modules/.bin/obsidian-brain" import.meta.url="file:///Users/u/.npm/_npx/<hash>/node_modules/obsidian-brain/dist/cli/index.js" fileURLToPath="/Users/u/.npm/_npx/<hash>/node_modules/obsidian-brain/dist/cli/index.js"
cli: main-entry check FAILED — process will exit cleanly when event loop drains. argv[1]="…/.bin/obsidian-brain" fileURLToPath="…/dist/cli/index.js" — these don't match. Likely cause: invoked via symlink (npx .bin shim). Server will not start.
Verified empirically. Created /tmp/sl/obsidian-brain-shim → dist/cli/index.js, ran via the shim with OBSIDIAN_BRAIN_DEBUG=1, observed the FAILED branch fire with the exact paths above. Ran the same binary directly (no symlink), observed cli: main-entry check PASSED — entry point reached, argv = ["server"]. Reproduces deterministically.
This is the same bug Talal hit on Node 22.22.2 / npm 10.x — it has nothing to do with npm 11.x's stdio-pipe regression. v1.7.5 → v1.7.12 chased the wrong hypothesis. The actual fix (use realpathSync to resolve both sides of the comparison before comparing) lands in v1.7.14.
16 more module-load: markers¶
Continued the v1.7.12 pattern across every remaining src file that does non-trivial work at import time. When the trace cuts off mid-startup, the LAST module-load: line is the last module whose import phase completed — the next module's evaluation is where it died.
Markers added to:
- src/cli/index.ts (after all imports complete, after package.json read)
- src/cli/models.ts (commands subroutine)
- src/config.ts (env-var parsing, vault-path resolution)
- src/embeddings/auto-recommend.ts, capacity.ts, chunker.ts, hf-metadata.ts, metadata-cache.ts, overrides.ts, presets.ts, seed-loader.ts
- src/obsidian/client.ts (REST client to the desktop app)
- src/search/unified.ts (RRF fusion)
- src/util/errors.ts (error formatter)
- src/vault/parser.ts, vault/writer.ts (markdown round-trip)
All gated on OBSIDIAN_BRAIN_DEBUG === '1' via debugLog. Zero output / overhead when disabled — verified by test/util/debug-log.test.ts.
src/embeddings/prefetch.ts deliberately excluded: the initial v1.7.13 push added a marker there, which broke CI with ERR_MODULE_NOT_FOUND: '../util/debug-log.js'. Root cause: scripts/prefetch-test-models.mjs loads prefetch.ts via Node 24's native TS strip-only loader (plain node scripts/…, not tsx), and that loader resolves imports literally — it refuses to rewrite .js → .ts like vitest/tsx do. So adding any internal .js-extensioned import to prefetch.ts breaks the prefetch CI step. Reverted the marker, added a load-bearing comment in prefetch.ts so a future reader doesn't reintroduce it. prefetch.ts is a leaf helper on the CI / models prefetch CLI path — not on the MCP-server boot path — so it doesn't need the diagnostic marker anyway.
Granular cli/index.ts logs¶
Beyond the argv-check diagnostic, added markers at every non-trivial step of cli/index.ts's top-level evaluation so the trace shows progress through:
- cli: 'server' subcommand action entered, calling startServer() — before await startServer()
- cli: startServer() returned (server is now running, awaiting transport messages) — after, useful when the crash is mid-server, not pre-server
Test totals¶
939 → 939 vitest passing. Preflight 11/11 green. YAML lint clean.
v1.7.12 — 2026-04-26 — Cache MTEB venv (release pip-install ~60 s → ~1 s) + module-load debug markers¶
Bundles two changes: the venv-cache speedup for the release workflow, and an additional layer of OBSIDIAN_BRAIN_DEBUG=1 debug instrumentation that pinpoints which heavy module dies during silent crashes.
Module-load debug markers¶
Added debugLog('module-load: <path>') at the top of every heavy module's body so when OBSIDIAN_BRAIN_DEBUG=1 is set, the trace shows the EXACT module that was being evaluated when a silent crash happened. ESM imports are evaluated depth-first synchronously — if a transitive native crash (e.g., onnxruntime-node's .node binding fails to load with SIGSEGV/SIGABRT) happens during a module's import phase, JS error handlers can't catch it. But the LAST module-load log we see in the trace pinpoints which module's import chain was being evaluated.
Markers added to:
- src/global-handlers.ts (inline writeSync to avoid circular-import edge case at this critical point — module's whole purpose is registering process error handlers)
- src/context.ts, src/server.ts (already imported debugLog)
- src/store/db.ts (better-sqlite3 + sqlite-vec module-top-level)
- src/embeddings/factory.ts (presets glue)
- src/embeddings/embedder.ts (PRIME SUSPECT — first import is @huggingface/transformers which transitively loads onnxruntime-node native binding; the marker line module-load: src/embeddings/embedder.ts (transformers loaded OK) fires AFTER that import succeeds, so its absence in a debug trace pinpoints the culprit)
- src/embeddings/ollama.ts
- src/embeddings/metadata-resolver.ts
- src/pipeline/indexer.ts
- src/pipeline/bootstrap.ts
- src/pipeline/watcher.ts
All gated on OBSIDIAN_BRAIN_DEBUG === '1'. Zero output, zero overhead when disabled — verified by an existing in-process test in test/util/debug-log.test.ts (debugLog() returns without invoking writeSync when DEBUG unset).
Diagnostic flow for a silent crash:
1. Set OBSIDIAN_BRAIN_DEBUG=1 in the MCP client config's env block
2. Restart the client
3. Read ~/Library/Logs/Claude/mcp-server-obsidian-brain.log
4. The LAST module-load: line tells you which module was being evaluated when the crash happened
5. If that line is embedder.ts (transformers loaded OK), the crash is in code AFTER transformers loaded
6. If that line is the previous module's marker (e.g., store/db.ts), the crash is in embedder.ts's import chain — likely @huggingface/transformers → onnxruntime-node
Venv cache (the original v1.7.12 change)¶
No user-visible runtime change. Release-process hygiene only — does not alter anything that ships in the npm tarball.
Pre-v1.7.12 we cached ~/.cache/pip (pip's wheel + http download cache) from a ci.yml main-push job, scoped to refs/heads/main so tag-pushed release.yml runs read it via cross-ref fallthrough. That worked — cache hit on every release tag — but pip install -r scripts/requirements-build-seed.txt still ran end-to-end on each release: parse the manifest, resolve the dep graph, copy ~thousands of wheel files into a fresh site-packages. ~60 s.
v1.7.12 caches the entire populated venv at ~/.venv-mteb instead. On a hit, site-packages is already there and release.yml runs ~/.venv-mteb/bin/python scripts/build-seed.py directly — pip is not invoked. Reported speedup for similar MTEB/torch stacks: ~60 s → ~1 s on hit (Adam Johnson, Simon Willison, AI2 ML team writeups).
-
.github/workflows/ci.yml—warm-mteb-pip-cachejob renamed towarm-mteb-venv. Now creates a venv at~/.venv-mteb, runspip installinto the venv on cache miss, saves the entire~/.venv-mtebdirectory. The job stays gated ongithub.ref == 'refs/heads/main' && github.event_name == 'push'so dev / PR runs don't pay the install cost. -
.github/workflows/release.yml—Restore MTEB pip cachestep replaced withRestore MTEB venv. TheInstall mtebstep is now conditional (if: cache-hit != 'true') and creates the venv as a fallback only when the cache miss is real. TheRegenerate model seed JSONstep uses~/.venv-mteb/bin/pythondirectly. On a cache hit (the common path), zero pip invocations happen — venv hits, build-seed runs, done. -
Cache key includes the FULL Python patch version (e.g.
py3.12.8, notpy3.12). Patch drift breaks venvs in two ways: (1) the venv'sbin/pythonshebang references a Python that may not exist on the new runner image, and (2) native extensions in torch/scipy are compiled against a specific Python ABI. Embedding the full version means a runner image's Python patch bump triggers a clean miss → rebuild → save under a new key, rather than a partial restore that fails at runtime. Documented case study atluke.hsiao.devfor why patch-level keying matters. -
No
restore-keysfallback. Same reason: a fuzzy fallback that matchespy3.12.7against apy3.12.8runtime is exactly the failure mode we want to avoid. Cache miss → clean rebuild is the safe default. -
Manifest hash in the key.
hashFiles('scripts/requirements-build-seed.txt')means any constraint change (e.g. mteb major bump) automatically triggers a new cache key without manual-v1→-v2work. Manual cache-buster suffix is still there for situations where we need to force a rebuild without changing the manifest (e.g. CVE patch in a transitive dep cached pre-fix). -
Bootstrap path: v1.7.12's release run gets a cache MISS (key changed from
mteb-pip-2.12-v1to the new venv key — different shape). It rebuilds + saves the venv to main scope. v1.7.13+ release tag pushes get the fallthrough hit and skip pip install entirely.
Test totals¶
939 → 939 vitest passing. The 11 module-load debug markers add one debug-call line per module — gated on env var, no-op in tests (which don't set OBSIDIAN_BRAIN_DEBUG=1). Existing test suites confirm the gate works. Preflight 11/11 green. YAML lint clean.
v1.7.11 — 2026-04-26 — Global error nets + OBSIDIAN_BRAIN_DEBUG=1 startup trace + enriched boot banner¶
Diagnostic infrastructure release. Doesn't fix the npm 11.x npx-stdin bug (out of our control) but converts every silent crash class — present and future — into a noisy crash with a recoverable error log on disk.
Global error nets¶
-
src/global-handlers.ts— registersprocess.on('uncaughtException')andprocess.on('unhandledRejection')at module-import time, immediately afterpreflight.tsincli/index.ts. Both handlers write synchronously to fd 2 viafs.writeSync(2, …)AND to~/.cache/obsidian-brain/last-startup-error.log. Both callprocess.exit(1)only AFTER the sync writes return. Closes the silent-crash class that bit the v1.7.5/v1.7.6/v1.7.7 cohort: any async error that escapes our explicitparseAsync().catchincli/index.tsandstartServer().catchinserver.ts(chokidar event handlers, MCP SDK transport callbacks, transitive-dep EventEmitters, fire-and-forgetvoid (async () => …)()blocks,setTimeoutcallbacks) used to fall through to Node's default handler — which writes async to stderr and races the implicit exit. Now the same error path is fully synchronous and recoverable. -
Marker line in the crash log distinguishes the new failure types from preflight's native-module-load crashes:
-
Tests: new
test/global-handlers.test.ts(9 cases):recordCrashshape for both kinds, non-Error reasons viaString()coercion, file-write failure tolerance,process.exit(1)invocation on the handler path, listener registration on import. Plus newtest/util/debug-log.test.ts(16 cases): gate behavior across all env-var values, in-process write verification viavi.mock('node:fs')for clean coverage credit AND a child-process suite that spawns a real Node process to verify actual stderr output end-to-end. Both files keep the test runner's stderr clean —vi.mockreplacesfs.writeSyncwith avi.fn()so the in-process tests don't pollute test output.
OBSIDIAN_BRAIN_DEBUG=1 startup trace¶
-
src/util/debug-log.ts— synchronous stderr trace gated onOBSIDIAN_BRAIN_DEBUG=1. Samefs.writeSync(2, …)pattern so the LAST debug line before a crash always reaches the MCP client's stderr. Format:obsidian-brain debug [+<ms>]: <msg>with monotonic milliseconds since process start. -
Read at the absolute earliest moment.
OBSIDIAN_BRAIN_DEBUGis captured as the first executable statement ofsrc/preflight.ts— beforecreateRequire, before native-module loads, before any other top-level work. Defensive design: even an unforeseen module-init crash still has the debug trace function armed and ready to fire on the LAST step before the crash. The first two debug lines (preflight: module loaded (debug mode active)andpreflight: createRequire resolved) appear BEFORE the boot banner when debug mode is on, confirming the env var was read before any other state was evaluated. -
Boot banner shows debug status. The standard banner now ends with
debug=onordebug=offso users can confirm at boot — without enabling trace mode — whether the env var was read correctly: -
Trace points wired into the entire startup path (~25 checkpoints):
preflight.ts— per-native-module load attempt + resultcli/index.ts— entry argv +serversubcommand action entry/exitcontext.ts— resolveConfig → openDb → createEmbedder → wiring completeserver.ts— startServer entry → tools registered →dbIsEmptydecision →server.connectbefore/after → background block entry/exit → watcher → signal handlers → orphan-PPID watchdog → return-
server.ts— shutdown invocation, watcher close, embedder dispose, DB close, teardown errors -
No-op when not enabled — single env-var check up-front, debug calls early-return. No measurable overhead in production.
-
How to enable in any MCP client config (
Trace appears inclaude_desktop_config.json, etc.):~/Library/Logs/Claude/mcp-server-obsidian-brain.logon macOS. The LAST line before any silent failure tells the user (and us) exactly which step the server reached before things went wrong.
Enriched boot banner¶
preflight.tsbanner now includes:- obsidian-brain version (read from
package.jsonviacreateRequire) - npm version (parsed from
process.env.npm_config_user_agent— set by npm/npx when they spawn us;n/awhen invoked via rawnode) - Existing fields: Node version,
NODE_MODULE_VERSIONABI, platform-archDiagnostic for the npm 11.x stdio-pipe bug: a log entry showingobsidian-brain: starting (v1.7.11, Node v24.14.1, NODE_MODULE_VERSION 137, npm 11.12.1, platform darwin-arm64)npm 11.ximmediately implicates that bug class. A log entry showingnpm n/aconfirms the user is invoking us vianodedirectly (the workaround).
What this DOES NOT fix¶
-
Sammy's bug (npm 11.x stdio detach via
npx -y obsidian-brain@latest) — out of our control, lives in npm's wrapper. Workaround: usenpx -y /abs/path/dist/cli/index.js server(skips npm's install machinery) ornode /abs/path/dist/cli/index.js serverdirectly. -
Talal's bug (silent 2.4 s exit on Node 22 / npm 10.x) — we can't reproduce locally, but v1.7.11 makes the next occurrence diagnose itself: any unhandled error will land in
~/.cache/obsidian-brain/last-startup-error.log, andOBSIDIAN_BRAIN_DEBUG=1will print exactly which step the server reached.
Test totals¶
902 → 939 vitest passing (added 9 cases for global-handlers.test.ts + 16 cases for debug-log.test.ts + 2 README dead-link tests already shipped in v1.7.9). Preflight 11/11 green (added gen-readme-recent --check step).
v1.7.10 — 2026-04-26 — Move MTEB pip cache save to ci.yml on main pushes (cross-tag fallthrough)¶
No user-visible runtime change. Release-process hygiene only.
v1.7.8 added a manual actions/cache/save@v5 for the MTEB pip cache inside release.yml, which fires on tag pushes. Each release saved a 2.7 GB cache, but the next release tag couldn't read it. v1.7.9's gh cache list showed the smoking gun — two separate caches with the same key under different ref scopes:
2026-04-26T12:14:03Z refs/heads/refs/tags/v1.7.9 mteb-pip-2.12-v1 (2730 MB)
2026-04-26T12:04:29Z refs/heads/refs/tags/v1.7.8 mteb-pip-2.12-v1 (2730 MB)
Per GitHub Actions cache docs:
"Workflow runs cannot restore caches created for different tag names. A cache created for the tag release-a with the base main would not be accessible to a workflow run triggered for the tag release-b."
The HF embedding-models cache pattern in ci.yml doesn't have this problem — caches saved on the default branch (main) automatically fall through to all other refs (branches AND tags). The fix mirrors that: save on a workflow that runs on push: main, restore from any ref via cross-ref fallthrough.
-
.github/workflows/ci.yml— added a new parallelwarm-mteb-pip-cachejob, gated onif: github.ref == 'refs/heads/main' && github.event_name == 'push'. Same split-restore/save pattern as the existing HF cache. Self-throttling — when the cache is already warm (the common case), the job finishes in ~10 s and skips both install and save. Only fires the 60-second pip install + 2.7 GB upload on a true miss (after a deliberate-v1→-v2key bump). Doesn't run on dev pushes or PRs. -
.github/workflows/release.yml— kept theRestore MTEB pip cachestep (now hits main's cache via fallthrough), removed theSave MTEB pip cachestep (was creating useless tag-scoped caches). TheInstall mtebstep keepspip install -r ...so it can read the restored wheels and produceUsing cached <wheel>output instead ofDownloading <wheel>. -
What this fixes for users: every release ran ~60 seconds of pip install + 2.7 GB cache upload, completely wasted because no future release could read it. After v1.7.10 is merged to main, the next ci.yml main push warms the cache once. Every subsequent release tag push reads it instantly. Saves ~60 s + 2.7 GB per release going forward.
-
Bootstrapping: v1.7.10's promote tag push will fire release.yml first (cache miss, install runs as before), then ci.yml on the merge-back commit warms main's cache. v1.7.11+ release tag pushes get the fallthrough hit.
v1.7.9 — 2026-04-26 — README "Recent releases" heading restore + new dead-link test for README¶
Docs hygiene only — no runtime change.
-
Restored the
## Recent releasesheading in README.md. When v1.7.8 added<!-- GENERATED:recent-releases -->markers around the bullet list, the heading immediately above was deleted alongside the old hand-maintained content. The bullets rendered fine but had no header above them, leaving the page-anchor[Recent releases](#recent-releases)in the README's table-of-contents (top of the file) silently broken. Heading restored above the marker block.gen-readme-recent --checkconfirms the markers + content are otherwise unchanged. -
New
test/docs/readme-links.test.ts— catches dead links in README.md going forward. Vitest suite that parses every markdown link in README and validates: - Internal anchors (
[text](#slug)) — must match an## Headingin README.md. Catches the v1.7.8 regression directly: deleting## Recent releaseswould have failed this test before v1.7.9 shipped. - Relative paths (
[text](docs/foo.md),[text](LICENSE)) — must exist on disk. Catches typos and references to docs that were renamed/deleted without a README sweep. - Anchored relative paths (
[text](docs/foo.md#section)) — file must exist AND the anchor must resolve to a heading in that file. - External URLs (
https://,mailto:,tel:) — skipped. Network-dependent, flaky; link-rot needs a different cadence (weekly cron, not per-commit). - Code blocks — fenced
codeand inlinecodeare stripped before parsing so example URLs in documentation don't get validated.
Slug algorithm mirrors GitHub's heading-anchor rule: lowercase, strip non-(word|space|hyphen) chars, collapse whitespace to single hyphens. Verified against the README's own table-of-contents at the top of the file as the canonical fixture. Sanity guard requires the parser to find at least 10 links — catches the case where someone accidentally breaks the regex and the suite passes vacuously.
-
docs/**.mdalready covered bymkdocs build --strictin thedocs:buildpreflight + CI step, so this new suite stays focused on README.md (which mkdocs doesn't render). -
Test totals: 902 → 906 vitest passing.
v1.7.8 — 2026-04-26 — Fix EMBEDDING_PRESET=multilingual-ollama silently using nomic-embed-text + consolidate preset resolution¶
Bug fix. Pre-v1.7.8, setting EMBEDDING_PRESET=multilingual-ollama (added in v1.7.5 Plan B to use qwen3-embedding:0.6b) silently fell through to the hardcoded Ollama default nomic-embed-text. Users without nomic-embed-text pulled in their local Ollama saw startup failures (HTTP 404 Not Found — model "nomic-embed-text" not found); users WITH nomic-embed-text pulled got the wrong model embedded into their vault. The preset-declared model (qwen3-embedding:0.6b) was never reaching the OllamaEmbedder constructor.
Root cause. src/embeddings/factory.ts:28 Ollama branch resolved the model independently of the preset registry: const model = process.env.EMBEDDING_MODEL ?? 'nomic-embed-text'. The transformers branch (line 47) correctly called resolveEmbeddingModel(process.env) which honors EMBEDDING_PRESET; the ollama branch never did. resolveEmbeddingProvider would correctly read the preset and return 'ollama', but once inside the ollama branch the preset's declared model was silently dropped. Architectural drift introduced when v1.5.0-F first added the Ollama provider; never noticed because the unit tests for resolveEmbeddingModel passed (the function works correctly in isolation) and there was no integration test for createEmbedder() that exercised the preset → embedder path end-to-end.
src/embeddings/presets.tsis now the single source of truth for everything preset-related:EMBEDDING_PRESETS— the 6-preset registry (unchanged data; newas const satisfiesshape carries through to consumers).DEFAULT_PRESET = 'english'— name of the preset that applies when no preset/model is set. Replaces hardcoded'english'strings scattered across resolvers.DEFAULT_OLLAMA_MODEL = 'nomic-embed-text'— the Ollama model used as a fallback when the user explicitly setsEMBEDDING_PROVIDER=ollamawithout naming a model or a matching preset. Replaces TWO parallel hardcodes (factory.ts + ollama.ts constructor default).resolvePresetConfig(env)— the new atomic resolver. Returns{ provider, model, presetName, source }together so consumers can't desync the (provider, model) pair. Every consumer must call this; re-implementing env-var precedence locally is the architectural mistake we just lived through.-
resolveEmbeddingProvider/resolveEmbeddingModel— kept as thin back-compat wrappers aroundresolvePresetConfig. Existing callers (auto-recommend.ts,cli/models.ts, every test file) keep working unchanged. -
src/embeddings/factory.ts— the actual fix. Collapsed to a singleresolvePresetConfig(process.env)call at the top, then branches oncfg.providerand usescfg.model. There is no longer any path throughcreateEmbedder()where provider and model can desync. Bug 2 is structurally impossible. -
src/embeddings/ollama.ts:37— constructor'smodeldefault-param was a parallel hardcode of'nomic-embed-text'. Now imports and usesDEFAULT_OLLAMA_MODEL. Changing the Ollama default in the future is one line, one file. -
New behavior — provider/preset mismatch warning. Setting
EMBEDDING_PROVIDER=ollamatogether withEMBEDDING_PRESET=english(a transformers preset) used to silently fall through tonomic-embed-textregardless of whatenglishdeclared. Now resolves toprovider='ollama' + model='Xenova/bge-small-en-v1.5'(preset's model carried over) and emits a one-shot warning explaining the conflict — the runtime failure that follows has context. Users who hit this should remove one of the env vars. -
9 new regression tests in
test/embeddings/factory.test.ts. The integration gap that let Bug 2 ship is closed by a property-based suite that iteratesEMBEDDING_PRESETSat test time and asserts every preset survives thecreateEmbedder()round-trip with the correct (provider, model) pair attached. Adding a new preset toEMBEDDING_PRESETSautomatically extends this suite — no test edit needed. Changing a preset's underlying model also requires no test edit (the assertion is "factory returns whatever the preset declares", not "factory returns this specific string"). The change-detector signal for intentional preset-model swaps lives separately intest/cli/models.test.ts'smodels listsnapshot. -
No user action required for users on the affected preset. If you were running with
EMBEDDING_PRESET=multilingual-ollamaand explicitEMBEDDING_MODEL=qwen3-embedding:0.6bas the documented workaround: the workaround keeps working —EMBEDDING_MODELstill wins on precedence. Removing the workaround now also works (EMBEDDING_PRESETalone resolves correctly). If you were running without the workaround and gettingnomic-embed-texterrors: a fresh boot on v1.7.8 will trigger a model-change reindex (the bootstrap detectsnomic-embed-text → qwen3-embedding:0.6band re-embeds), so plan ~5–15 minutes of background reindex on a typical vault. -
Test totals: 893 → 902 vitest passing. Preflight 10/10 green.
-
Stale entry pruned from
docs/models.mdlicense catalogue.Xenova/paraphrase-MiniLM-L3-v2was listed in the MIT row of the license catalogue at line 258 even though it stopped being a preset model in v1.7.4 (replaced byMongoDB/mdbr-leaf-ir). The only remaining mention in the body is the historical breadcrumbv1.7.4: replaced \Xenova/paraphrase-MiniLM-L3-v2`` on line 17, which is fine — but listing a no-longer-supported model alongside current presets in a license catalogue implied users could still pick it. Removed. Cross-checked every other entry: all 14 remaining entries are either current presets or documented BYOM ("bring your own model") recipes with body sections matching them, so the table is now consistent.
Also in v1.7.8 — Stop the churning MTEB pip cache in release.yml (release-process hygiene)¶
No user-visible runtime change — does not alter anything that ships in the npm tarball.
Replaces actions/setup-python@v6's built-in cache: pip (added in v1.7.6) with the same explicit actions/cache/restore@v5 + actions/cache/save@v5 split-pattern that ci.yml already uses for the Hugging Face embedding-models cache.
-
Symptom (observed v1.7.6 → v1.7.7): every release run uploaded 2.88 GB of pip-cached wheels to GitHub Actions cache, even though the requirements file (
scripts/requirements-build-seed.txt) hadn't changed. Cache restore reported a partial fallback hit (~25 MB under an old key like…-pip-4956364a…), pip re-downloaded the rest, and the post-step saved a fresh tarball under a new exact key (…-pip-c5a61df2…). Same content, new key, every run. -
Root cause:
setup-python@v6's cache key includes runner-image-derived inputs that drift between runs even when the dependency manifest is unchanged. Restore vs save compute different hashes → no exact-key hit → save fires unconditionally. -
Fix: key the cache on a stable hardcoded string (
mteb-pip-2.12-v1) rather than a churn-prone derived hash. Mirrors the pre-existing HF cache pattern inci.yml:69-126: actions/cache/restore@v5BEFOREpip install,continue-on-error: true, with arestore-keysfallback chain (mteb-pip-2.12-,mteb-pip-).actions/cache/save@v5AFTERpip install, gated byif: steps.install-mteb.outcome == 'success' && steps.mteb-pip-restore.outputs.cache-hit != 'true'— only saves when install succeeded AND wasn't already an exact-key hit.-
Cache-bust mechanism is explicit: bump
-v1→-v2to retire stale wheels (e.g. CVE patch in a transitive dep), or bump prefix tomteb-pip-3.x-v1when MTEB ships 3.0. -
Two release-runs to fully benefit: the v1.7.8 run itself populates the new stable key for the first time (same wall-time as v1.7.7). The v1.7.9 run hits the exact key, reads wheels locally (~10–15 s vs ~60 s of network downloads), skips the 2.88 GB upload entirely. Subsequent runs stay cache-hit until the manifest or
-v1suffix changes. -
Seed-regen step itself is unchanged.
scripts/build-seed.pystill runs afterpip install -r scripts/requirements-build-seed.txt, still hascontinue-on-error: trueso a failure doesn't block the release (committeddata/seed-models.jsonships if regen fails). All three of theSetup Python/Install mteb/Regenerate model seed JSONsteps still ride the same release-only tag-trigger gate.
v1.7.7 — 2026-04-26 — Surface silent native-module crashes (preflight wrapper + sync stderr writes + un-masked postinstall + Node-identity banner)¶
Fixes the silent-crash failure mode where Claude Desktop's MCP transport spawns the npx-cached obsidian-brain after a Node version change, the cached better_sqlite3.node is incompatible with the current Node, and the process dies with no error visible in ~/Library/Logs/Claude/mcp-server-obsidian-brain.log. Two underlying causes stacked: top-level import of native modules can fail before any user-code try/catch is on the stack, and process.stderr.write(msg) + process.exit(1) races with Node's async stderr buffer so the error message can be discarded before reaching the OS pipe. Both addressed.
-
src/preflight.ts— new module, MUST be the first import insrc/cli/index.ts. UsescreateRequire(import.meta.url)to loadbetter-sqlite3andsqlite-vecsynchronously inside try/catch (staticimportcan't go inside try/catch — syntax error — butcreateRequire-stylerequire()calls can). On the happy path: ~10 ms tax, modules cached in Node's require map, downstream ESMimportstatements insrc/store/db.tshit cache and skip the load entirely. On failure: writes a banner + the full error stack to fd 2 via synchronousfs.writeSync(2, …)(bypasses Node's async Writable buffer — bytes always reach the pipe before exit), AND writes the same content to~/.cache/obsidian-brain/last-startup-error.logas a recoverable record if the MCP client's stderr capture loses the message anyway. Then dispatches to the auto-heal insrc/auto-heal.tsto spawn a background rebuild + tell the user to restart their MCP client. -
src/auto-heal.ts— new file extracted from the bottom ofsrc/context.ts(was a 200-line block at lines 161–365). Now standalone, parameterised by failing module ('better-sqlite3' | 'sqlite-vec'), reachable from preflight without circular-importingcontext.ts. The error-pattern matcher (isLikelyAbiFailure) is broadened from/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED/to also catchwas compiled against a different Node\.js version,dlopen.*Symbol not found,dlopen.*image not found,incompatible architecture, and theCannot find module 'sqlite-vec...'symptom that surfaces when a platform-specific optional-dep package is missing.src/context.ts's in-openDbcatch still calls into this module — the second-line-of-defence path covers the case whereimportsucceeded butnew Database()throws at construction time. Newtest/auto-heal.test.ts(8 unit cases) covers the matcher; existingtest/context.test.tscovers the dispatch. -
Sqlite-vec auto-heal path —
tryAutoHealAbiMismatchnow acceptsmoduleand routes to the right command. Forbetter-sqlite3: existingnpm rebuild better-sqlite3. Forsqlite-vec: newnpm install --no-save sqlite-vec-${process.platform}-${process.arch}(the platform-specific optional dep). Marker filename gains the module name (abi-heal-attempted-better-sqlite3-<abi>/abi-heal-attempted-sqlite-vec-<platform>-<arch>) so the two heal paths don't share a cooldown. Pre-v1.7.7 markers (abi-heal-attempted-<abi>only) are now obsolete; the test suite cleans both old and new onbeforeEach. -
process.stderr.write→fs.writeSync(2, …)in every catch handler that follows withprocess.exit(1):src/cli/index.tsparseAsync().catch,src/server.tsstartServer().catch,src/preflight.ts. Synchronous OS-level write blocks until bytes are accepted by the pipe; the subsequentprocess.exitno longer races. Closes the failure mode where Apr 22–23 crashes printed errors but the 2026-04-26 02:10 crash didn't — same root cause, different timing on the buffer-flush race. -
Node-identity banner — first line on every boot, written via
Always lands in Claude Desktop's log even on immediate crash. Diagnosing future ABI mismatches starts with "what Node was active that boot?" — this answers it without the user having to run anything.fs.writeSync(2, …)fromrunPreflight: -
package.jsonpostinstall un-masked —"npm rebuild better-sqlite3 || true"→"npm rebuild better-sqlite3 || (echo 'obsidian-brain: postinstall rebuild ... FAILED ...' 1>&2; exit 1)". The|| truesuffix was silently masking rebuild failures, leaving installs in a half-broken state that crashed on first boot. Now the failure surfaces immediately duringnpm install/npx -y obsidian-brain@latestwith an actionable recovery message + link to troubleshooting docs. Only triggers for users on Node versions with no prebuild AND no C++ toolchain — an existing failure case that v1.7.7 just makes visible instead of silent. -
CRASH RECOVERY (existing path, now reachable) — when the failure recurs (and it will, every time a user upgrades Node while the npx cache holds a stale obsidian-brain), users see:
Both Claude Desktop's log AND the local diagnostic file capture the same content. No more silent crashes.obsidian-brain: ✗ Native module load failed before server could start. Module: better-sqlite3 Node: v24.14.1 (NODE_MODULE_VERSION 137) Detail: ~/.cache/obsidian-brain/last-startup-error.log Auto-heal: a background rebuild was started (PID 12345). Restart your MCP client (⌘Q + reopen) in about 1 minute. If the problem persists after restart: rm -rf ~/.npm/_npx -
Coverage —
src/preflight.tsandsrc/context.tsadded tovitest.config.tscoverage.excludeper the project's existing grandfather-via-exclude policy. Preflight is process-startup-glue that can't be meaningfully unit-tested without breaking the runner; context.ts's remaining content after the auto-heal extraction is end-to-end glue (vault open, DB open, embedder factory wiring) covered byscripts/mcp-smoke.tsandtest/integration/*which spawn real subprocesses V8 coverage doesn't follow into. Final totals: 893/893 vitest, 38/38 Python, preflight 10/10 green.
v1.7.6 — 2026-04-26 — Release-flow drift guard + revert redundant docs-deploy step + pip caching for build-seed¶
No user-visible runtime change. Internal release-process hygiene after the v1.7.5 ship-day surfaced two real flaws.
- Stale
dev-shippeddrift guard inscripts/promote.mjs. The v1.7.3 ship attempt failed because thedev-shippedtag was at a pre-v1.7.0 commit (someone shipped earlier releases without advancing the tag) andpromotetried to re-cherry-pick all of v1.7.0/v1.7.1/v1.7.2 onto main, conflicting on the first commit. After computing pending, the guard now scansgit log --first-parent dev-shipped..targetfor any commit whose subject matches^v?\d+\.\d+\.\d+$— that regex matches the bare-version-string commitsnpm versionproduces and which arrive on dev's first-parent line via the merge-back step of every release. Their presence in the pending range proves at least one full release shipped withoutdev-shippedbeing advanced; the guard aborts before any cherry-pick with the exact recovery command pointing the tag at the newest version-bump anchor in the range. Zero impact on the happy path. Triggers only when drift exists. RELEASING.md gains a "Stale dev-shipped tag" section documenting the failure mode + fix. - Reverted the v1.7.5 docs-rebuild step from
release.yml. The step duplicated work that.github/workflows/docs.ymlhas been doing since 2026-04-24 — build the site withmkdocs --strict, upload viaactions/upload-pages-artifact, deploy viaactions/deploy-pages(artifact-based, no branch). v1.7.5's release.yml addition usedpeaceiris/actions-gh-pages@v3which uses a different deployment model (push to agh-pagesbranch). The repo's Pages config isbuild_type: workflow, so it doesn't read from any branch; peaceiris's gh-pages branch was dead weight. The v1.7.5 site update came fromdocs.yml's artifact deploy, not from peaceiris. Removed the 4-step block, thepages: writepermission, and theenvironment: github-pagesjob binding from release.yml. Existing orphangh-pagesbranch deleted from origin. - Pip caching for the build-seed step. Bumped the build-seed
step's
actions/setup-python@v5→@v6(Node 24, kills the remaining deprecation warning in this workflow) and addedcache: pip+cache-dependency-path: scripts/requirements-build-seed.txt. The MTEB pin moves from inlinepip install 'mteb>=2.12,<3'into the new requirements file so setup-python's pip cache keys off the file's hash. Cold release is unchanged; warm release skips the ~30s mteb install.
v1.7.5 — 2026-04-25 — Six-layer metadata-resolver chain (overrides → cache → seed → HF → probe → fallback) + Ollama parity (tag-swap detection + /api/show capacity + override flow-through) + four new models CLI subcommands (add / override / fetch-seed / refresh-cache) + multilingual-ollama preset → qwen3-embedding:0.6b + friendly UserError CLI formatting + doc-drift invariant tests¶
Mostly invisible upgrade, with one preset model swap. Five of the six canonical presets (english, english-fast, english-quality, multilingual, multilingual-quality) are unchanged in shape — same prefix, same dim, same chunk budget — they're just sourced from upstream HF configs (via a bundled data/seed-models.json refreshed at every release) and cached in embedder_capability instead of hardcoded across presets.ts / embedder.ts / capacity.KNOWN_MAX_TOKENS. The sixth preset, multilingual-ollama, swaps its underlying model from bge-m3 to qwen3-embedding:0.6b (+4.77pp MTEB-multilingual; existing preset users auto-reindex on next boot — ollama pull qwen3-embedding:0.6b first). Ollama users on asymmetric models (nomic / qwen / mxbai families) also get a one-time reindex now that override-flowed prefixes actually take effect.
For BYOM users (EMBEDDING_MODEL=any/hf-id): the resolver fetches dim / max-tokens / query+document prefixes live from HF on first use and caches them per-vault forever (invalidate explicitly via obsidian-brain models refresh-cache). Wrong-prefix bugs (the kind that would have shipped a wrong-flip prefix by hand) become impossible — the source of truth is upstream HF configs (or the user's own override file via models override), not us.
Resolver chain — six layers, override → cache → seed → HF → embedder probe → fallback¶
- Layer 0: user-config overrides (
src/embeddings/user-config.ts,src/embeddings/overrides.ts) — two new files live in~/.config/obsidian-brain/(XDG-compliant;%APPDATA%/obsidian-brain/on Windows;$OBSIDIAN_BRAIN_CONFIG_DIRoverrides everything). Because they're outside the npm package directory, bothmodel-overrides.jsonand the user-fetchedseed-models.jsonsurvivenpm updateintact. Override changes auto-trigger a re-embed via the existing prefix-strategy hash inbootstrap.ts;maxTokensoverrides take effect on the next reindex. - Layer 1: pure HF API client (
src/embeddings/hf-metadata.ts) —getEmbeddingMetadata(modelId)fetchesconfig.json+tokenizer_config.json+sentence_bert_config.json+config_sentence_transformers.json+modules.jsonin parallel, plus the upstreambase_model'sconfig_sentence_transformers.jsonwhen the direct repo lacksprompts. AbortController-based timeout (5s default), 2 retries with backoff. Multimodal / vision / audio models throw cleanly. Zero project coupling — mockable viavi.spyOn(global, 'fetch'). - Layer 2: bundled seed loader (
src/embeddings/seed-loader.ts) — readsdata/seed-models.jsononce at startup, exposes a typedMap<modelId, SeedEntry>. The user-fetched path at~/.config/obsidian-brain/seed-models.json(managed viamodels fetch-seed) takes priority over the bundled npm-tarball copy when present. Bad shape / missing file → empty map + stderr warning, never crashes. JSON imports usetsconfig.resolveJsonModule(already enabled). - Layer 3: resolution chain (
src/embeddings/metadata-resolver.ts) — pure function with all deps injected. Order: cache → bundled seed → HF live → embedder probe → safe defaults. Cache lives forever once written; users invalidate explicitly vianpx obsidian-brain models refresh-cache [--model <id>]. No TTL, no stale-while-revalidate, no background refetches — the v7 metadata fields (dim,model_type,hidden_size, ONNX size) are immutable for a given HF id, and the rare fields that CAN change post-publish (tokenizer config corrections, retroactively-added prompts) are cheaper to handle via explicit user action than via constant background HF traffic. Sync variant (resolveModelMetadataSync) for the bootstrap prefix-strategy hash. - Layer 4: cache persistence (
src/embeddings/metadata-cache.ts) — schema v7 columns onembedder_capability(dim,query_prefix,document_prefix,prefix_source,base_model,size_bytes,fetched_at).clearMetadataCache(db, modelId?)nulls the v7 columns on demand (preserves v6 capacity columns); backs themodels refresh-cacheCLI subcommand. - Schema v7 migration — adds the seven nullable columns to
embedder_capabilityvia idempotentensureEmbedderCapabilityV7Columns(db). Existing v6 rows untouched.selfCheckSchemaextended to verify + heal the new columns. - Resolver short-circuit when override is complete (Step 0 ahead of cache lookup) — when the user override fully specifies
maxTokens,queryPrefix, ANDdocumentPrefix, skip the entire chain and return the override directly (cached asprefixSource: 'override'). This makesmodels addtruly zero-network for the registered id; partial overrides still flow through cache → seed → HF as before. prefixSource: 'override'cache marker — added to theCachedPrefixSourceunion and propagated throughEmbedderMetadata/ResolvedMetadata.materialise()now surfaces anoverrideApplied: booleanfield on the resolved metadata soindex_status/ future debug commands can distinguish "the seed says X" from "the user overrode X to Y". Override-applied entries cache as normal; only the marker differs.- Tier 3 README fingerprinting deferred (
src/embeddings/hf-metadata.ts) — exploratory implementation lives in the file (resolvePromptsFromReadme, language-aware script filter, tightenedisPlausiblePrefix) but is not wired into the live resolver chain. False-positive risk on long-form READMEs is too high to ship without an eval harness; deferred to a future release. The resolver chain stops at HF live fetch + embedder probe + safe defaults for now. The exploratory function and its bug fixes (BGE-en/zh script filter,print()-label rejection) survive in the file as a starting point.
Bundled seed (scripts/build-seed.py)¶
- MTEB Python registry as the bulk source — zero HF API calls for the bulk path. The previous Node script (
build-seed.mjs) clonedembeddings-benchmark/results, walked eachmodel_meta.json, and fired ~5,500 HF API calls per release run — a full ~700-candidate pass burned through anonymous (500/5min) and free-tier (1,000/5min) HF rate limits and tripped 429s mid-run. The Python rewrite replaces the bulk path withmteb.get_model_metas()(the in-process ModelMeta registry the MTEB maintainers curate by hand) and pulls the three load-bearing fields directly:name,max_tokens, andloader_kwargs.model_prompts. Why Python is non-optional:bge_models.pyuses literal"query"string keys in the prompts dict bute5_models.pyusesPromptType.query.valueenum-attribute keys (both resolve to the runtime string"query"becausePromptTypeis a str-enum), and some family files build the dict via conditional code pure-JS regex can't follow — importing the registry resolves all forms uniformly.npm run build:seedis preserved as a one-line wrapper aroundpython3 scripts/build-seed.py. Release CI runsactions/setup-python@v5+pip install 'mteb>=2.12,<3'+npm run build:seedwithcontinue-on-error: true— a future MTEB symbol rename never blocks a release because the committed anchor ships unchanged in that case. - HF
config_sentence_transformers.jsonfallback for instruction-aware models (_fetch_hf_default_prompts) — MTEB storesmodel_prompts: Nonefor ~107 instruction-aware models (Qwen3-Embedding family, e5-mistral-7b-instruct, Snowflake/snowflake-arctic-embed-l, etc.) because its evaluation harness applies task-specific instructions per benchmark via wrapper classes, not a static dict. Pre-fix the seed shipped null/null prefixes for all of them, dropping retrieval quality 1-5%. Build-seed now falls back to the model'sconfig_sentence_transformers.jsonon HF (single GET per model, polite, well under any rate limit) and ships the author's recommended general-purpose retrieval default. Verified live: ~57% of the 107 candidates have a usableprompts.queryfield — 61 entries gain real prompts, ~46 stay null (correctly — those models have no canonical default and users override per-vault if they need one). Authentication-required models (google/embeddinggemma-300m,Alibaba-NLP/gte-Qwen1.5-7B-instruct,nvidia/NV-Embed-v1) return HTTP 401 and stay null. Picksprompts.queryif present; falls back to the first*_querykey alphabetically (deterministic) for e5-mistral-style task-specific configs. - Smarter
{text}placeholder semantics + runtimereplaceAll(_normalize_prompt_template+src/embeddings/embedder.ts+src/embeddings/ollama.ts) — pre-fix the build-time normalizer stripped trailing{text}placeholders from prompts, butWhereIsAI/UAE-Large-V1shipped"Represent this sentence for searching relevant passages: {text}"verbatim because the runtime path used.replace('{text}', text)(single replacement only) and the normalizer never fired on multi-{text}patterns. Three buckets now: zero placeholders → ship as plain prefix; every placeholder is{text}→ ship as template, runtime substitutes every occurrence (replaceAll('{text}', input)covers"Task: {text}\nQuery: {text}"style multi-{text}templates); any non-{text}placeholder ({task},{instruction},{query}) → drop with a warning, those vars are MTEB eval-harness conditional fills that can't be statically resolved. Regression guard added to the Python test suite walks the committed seed and asserts no non-{text}placeholder slips through. - Seed schema bumped v1 → v2 to match the new minimal-shape source. Drops
dim/prefixSource/modelType/baseModel/hasDenseLayer/hasNormalize/sizeBytes/runnableViaTransformersJs(all display-only — runtime probesdimfrom the loaded ONNX, and verified during the rewrite that MTEB's curatedembed_dim=512forBAAI/bge-small-en-v1.5is wrong; actual model dim is 384). Schema v2 carries only the three load-bearing fields (maxTokens,queryPrefix,documentPrefix).seed-loader.tsreads both v1 and v2 transparently via an exported_adaptV1Entryprojection so an older anchor pulled in via cherry-pick keeps working; the adapter has direct unit-test coverage so the back-compat branch is genuinely covered, not aspirational. - End-to-end smoke: local run with mteb 2.12.30 produces a 349-entry seed (341 from MTEB filter + 8 hand-aliased Ollama tags / Xenova mirrors) in ~25 seconds, zero HF calls for the bulk path, ~107 single-model GETs for the instruction-aware HF fallback. All six canonical presets resolve with correct prefixes (BGE:
"Represent this sentence for searching relevant passages: "query / empty document; E5:"query: "/"passage: "; Qwen3:"Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:"query / empty document).
Hardcoded-knowledge deletions (the cleanup the layered resolver makes possible)¶
getTransformersPrefixif/else chain DELETED — its 9 family-pattern branches (BGE / E5 / Nomic / mxbai / mdbr-leaf / Arctic v1+v2 / Jina v2 / GTE / Qwen3) are now sourced fromconfig_sentence_transformers.jsonupstream.embedder.embed()readsthis._metadata.queryPrefix/documentPrefixafterbootstrap()callsembedder.setMetadata(meta)with the resolved metadata.KNOWN_MAX_TOKENSmap DELETED — its 13 hand-curated entries are now in the bundled seed (or fetched live for BYOM). TheresolveTransformersAdvertisedhelper drops the override-table check; tokenizer-config fallback path remains for tests that callgetCapacity()directly.EMBEDDING_PRESETS[*].dim / .symmetric / .sizeMb / .langfields REMOVED — preset entries are now{ model, provider }only.models listreadsmaxTokensandsymmetricfrom the bundled seed at runtime;dimis no longer displayed (probed at runtime from the loaded ONNX, not stored in seed v2);sizeMbis available viamodels check <id>(live HF probe).Embedderinterface gains optionalsetMetadata(meta)/getMetadata()— bothTransformersEmbedderandOllamaEmbedderimplement them (Ollama's wiring is below).bootstrap.computePrefixStrategyreads the prefix offembedder.getMetadata()instead of calling the deletedgetTransformersPrefix.
multilingual-ollama preset model swap¶
multilingual-ollamapreset model swap:bge-m3→qwen3-embedding:0.6b(src/embeddings/presets.ts). +4.77pp MTEB-multilingual gain (64.33 vs 59.56), 4× context window (32 768 vs 8192 tokens), smaller Ollama disk footprint (~600 MB vs 1.2 GB), instruction-aware retrieval via the canonical query prompt shipped in the seed ("Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:"). Existingmultilingual-ollamausers get a one-time auto-reindex on next boot via the existing model-id-change detection path inbootstrap.ts(model identifier flips fromollama:bge-m3toollama:qwen3-embedding:0.6b); the user runsollama pull qwen3-embedding:0.6bonce before restart, otherwise boot fails with the existingpull thismessage.bge-m3remains a fully-supported BYOM target viaEMBEDDING_PROVIDER=ollama EMBEDDING_MODEL=bge-m3. Verified live against Ollama: dim=1024, ctx=32768, digest=ac6da0dfba84a81f.... The known-bug stderr warning emitted onmultilingual-qualityresolution updated to recommend the new default with the new comparison numbers.
Ollama runtime — auto-detect tag swaps, real capacity from /api/show, override flow-through¶
- Auto-detect
ollama pullweight swaps + extract real dim/max-tokens from/api/show(src/embeddings/ollama.ts). Verified live against four pulled models (nomic-embed-text, all-minilm, mxbai-embed-large, qwen3-embedding:0.6b for the canonicalmultilingual-ollamapreset). NewOllamaEmbedder.identityHash()returns the manifest digest from/api/tags(sha256 of the model manifest);bootstrap.tscompares stored vs current and triggers reindex when weights swap silently under the same tag (ollama pull bge-m3updatingbge-m3:latest). Init also readsmodel_info.<arch>.embedding_length(real dim) andmodel_info.<arch>.context_length(real max-tokens) from/api/show, so we no longer need the legacy "fire an empty embed to probe dim" path on modern Ollama. Capability check (capabilities: ['embedding']) fails fast with a clear error if a user accidentally pointsEMBEDDING_MODELat an LLM. Thenum_ctxwe send to/api/embeddingsis now resolved asOLLAMA_NUM_CTX || cachedContextLength || 8192— the legacy8192default was over the architectural limit for nomic-embed-text (2048) and the bert family (512), causing Ollama to allocate context that exceeded the model's positional embedding cap. - Prefix overrides (
models override/models add) now flow through correctly —OllamaEmbeddernow implementssetMetadata/getMetadata. Pre-fix, the embedder used hardcoded family heuristics (if model.includes('nomic')...) that ignored the resolver's authoritative prefix; user overrides silently had no effect for Ollama models. Verified empirically with a real-Ollama probe: without override, nomic-embed-text query produces vector-0.4731 0.4855 -4.6607...; withmodels override … --query-prefix "CUSTOM_QUERY: ", the same input produces-0.1615 0.1846 -4.6093...— different, override is taking effect. The hardcoded heuristics remain as a fallback for the init-time probe (beforesetMetadataruns) and tests, so canonical preset behaviour is unchanged. Triple-confirmed Ollama does NOT auto-apply prefixes itself:/api/showtemplate is verbatim pass-through (Go-template{{ .Prompt }}) for both nomic and mxbai models; the official Ollama API docs specify notask_typeparameter on/api/embedor/api/embeddings; an empirical "vector with vs without prefix" test confirmed prompts modify output. - Bootstrap prefix-strategy reindex now fires for Ollama too (
src/pipeline/bootstrap.ts) — pre-fix thecomputePrefixStrategyhelper short-circuited to''for non-transformers providers, so prefix changes (override / fetch-seed / Tier 3 update) wouldn't auto-reindex Ollama users. Lifted that bypass now that Ollama actually reads metadata. Existing Ollama users on asymmetric models (nomic-embed-text, qwen, mxbai families) get a one-time reindex on next boot — same one-time-tracking-stamp pattern transformers.js users got when prefix-strategy was first added; symmetric Ollama models (bge-m3, all-minilm) are unaffected.
CLI subcommands & UX¶
obsidian-brain models add <id>— peer tomodels override, dedicated to "register a new model NOT in the bundled seed." Requires--max-tokens N;--query-prefixand--document-prefixdefault to"". Refuses if the id is already in the seed (points the user atmodels overrideinstead). Refuses if the id already has an override (points atmodels override <id> --removeto clear first). No silent overwrites. When all three load-bearing fields are present, the resolver short-circuits the HF lookup entirely on first use — your override IS the metadata, no HF round-trip.obsidian-brain models override <id>— set, remove, or list per-model metadata overrides. Three modes:--max-tokens N --query-prefix S --document-prefix S(set/patch — flags combinable),--remove [--field name](clear all or one field),--list(dump every override on disk). Each override is a partial patch — omitted fields fall through to the next layer. Use case: MTEB's curatedembed_dim=512forBAAI/bge-small-en-v1.5is wrong (actual is 384) — a user wanting to correct a similar bug locally now runsmodels override <id> --max-tokens <correct>and the change persists acrossnpm update. Validation rejects non-positivemaxTokensand non-string non-null prefixes per-field; bad fields drop, good fields keep.obsidian-brain models fetch-seed— download the latestdata/seed-models.jsonfrom themainbranch on GitHub and write it to~/.config/obsidian-brain/seed-models.json. The seed-loader checks the user-fetched path before the bundled npm-tarball copy, so users get upstream MTEB fixes without waiting for an npm release.--checkvalidates the download without writing.--url <url>overrides the source for forks / self-hosted mirrors. Schema-version-aware — refuses to overwrite if the fetched file declares an unsupported$schemaVersion(forces a package upgrade for runtime-affecting schema changes). Atomic write (tmp+ rename) so partial downloads can never leave a corrupt seed.models check <id>no longer downloads weights — fetches metadata via Layer 1 in <2s instead of the prior ~30s download-and-load. Add--loadfor end-to-end validation.models listsurfaces every seed entry, not just the 6 presets — added--all(include every entry in the bundled MTEB-derived seed; 349 dense, text-only, open-weights models as of mteb 2.12.30) and--filter <substr>(case-insensitive substring match on model id) flags. Combinable:models list --all --filter mongodbreturns bothMongoDB/mdbr-leaf-ir(preset:english-fast) andMongoDB/mdbr-leaf-mt(preset:null). Output adds apresetfield that'snullfor non-preset entries; TTY footer shows "(N models matching— pass --all for every seed entry)" so the gap is obvious. Closes the discoverability gap where the seed shipped invisible to users.
models refresh-cacheno longer requiresVAULT_PATH— it only reads/writes the local SQLite DB at$XDG_DATA_HOME/obsidian-brain/kg.db(or$DATA_DIR), which is derivable without a vault. Pre-fix, runningmodels refresh-cachein a clean shell threw aUserError: VAULT_PATH is not seteven though the operation has zero dependency on vault content. Fix: newresolveDataConfig()helper alongsideresolveConfig()that returns just{ dataDir, dbPath }and is used by every vault-agnostic CLI subcommand. Caught by the CLI audit pass.models refresh-cachedescription tightened — was missing two real-world facts. Added: (1) for seeded models the cost is ~0 HF calls (the 349-entry seed repopulates the cache instantly on next boot); 1 HF call per non-seeded BYOM id. (2) the prefix-strategy hash auto-detects any prefix change and triggers a re-embed in bootstrap, so it's safe to run any time. Caveat: if you run it OFFLINE on a non-seeded BYOM id, fallback safe defaults get cached — fix by running again online.- Friendly CLI errors — new
UserErrorsentinel class (src/errors.ts) and a CLI catch-handler split. Expected user-facing problems (missing env var, bad flag value) printobsidian-brain: <message>\n ↳ <hint>\nto stderr, no stack trace. Internal/programmer errors keep printing the full stack so bugs remain debuggable. Pre-fix,obsidian-brain watchwithoutVAULT_PATHset dumped a 10-line Commander stack trace; now: a single sentence + a one-line hint with the exact env var to set.resolveConfigis the first call site; future user-facing errors should throwUserErrorrather than plainError. obsidian-brain --versionnow reports the actual installed version — was hardcoded at'1.2.2'insrc/cli/index.tssince the project's start, drifted across every release v1.3.0 → v1.7.4. Fixed: readversionfrompackage.jsonat runtime viacreateRequire. Same fix also switches the short-flag from-V(Commander's capital default) to-v(the lowercase convention every modern CLI uses —node -v,npm -v,tsc -v).--versionand-h/--helpcontinue to work.- CLI help-text accuracy pass — three lies fixed: (1)
obsidian-brain index --dropdescription claimed it was "required when switching EMBEDDING_MODEL" — false (bootstrap auto-detects model/provider changes and wipes state on its own); now says "mostly an escape hatch". (2)obsidian-brain searchonly listedsemantic | fulltextmodes despitectx.search.hybrid()being the production default —--mode hybrid(RRF-fused) is now the default and listed first; explicit unknown-mode rejection added. (3)obsidian-brain models prefetchdeclared a--timeout <ms>option that wasvoid-ed in the action (option existed, did nothing, lied to users about what it did) — option removed;prefetchModelhas its own retry+backoff loop. Verified by walking every subcommand's--helpand cross-referencing source. - CLI help-snapshot tests (
test/cli/help-snapshot.test.ts) — captures--helpoutput for the top-level program plus every subcommand and asserts viatoMatchInlineSnapshot. What this catches: future drift between code and help text (developer adds a flag, removes one, changes a default, rewords a description, adds a subcommand → snapshot diff is visible in their PR). What it does NOT catch: lies that have always been lies — pre-fix this would have happily snapshot-locked the wrong "--drop is required when switching EMBEDDING_MODEL" claim. The snapshot is a forcing function for change-noise, not a correctness oracle. Required a small refactor ofsrc/cli/index.tsto exportbuildProgram()(the script entry-point at the bottom is now gated behind aprocess.argv[1] === fileURLToPath(import.meta.url)check so importing from a test never accidentally firesparseAsync).
Docs & maintainer process¶
- New
docs/cli.mdpage — first centralised CLI reference covering every subcommand underobsidian-brain(server,index,watch,search, allmodelssubcommands). Wired intomkdocs.ymlnav as its own top-level "CLI" section. Includes new sections formodels add,models override, andmodels fetch-seed; an env-var quick-reference table forobsidian-brain server(transformers / Ollama-preset / Ollama-BYOM / non-default Ollama-URL combinations); explicit documentation that there are three independent ways to use Ollama (preset /EMBEDDING_PROVIDERoverride / both); and the "How model metadata is resolved" section showing the 6-step chain (Layer 0 = user overrides). docs/models.mddrift fixed — three audit-flagged issues: (1)englishpreset prefix description wrong (saidquery:/passage:E5-style; actual seed has BGE'sRepresent this sentence for searching relevant passages:/""). (2) Seed coverage wrong (said "~250"; actual is 349 since the Python rewrite). (3)models checkdescription wrong (said it goes through "the same resolution chain the runtime uses"; actually it skips the chain and goes direct to live HF — verified via the CLI audit). Plus added the newmodels add/models override/models fetch-seedrows to the subcommand table.- Pruned legacy preset aliases from docs prose —
fastestandbalancedaliases (and accompanying "deprecation warning" sections) removed fromdocs/architecture.md,docs/getting-started.md,docs/install-clients.md, anddocs/models.md. The aliases still resolve at runtime (one-time stderr nudge points users at the canonical name) but they're no longer surfaced in user-facing docs as supported configuration. - Maintainer rule added to
RELEASING.md: no obsidian-brain self-version refs in docs prose (everywhere exceptdocs/CHANGELOG.mdanddocs/roadmap.md). Phrases like "since v1.4.0" / "added in v1.7.0" / "v1.7.5+ metadata cache" rot the moment a feature ships further back than the doc remembers — describe behaviour in the present tense instead. External dependency contracts (plugin v0.2.0+,Obsidian ≥ 1.10.0,Node ≥ 20) stay because they ARE the contract. Includes a one-line grep recipe maintainers can run before promote. The doc-scrub commit (a84abc5) was the first pass implementing this rule across ~40 references in 11 doc files; the rule itself prevents regression. - Stripped internal version refs from CLI surface + help-snapshot test names —
obsidian-brain models refresh-cache --helpno longer carriesv1.7.5in its description, and the help-snapshot test names use genericregression:prefixes instead ofv1.7.5 fix:. Matches the doc-prose rule that user-facing surfaces describe behaviour in the present tense. RELEASING.mdgains four maintainer rules — preflight description updated to includecheck-env-vars+test:python(was missing); new manual rule "if you touched any CLI subcommand or flag, updatedocs/cli.md" (the help-snapshot test catches the mechanical drift but not the prose); new manual rule "new env-var read insrc/→ declare inserver.jsonAND keepcheck-env-vars.mjsALLOWLIST in sync"; thepromotestep description now lists every preflight step (was abbreviated).
Tests + observability¶
- Tests substantially expanded across the release, all under one count by end of work. Vitest additions: 4 new test files for the resolver layers (Layer 1-4) with mocked fetch + in-memory DB (~35 cases); 14 cases for the user-config layer (
test/embeddings/overrides.test.ts— round-trip, partial merge, single-field remove, validation rejecting bad shapes, unsupported$versionignored); 5 cases inmetadata-resolver.test.tscovering the override layering on each chain step; 5 cases formodels add(set, defaults, refuses-seeded, refuses-existing-override, rejects non-positive max-tokens); 2 for the resolver Step-0 short-circuit (complete override skips HF; partial override still hits it); 5 for theUserErrorformatter; 2 covering the v1→v2 seed adapter (_adaptV1Entry); plus a regression-pin inline-snapshot test on themodels listJSON output that locks the exact six-preset table (preset/model/provider/maxTokens/symmetric) so any future drift — preset rename, MTEB-side max-tokens shift, new preset added — fails the snapshot and forces explicit acknowledgement in the PR. Plus three doc-drift invariant tests (test/docs/):tool-count-invariant.test.tsassertsdocs/tools.mdheading set matchessrc/tools/*.tsexactly (catches added/removed tools that didn't propagate to docs);no-version-refs.test.tsrejects "since/in/as of vX.Y.Z" prose anywhere outsidedocs/CHANGELOG.md,docs/roadmap.md,docs/migration-aaronsb.md(elevates the RELEASING.md grep recipe to a CI gate);plugin-compat-invariant.test.tsrejects hardcoded companion-plugin version pins like "plugin v1.4.0+" (the contract is same major.minor; pins rot every release). Plus a separate Python unit-test suite forscripts/build-seed.py(test/scripts/test_build_seed.py, 38 stdlib-unittestcases): filter rules (open-weights / dense / modality / multi-vector / static-embedding-by-inf); prompt-extraction edge cases (bge-style literal"query"keys, e5-stylePromptType.query.valueenum keys,"passage"fallback, symmetric-model null-prefix path, max_tokens int coercion + skip reasons, defensive non-string prompt-value rejection); the alias-table covers-every-canonical-preset invariant; the new placeholder-semantics rule (zero / all-{text}/ mixed cases including the"Task: {text}\nQuery: {text}"multi-template pattern); the HF fallback (_fetch_hf_default_promptsmocked: cleanprompts.query, alphabetical*_queryfallback, 404, invalid JSON, in-process cache hit); the instruction-aware-only fallback gating inextract_entry; the regression guard that walks the committed seed and asserts no non-{text}placeholder ships; and CHANGELOG/seed consistency invariants (the topmost release block's claimed seed entry count + Python test count must match reality). Runs in 0.2s with stdlib only, nomtebimport needed. Wired intonpm run preflightand.github/workflows/ci.ymlso a regression in the seed generator or doc state fails CI on every PR. Final totals at end of v1.7.5 work: 885/885 vitest, 38/38 Python, preflight 10/10 green. - Test output silenced (
test/setup/silence-stderr.ts) — vitest setup file monkey-patchesprocess.stderr.writeandconsole.{log,warn,error}to no-ops at module load sonpm run test:coverageoutput is clean. Pre-fix: ~50 noisy lines per run from production code paths the suite intentionally exercises (per-chunk skip warnings, fault-tolerant indexer summaries, prefetch retry logs, embedder-drift drift-floor lines, background-reindex catch handlers, metadata-resolver HF-fallback warnings). Tests that capture stderr viavi.spyOn(process.stderr, 'write')keep working — their spy wraps the no-op and replaces its impl with their capture for the test's duration. Direct property assignment (NOTvi.spyOn) so the silencer survivesvi.restoreAllMocks()and catches stderr from async fire-and-forget catch handlers that fire afterafterEachhas run. Override:OBSIDIAN_BRAIN_TEST_STDERR=1to skip the silencer when debugging.
v1.7.4 — 2026-04-25 — english-fast preset model swap → MongoDB/mdbr-leaf-ir¶
One-time auto-reindex on upgrade for users on EMBEDDING_PRESET=english-fast or the deprecated fastest alias. No action required — semantic search returns {status: "preparing"} during the rebuild; everything else stays online. Other presets unaffected.
english-fastmodel swap —Xenova/paraphrase-MiniLM-L3-v2(17 MB, 384d, symmetric, MTEB ~0.55) →MongoDB/mdbr-leaf-ir(22 MB, 768d post-Dense projection, asymmetric, retrieval-tuned). mdbr-leaf-ir is a 23M-param Matryoshka student ofmxbai-embed-large-v1, distilled and retrieval-tuned by MongoDB. Apache-2.0. Ships ONNX weights in the official HF repo so transformers.js v4 loads it directly without anonnx-community/...mirror. Sister modelMongoDB/mdbr-leaf-mtis for general/clustering tasks;-iris what we wire here for RAG-style search.- Mxbai-style asymmetric prefix —
getTransformersPrefixnow matchesmdbr-leafalongsidemxbai/mixedbread, applyingRepresent this sentence for searching relevant passages:to queries and an empty prefix to documents (per theconfig_sentence_transformers.jsonshipped on the model). Users don't need to do anything — same auto-prefix flow as bge / e5 / nomic / arctic. KNOWN_MAX_TOKENS— addsMongoDB/mdbr-leaf-irandMongoDB/mdbr-leaf-mtat 512 tokens (max_position_embeddings).- Deprecation message updated — the
EMBEDDING_PRESET=fastestwarning now notes that v1.7.4 also changed the underlying model, alongside the existing rename-to-english-fastguidance.EMBEDDING_PRESET=fastestkeeps working; users can switch toenglish-fastto suppress the warning, or pinEMBEDDING_MODEL=Xenova/paraphrase-MiniLM-L3-v2to keep the old model. docs/models.md— preset table + quality ranking + license catalogue updated for the swap.
v1.7.3 — 2026-04-25 — Title-fallback for empty notes + capacity-drift floor + three-bucket index_status¶
Urgent fix. v1.7.2's "fault-tolerant + self-heal" did not actually fix the user-reported 32% missing-embeddings symptom. After a v1.7.2 reindex, vaults full of daily-note stubs / MOCs / template-only notes still showed notesMissingEmbeddings ≈ 32% regardless of which embedder was active (multilingual-quality, bge-m3, qllama/multilingual-e5-large-instruct — all reproduced the same number). Root cause: the chunker correctly returned [] for content-less notes, but setSyncMtime still fired, leaving them invisible to index_status's JOIN. v1.7.2's F6 self-heal would wipe their sync.mtime on every reindex — but the next pass produced zero chunks again, infinite no-op loop. Compounded by an unfloored adaptive-capacity ratchet that drove discovered_max_tokens down to 115 (from 512 advertised) on long-note failures, cascading more chunks into "too long" → more shrinking → runaway. Upgrading from v1.7.2 is drop-in; the next reindex auto-rebuilds and the 32% number drops to a small honest count.
- Title-fallback embedding for content-less notes —
src/pipeline/indexer.tsembedChunksnow synthesises a single fallback chunk fromtitle + tags + scalar frontmatter values + first 5 wikilink/embed targetswhenchunkMarkdown()returns[]. Daily notes (# 2026-04-25only), frontmatter-only metadata notes, embeds-only collector notes, and any note shorter thanminChunkCharsnow stay searchable by name instead of being silently dropped from the index. Truly content-less files (no title, no frontmatter, no body) are recorded infailed_chunkswith reason'no-embeddable-content'and skipped permanently — no infinite-retry loop. - Adaptive-capacity floor — new
MIN_DISCOVERED_TOKENS = 256floor insrc/embeddings/capacity.tsclampsreduceDiscoveredMaxTokensso a single freak chunk failure can no longer halve the cached budget down into single-sentence territory. For tiny models (advertised < 256) the floor adapts tomin(MIN_DISCOVERED_TOKENS, advertised)so we never claim more capacity than the model supports. - Capacity reset on every full reindex — new
resetDiscoveredCapacity(db, embedder)is called at the top ofIndexPipeline.index()to wipediscovered_max_tokensback to advertised. Closes the cross-boot drift cascade where yesterday's runaway shrunken value would still throttle today's pass even after the underlying issue is gone. - F6 self-heal becomes a true diagnostic, not a retry-loop — the end-of-reindex query now JOINs
chunks_vec(catches notes whose chunk rows exist but failed to embed) and excludes notes already classified as'no-embeddable-content'(those will fail the same way next pass; wiping theirsync.mtimeis the v1.7.2 infinite-loop bug). Notes with genuine unexpected gaps (e.g., dead embedder mid-pass) still get retried on next boot. index_statusreports three buckets — addsnotesNoEmbeddableContent(count of distinctnote_ids infailed_chunkswith reason'no-embeddable-content') alongside the existingnotesWithEmbeddings.notesMissingEmbeddingsis redefined asnotesTotal - notesWithEmbeddings - notesNoEmbeddableContentso it reflects only genuine failures, not the daily-note tail. Newsummaryfield gives MCP clients a one-line human-readable description so Claude reports "X / Y indexed; Z have no embeddable content; W failed" instead of conflating all three into a single misleading "missing" count.
v1.7.2 — 2026-04-25 — Reindex bug fixes + multilingual-ollama auto-routing + docs split¶
Urgent fix. Upgrading from v1.7.0 / v1.7.1 is drop-in. If your last reindex left notes without embeddings, the next boot's end-of-reindex self-heal queues them for retry automatically.
- Fix
reindexthrowing"Too few parameter values were provided"—src/store/nodes.tsupsertNodenow coerces undefinedtitle/content/frontmatterto safe defaults before the.run()call (handles malformed frontmatter, NULL rows from older obsidian-brain versions, etc.). On any remainingRangeError/Cannot bindSQLite-bind error, the wrapper re-throws with the failing node id, the field types, andrm -rf ~/.npm/_npxrecovery guidance — instead of the cryptic raw SQLite wording. - Fix silent 33% note-skip after switching
EMBEDDING_PROVIDER—src/pipeline/indexer.tsapplyNodenow wraps the legacy note-levelembedder.embed(...)call (line ~327) in the same fault-tolerant try/catch as the per-chunk embed loop. Notes that hit transformers.js'smultilingual-e5-basetoken_type_ids bug or any other "too long" / shape error now (a) get logged + recorded infailed_chunksasnote-too-long/note-embed-error, (b) still getsetSyncMtimeso a later reindex can retry, and © keep their per-chunk embeddings (chunk-level retrieval still works). End-of-reindex self-heal detects any note that still has zero chunks, wipes itssync.mtime, and reportsnotesMissingEmbeddingsvia theindex_statustool. - Fix
EMBEDDING_PRESET=multilingual-ollamarouting to HuggingFace 401 instead of Ollama — preset entries now declare aprovider: 'transformers' | 'ollama'field. NewresolveEmbeddingProvider(env)honoursEMBEDDING_PROVIDERoverride →EMBEDDING_MODEL(assumes transformers) → preset's declared provider. SoEMBEDDING_PRESET=multilingual-ollamanow "just works" without ALSO needingEMBEDDING_PROVIDER=ollamaset. Unknown provider values still throw with a clear error listing valid options. - Defensive top-level
index()error classifier — SQL bind / schema-drift errors are now caught at the top of the indexer pipeline, logged with the offending statement fragment, and re-thrown with actionable guidance. MCP clients see"reindex failed: SQL error (likely schema drift or stale install) — …"instead of the raw"Too few parameter values". dropEmbeddingStateclears v6 capacity tables — switchingEMBEDDING_PROVIDER(Ollama → transformers or vice versa) now wipesembedder_capabilityandfailed_chunksalongside the existingnodes_vec/chunks_vec/chunks/syncclear. Closes the cascade where a stale shrunkendiscovered_max_tokensfrom a prior run would force every chunk into the skip path.- New
selfCheckSchema(db)runs inopenDbafterinitSchema. For each v1.7.0 schema-v6 table (embedder_capability,failed_chunks), it cross-references the live column set against the code's expected list viaPRAGMA table_info. Auto-heals fully-missing tables; warns to stderr on missing columns; warns-but-continues on extra columns (forward-compat). Catches stale-cache scenarios where an older obsidian-brain wrote a different schema than the current code reads. - Stderr warning when
EMBEDDING_PRESET=multilingual-qualityresolves — surfaces the documented transformers.js#267token_type_idsbug for inputs > ~400 words and points users atmultilingual-ollama(bge-m3, MTEB multi 0.7558) ormultilingual(e5-small, more tolerant) as alternatives. docs/embeddings.mdsplit — preset catalogue + BYOM + license notes moved to a newdocs/models.md("Models" tab in the docs nav).docs/embeddings.mdstays lean for pipeline architecture (chunking, hybrid RRF, why local). Fixes the misleading "Highest-quality multilingual preset" label that was incorrectly applied tomultilingual-quality(e5-base, MTEB 0.6881) — the title now correctly belongs tomultilingual-ollama(bge-m3, MTEB 0.7558, 16× context window). Adds BYOM entries with exactollama pull/EMBEDDING_MODELrecipes forintfloat/multilingual-e5-large-instruct(MIT, MTEB 0.7781),Alibaba-NLP/gte-modernbert-base(Apache-2.0, 8192 ctx, +8.3pp),onnx-community/embeddinggemma-300m-ONNX(+9.3pp), andonnx-community/mdbr-leaf-mt-ONNX(Apache-2.0, best sub-30M).
v1.7.1 — 2026-04-24 — Docs sweep for v1.7.0¶
No user-visible code change. Documentation-only release. Upgrading from v1.7.0 is drop-in — no schema migration, no config change, no runtime behaviour shift.
docs/tools.md— addedindex_statustool section + capability-matrix row. The tool shipped in v1.7.0 but had no entry in the reference; now documented with all response fields (embeddingModel,chunksSkippedInLastRun,failedChunks[],advertisedMaxTokens,discoveredMaxTokens,reindexInProgress, etc.).docs/embeddings.md— replaced the stale "Available models" table with the actual v1.7.0 six-preset set (english,english-fast,english-quality,multilingual,multilingual-quality,multilingual-ollama). Added a "Deprecated aliases" subsection explaining the model change forbalanced(nowenglish— re-embeds once on upgrade). Speed-numbers table rewritten to use canonical preset names.docs/roadmap.md— renumbered the "Planned / In progress" v1.7.0 section to v1.8.0 (block-ref editing + FTS5 frontmatter + topic-aware PageRank). v1.7.0 shipped as a completely different bundle; the version collision is now resolved. Corresponding v1.8.0 (analytics writeup) moves to v1.9.0.README.md— "17 MCP tools" → "18 MCP tools"; addedindex_statusto the Maintenance bullet.docs/index.md— new "Health & observability" feature card covering fault-tolerant indexing +index_status/reindex.docs/architecture.md— expanded preset-resolver paragraph to v1.7.0 shape (six presets + deprecated aliases + first-boot auto-recommend); addedembedder_capabilityandfailed_chunkstables to the schema listing with their v1.7.0 rationale.
v1.7.0 — 2026-04-24 — Fault-tolerant embeddings, expanded presets, BYOM CLI, index_status tool, one-line macOS installer¶
⚠ One-time background reindex on upgrade — v1.7.0 bumps the prefix-strategy version to close a latent Arctic Embed v2 bug and to add Ollama-routed e5 prefix support. Asymmetric-model users (BGE, E5, Nomic, etc.) will see a one-time re-embed on first boot; semantic search returns a preparing status during it; fulltext + all other tools work throughout.
Fault tolerance + adaptive capacity:
- Fault-tolerant rebuild — per-chunk try/catch so one bad chunk no longer halts a full reindex; skipped chunks are logged and recorded in the new failed_chunks table. Follows NAACL 2025 consensus: skip + log, not recurse-halve.
- Ollama num_ctx override — new options.num_ctx field in every embed request (default 8192); configurable via OLLAMA_NUM_CTX. Ollama's own default of 2048 silently truncates long chunks for models trained on larger contexts.
- Adaptive capacity — tokenizer-aware chunk budgeting reads model_max_length directly from the model's AutoTokenizer; schema v6 adds embedder_capability and failed_chunks tables; failed chunks reduce the cached discovered_max_tokens so future chunks aim smaller. Configurable via OBSIDIAN_BRAIN_MAX_CHUNK_TOKENS.
- EmbedderLoadError — structured error with kind: not-found (model id not on HF), no-onnx (repo exists, no ONNX weights), offline (network unavailable at load time). Wraps around the existing corrupt-cache retry logic.
Preset + BYOM UX:
- 6 presets (was 4): english, english-fast, english-quality, english-longctx, multilingual, multilingual-quality. Deprecated aliases fastest and balanced emit a stderr warning and resolve to their canonical equivalents.
- balanced model change — balanced now resolves to english (bge-small-en-v1.5). The old model (all-MiniLM-L6-v2) is dropped. If you use EMBEDDING_PRESET=balanced you will re-embed once on upgrade. Change to EMBEDDING_PRESET=english to suppress the deprecation warning going forward.
- First-boot auto-recommend — scans vault Unicode blocks on first start and recommends english vs multilingual preset automatically.
- New obsidian-brain models CLI — list, recommend, prefetch, and check <id> subcommands. check downloads, loads, and reports dim + prefix behaviour before you commit to a model.
Prefix fixes:
- Arctic Embed v2 now emits query: prefix (v1 used the longer "Represent…" instruction — now corrected).
- Ollama-routed E5 models now receive query: / passage: prefixes (previously silently dropped, causing a 20–30% quality regression).
- Qwen3 embeddings now receive Query: on the query side.
- Jina v2 and GTE registered as explicit no-ops (no false fallthrough).
- PREFIX_STRATEGY_VERSION bumped 1 → 2 — triggers the one-time reindex described above.
Observability:
- New index_status MCP tool — read-only: reports active model, dim, notes indexed, failed chunks, capacity bounds, whether a reindex is in flight, and any init errors. Call it from your MCP client to inspect index health at any time.
- ctx.reindexInProgress is now a reliable boolean (previously an unreliable promise probe).
- Semantic search returns a preparing status with a reindex-in-progress message when the bootstrap needsReindex flag fires.
Quality:
- TreeRAG parent-heading prefix — each chunk embedding is prefixed with its nearest parent heading path (ACL 2025), improving multi-chunk relevance for long notes.
- v4.2.0 numerical equivalence retro-check — 50-note fixture verifies cosine similarity ≥ 0.99 per note against the v4 baseline after the @huggingface/transformers 3 → 4 upgrade. Threshold chosen to tolerate cross-platform onnxruntime-node SIMD / GEMM accumulation drift (Linux AVX ↔ macOS NEON produce 0.997–0.999 cosine on quantized q8 inference — inherent float-math divergence, not a library regression) while still catching every real regression worth catching (tokenizer break, pooling shift, weight corruption, sign flip, wrong model — all drop well below 0.95). An afterAll drift-floor warning prints the minimum cosine per run with runtime + baseline platforms tagged, so maintainers can spot downward trends before they red-line. Baseline JSON records platform, arch, and onnxruntime-node version for future debug.
- Schema v6 migration is idempotent; existing databases upgrade in-place.
CLI + retry polish:
- prefetchModel default maxAttempts lowered from 4 to 3. obsidian-brain models prefetch and models check now fail faster on unreachable / missing models — three attempts is the industry-standard retry budget and matches the HF CLI. Explicit overrides can still pass maxAttempts via the option.
- New test/embeddings/prefetch-integration.test.ts — actually downloads and probes Xenova/bge-small-en-v1.5 via the real @huggingface/transformers, and verifies the retry loop rejects with attempts = N when a real HF model id doesn't exist. Previously the mock-based unit tests injected fake error strings but no real model load was ever exercised. Runs by default; opt out with OBSIDIAN_BRAIN_SKIP_BASELINE=1 (same flag as v4-equivalence.test.ts, which shares the HF cache).
One-line macOS installer:
- scripts/install.sh — a Homebrew-style curl-piped bash installer that automates every step of docs/install-mac-nontechnical.md: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/sweir1/obsidian-brain/main/scripts/install.sh)". Installs Homebrew non-interactively if missing, installs or upgrades Node to ≥ 20.19.0, symlinks node + npx into /usr/local/bin so GUI-launched Claude Desktop can resolve them (the recurring spawn npx ENOENT failure mode), prompts for the vault path with auto-detection of ~/Documents/Obsidian Vault, ~/Documents/Obsidian, and iCloud ~/Library/Mobile Documents/iCloud~md~obsidian/Documents/*, pre-warms the npx cache so any ERR_DLOPEN_FAILED from a mismatched better-sqlite3 ABI surfaces before Claude Desktop ever spawns (with the exact rm -rf ~/.npm/_npx remediation printed inline), and politely osascript quits + relaunches Claude so the new Full Disk Access grant takes effect on next launch.
- Non-destructive config merge. A small node one-liner JSON-merges the obsidian-brain entry into ~/Library/Application Support/Claude/claude_desktop_config.json, preserving every other mcpServers entry and every top-level key. A timestamped .bak.<epoch> of the pre-merge file is written on every run — even when the existing config is malformed JSON, the original is preserved and a fresh config is written. Re-running is idempotent (returns replaced instead of added when the entry already exists). Node was chosen over jq (not installed on stock macOS) and python3 (deprecated on stock macOS) because the installer just installed Node for the user as a hard prerequisite.
- Full Disk Access via deep-link, not automation. The pane is opened with the Ventura+ URL x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles and falls back to the legacy com.apple.preference.security URL for older macOS. The installer then blocks on an Enter press rather than polling — the TCC grant lives in /Library/Application Support/com.apple.TCC/TCC.db which is SIP-protected at the kernel level, tccutil only exposes reset (never grant), and the only supported silent path is an MDM-delivered PPPC profile.
- README.md, docs/index.md, docs/install-mac-nontechnical.md — updated to surface the one-liner above the existing manual JSON snippet. The manual walkthrough stays intact as the auditable reference the script mirrors step-for-step.
- package.json — scripts/install.sh added to the files array so the installer ships in the npm tarball alongside dist/.
New env vars (declared in server.json per v1.6.21 validator):
- OLLAMA_NUM_CTX — override Ollama's context window for embed requests (default 8192).
- OBSIDIAN_BRAIN_MAX_CHUNK_TOKENS — override the adaptive chunk-size budget in tokens.
v1.6.22 — 2026-04-24 — Split coverage discipline into its own doc¶
No user-visible change. Documentation-only release. Upgrading from v1.6.21 is drop-in — no schema migration, no config change, no runtime behaviour shift.
docs— extract coverage discipline fromRELEASING.mdintodocs/coverage.md.RELEASING.mdhad grown to 811 lines, with ~217 lines of coverage-gate essay (gate shape, V8 provider rationale,/* v8 ignore */policy, fast-check pilot, grandfather mechanism, two discipline principles, manual ratchet, escape hatch) buried mid-doc. The coverage content moves to its own standalone mkdocs page —docs/coverage.md, linked from the site nav under Project → Test coverage.RELEASING.mdkeeps a short gate-summary section naming the three enforcement checkpoints (preflight, CI, release gate) and explicitly directing the reader todocs/coverage.mdas required reading before a first release.docs— trim branch-protection essay fromRELEASING.md. Three subsections were restating what the ruleset definitions already made explicit: "Defense in depth — why these give you what you asked for" (prose restating the rule list above it), "Emergency escape hatch" (agh apirecipe findable in 5s via web search), and "If CI breaks (temporary)" (an edge-case flag forsetup:protection). All three deleted. The ruleset-by-ruleset breakdown (main hard / main workflow / dev) stays — that's the actual operational info.- Net:
RELEASING.md811 → 603 lines. Coverage content reachable as its own doc. mkdocs strict build green.
v1.6.21 — 2026-04-24 — Validate server.json before publish; drop dist-tag auto-roll¶
No user-visible change. Release-plumbing release. Upgrading from v1.6.20 is drop-in — no schema migration, no config change, no runtime behaviour shift.
ci(release)— moveserver.jsonvalidation beforenpm publish. The previous ordering ran./mcp-publisher validateAFTER npm already had the tarball, so a malformedserver.json(e.g. drift in the hand-maintainedenvironmentVariables[]list) would leak an un-publishable-to-MCP-Registry version onto npm. Now validation runs immediately after build, before either publish — ifserver.jsonfails the schema, nothing ships. Matches the pre-existing npmpreversionhook pattern (check first, mutate second).ci(release)— drop thepreviousdist-tag auto-roll. v1.6.20's release.yml added two steps that tried to capture the pre-publishlatestand setpreviousto it after publish. The post-publish step failed with E401 on every release, because npm's OIDC trusted publisher token is scoped topublishonly — it can't authenticatenpm dist-tag add. Worse, the failure cascaded (default GitHub Actions skips subsequent steps) and took MCP Registry + GitHub Release down with it on v1.6.20. Dropping the feature entirely: keeping OIDC-only auth (no long-livedNPM_TOKENsecret) meanspreviousis maintained manually when it matters:npm dist-tag add obsidian-brain@X.Y.Z previous.ci(release)— reorder install-mcp-publisher earlier. mcp-publisher binary is now downloaded before npm publish so that./mcp-publisher validatecan run onserver.jsonbefore any publish. Login to the MCP Registry stays where it was (after npm publish, before MCP publish) — the OIDC token exchange at login time is independent of package state.
v1.6.20 — 2026-04-24 — Auto-roll previous dist-tag on every publish¶
No user-visible change. Release-plumbing release. Upgrading from v1.6.19 is drop-in — no schema migration, no config change, no runtime behaviour shift.
ci(release)— rollpreviousdist-tag on every publish. Thepreviousnpm dist-tag was set manually during the v1.6.16 / v1.6.17 out-of-order recovery and then stayed pinned at 1.6.17 across every subsequent release, because nothing moved it.release.ymlnow has two new steps bracketingnpm publish: (1) captures the currentlatestbefore publishing (into a step output), (2) after publish rollspreviousto that captured value. Net effect:previousalways tracks "the version one before the currentlatest", auto-maintained. Useful as a well-defined rollback target for each release. Skips cleanly on first-ever publish or a same-version republish. Reuses the OIDC session set up fornpm publish— no extra secret or OTP.
v1.6.19 — 2026-04-24 — Release system hardening (B5 follow-up)¶
No user-visible change. Release-plumbing release. Upgrading from v1.6.18 is drop-in — no schema migration, no config change, no runtime behaviour shift.
Follow-up to the B5 flow that shipped in v1.6.14. Three issues surfaced during the v1.6.14 → v1.6.18 back-to-back ship and are closed here:
fix(promote)— auto-resolve CHANGELOG merge-back conflicts. Under B5, dev carries CHANGELOG entries for yet-to-ship future releases above the one just shipped. Whenpromotemergesorigin/mainback intodev, git flagsdocs/CHANGELOG.mdas conflicted — even though the correct resolution is trivially "keep dev's side" (dev has the superset).promote.mjsnow detects the only-CHANGELOG-conflicted case and auto-runsgit checkout --ours docs/CHANGELOG.md && git add && git commit --no-edit. Any other conflicted file still fails loudly with the same recovery hint as before. Zero manual steps per release.fix(ci)— queue concurrent CI runs instead of cancelling.ci.ymlpreviously hadcancel-in-progress: truewith a concurrency group keyed only on the ref, so rapid-fire main pushes caused a cancel-cascade: each new commit's CI cancelled the previous commit's still-running CI. Combined withrelease.yml's new "Wait for CI green on this SHA" gate, this blocked publishes for older commits (it's how v1.6.16 and v1.6.17 ended up tagged-on-main-but-not-on-npm, requiring a manual--tag previousrecovery). Fix:cancel-in-progress: false+ SHA in the group so different commits queue instead of cancel, and re-pushes of the same commit still coalesce.ci(release)— drop smoke + HF prefetch fromrelease.yml. The new CI-gate step confirmsci.ymlwas green on the exact tagged SHA beforerelease.ymlproceeds. Re-running smoke / HF cache restore + save / prefetch inside the release job was pure duplication of workci.ymlalready did on the same commit. Kept: Node setup,npm ci, build (those produce the tarball npm publishes — they're prep, not validation). Dropped: HF restore, prefetch, HF save, smoke. ~60–90s saved per release.chore(deps-actions)— bumpactions/upload-artifact4 → 7 (coverage report upload inci.yml). Drop-in for our usage —name,path,retention-days,if-no-files-foundall unchanged. v6 moved the action's runtime from Node 20 → 24, which resolves the Node 20 deprecation warning GitHub was annotating on every CI run (ahead of Node 20's removal from runners in September 2026). New opt-in inputs (archive,compression-level,overwrite,include-hidden-files) all default to v4-compatible values.
v1.6.18 — 2026-04-24 — chore: bump @huggingface/transformers 3 → 4¶
No user-visible change expected. Dependency-update release. Upgrading from v1.6.17 is drop-in for the default english preset in the environment where preflight ran; fastest/balanced/multilingual presets will re-download models on first use if ONNX file formats differ between v3 and v4.
@huggingface/transformers3.8.1 → 4.2.0 (new C++ WebGPU runtime on ONNX Runtime; tokenizers extracted to@huggingface/tokenizers, jinja extracted to@huggingface/jinja; build moved Webpack → esbuild). Our usage insrc/embeddings/embedder.ts—pipeline('feature-extraction', model, { dtype: 'q8' }),env.cacheDir, the customExtractorinterface (tolist(),dispose()) — works unmodified under v4.- New transitive runtime deps (auto-installed):
@huggingface/tokenizers@^0.1.3,@huggingface/jinja@^0.5.6,sharp@^0.34.5,onnxruntime-node@1.24.3,onnxruntime-web(dev build). No direct dev-dep addition required. - If reverting to v1.6.17 or earlier, run
rm -rf $TRANSFORMERS_CACHEbefore rolling back — v4 ONNX files aren't guaranteed to be readable by v3.
v1.6.17 — 2026-04-24 — chore: bump typescript 5.9 → 6.0¶
No user-visible change. Dependency-update release. Upgrading from v1.6.16 is drop-in — no schema migration, no config change, no runtime behaviour shift.
typescript5.9.3 → 6.0.3 (the "prepare for TS7 Go port" release). Zero source edits and zerotsconfig.jsonedits required — our config already sidesteps every 6.0 deprecation (moduleResolution: "nodenext"notnode|classic,target: "ES2022"notES5, nobaseUrl, nooutFile, nomodule: amd|umd|system). Build, tests, coverage, smoke, strict docs build all green under 6.0.3 on first try.ignoreDeprecations: "6.0"intentionally not added — nothing in our tree triggers a deprecation, so the flag would silence warnings we don't have. Add it if/when transitive@types/*start warning in a later bump.
v1.6.16 — 2026-04-24 — chore: bump chokidar 4 → 5, node floor 20.19¶
No user-visible change. Dependency-update release. Upgrading from v1.6.15 requires Node.js ≥ 20.19.0; drop-in otherwise.
chokidar4.0.3 → 5.0.0. Onewatch()call insrc/pipeline/watcher.ts(function-basedignoredmatcher, four handlers foradd/change/unlink/error). No source changes required — our matcher was already function-based (regex), which is what chokidar 5 wants. Internals are ESM-only now; package size dropped ~150kb → ~80kb.engines.nodebumped>=20→>=20.19.0. Chokidar 5 requires Node 20.19+ (the first 20.x that canrequire()ESM synchronously). If you're running the MCP server under an older Node, bump it —nvm install 20.19or later. CI and release workflows were already on Node 24, so no workflow changes.
v1.6.15 — 2026-04-24 — chore: bump diff 8 → 9¶
No user-visible change. Dependency-update release. Upgrading from v1.6.14 is drop-in — no schema migration, no config change, no runtime behaviour shift.
diff8.0.4 → 9.0.0. Our usage is limited tocreatePatch()string output insrc/tools/edit-note.ts(two call sites, dry-run diff summaries for bulk + single edits). v9's API-surface changes — ES5 support dropped,merge()removed, stricterparsePatch(mismatched header counts +---/+++-only patches now rejected),StructuredPatch.oldFileName/newFileNametypedstring | undefined, UMD global renamedJsDiff→Diff— don't affect us. No source edits required.- Types ship in-package in v9.
@types/diffwas never in ourdevDependencies; no co-change needed.
v1.6.14 — 2026-04-24 — Test rigor + branch coverage lift¶
No user-visible change. Test-suite-only release. Upgrading from v1.6.13 is drop-in — no schema migration, no config change, no runtime behaviour shift.
Follow-up to v1.6.13's coverage-gate setup: the gate was green but the underlying branch coverage sat at 76.95%, 13pp behind lines (90.42%). This release investigates why, fixes what's worth fixing, and documents what isn't. Final numbers: statements 91.6 (+2.4pp), branches 81.8 (+4.9pp), functions 90.6 (+1.8pp), lines 92.6 (+2.1pp). All 537 tests green.
- Why branches trails lines — and why 85% is the realistic ceiling. Every
if/ternary/??/?./||/&&/default-param is a countable branch; a single line likeconst x = user?.profile?.name ?? 'anon'produces 5 branches per 1 line. Happy-path tests give 100% lines and ~40% branches on that line. A 10–20pp gap is textbook for idiomatic TypeScript (Codecov). 61% of the pre-v1.6.14 gap was defensive/unreachable code (catcharms,instanceof Errorelse-arms, ABI-heal messaging). Chasing those with contrived tests is coverage theatre. - V8 provider is accurate under Vitest 4 — not inflated. Since Vitest 3.2 the coverage-v8 provider ships
ast-v8-to-istanbulAST remapping (Vitest 3.2 blog). Vitest 4 removed the old heuristic path entirely and makes AST the only mode (Vitest 4 migration). Our 81.8% branches is a real number; switching to Istanbul would return the same number at ~3× the runtime. - 22 rigor tests added across replace-window (UTF-8 multibyte + regex-metachar literal match), fts5-escape (non-ASCII phrase-quoting + round-trip through a real FTS5 MATCH), patch-frontmatter (YAML array/object round-trip, overwrite, insert-into-fm-less, clear non-existent), parser (unclosed-frontmatter graceful fallback + console.warn signal), centrality (betweenness + PageRank ordering on star/chain topologies — the existing shape-only asserts would pass on a randomised algorithm). All 22 passed on first run, so existing code is correct; the tests now serve as regression guards against two plausible future bugs (
Buffer.byteLengthvs.lengthconfusion,\wregex "fix" that breaks non-ASCII handling). - 20 coverage-gap tests added: new
test/tools/list-notes.test.ts(no prior test existed), themeId paths onrank-notes(previously untested), nomic/mxbai/arctic-embed document-arm + mixedbread||short-circuit onembedder-prefix,preserveCodeBlocks: false+preserveLatexBlocks: false+ sentence-split path onchunker, backward-edgefirstEdgeContextonpathfinding. ~+3.8pp branches / ~+2pp lines. errorMessagehelper + single/* v8 ignore next */— nine catch-sites acrossedit-note/register/editor/context/obsidian/clienteach reimplementederr instanceof Error ? err.message : String(err). Centralised intosrc/util/errors.ts :: errorMessage(err)with one coverage-ignore on theString(err)fallback; the else arm was always unreachable noise (no call site throws non-Errorvalues). Net: -18 unreachable branch arms, single rationale in one place. Grandfatheredwatcher.tsstill has three inline sites — deliberately left alone to keep this commit scoped to coverage-measured files.- fast-check property-pilot for the chunker. New
test/embeddings/chunker.properties.test.tsruns three invariants over 500 total random markdown documents: chunkIndex contiguity, no raw protect-sentinel leakage, fenced code blocks appear intact in exactly one chunk. <1s added to the test run. Scoped to chunker because the sentinel protect/restore cycle and hard-cut collision avoidance have failure modes example-based tests can't anticipate. Expansion candidates (deferred):wiki-linksrewrite round-trip,fts5-escapeMATCH-validity across arbitrary Unicode. - Thresholds unchanged at 57/37. The per-file-minimum floor-setters (
src/context.tsat 59.5 lines,src/tools/link-notes.tsat 40.0 branches) didn't shift meaningfully — the files we tested weren't those floor-setters. Global aspirational target of 85% branches / 92% lines documented inRELEASING.md(not enforced — aspirational lives in docs, floors live in config). Next manual ratchet when the floor-setters themselves get dedicated tests. - Out of scope (deferred): Stryker mutation testing (setup cost on TS+ESM+NodeNext ~half-day; right tool for the gap at 90%+ lines, wrong release), Istanbul provider switch (rejected — Vitest 4 V8 is equally accurate and faster), Codecov integration (json-summary already in reporters; adopt when a contributor joins).
v1.6.13 — 2026-04-23 — Vitest coverage gate + promote cherry-pick flow¶
No user-visible change. CI + test-infrastructure addition. Upgrading from v1.6.12 is drop-in — no schema migration, no config change, no runtime behaviour shift.
Adds V8-provider coverage measurement via @vitest/coverage-v8, enforced per-file on every PR and push to main/dev. The gate fires locally via npm run preflight (so npm run promote can't slip a coverage regression past CI into a tag) and in .github/workflows/ci.yml (so PRs can't merge with under-threshold code). Coverage HTML report is uploaded as a CI artifact on every run (success or failure), so threshold trips are actionable from the GitHub Actions UI without a local re-run.
- Thresholds: baseline-anchored (per-file-minimum on non-excluded files, minus 3pp for refactor tolerance). Lines 57, branches 37 at v1.6.13 baseline.
perFile: trueso a 0%-covered new file trips the gate regardless of the project average. No autoUpdate — manual ratchet via small PR when baseline shifts up meaningfully. SeeRELEASING.md→ "Test coverage" for the discipline principles (forward: new tests must assert; backward: don't retrofit existing tests to raise numbers). - Provider choice — V8 over Istanbul: ~10% runtime overhead vs 20–40%, source-map-clean with vite-node, accurate enough for this codebase's imperative control flow.
- Grandfather mechanism —
coverage.exclude, not per-path thresholds. Discovered during implementation: in vitest 4, per-path threshold keys can only raise the bar on matched files — they don't exempt files from the global floor. Vitest source is explicit: "Global threshold is for all files, even if they are included by glob patterns" (see vitest-dev/vitest#6165).coverage.excludeis the only mechanism that actually exempts a file. Seven files currently excluded with rationale + TODO comments invitest.config.ts:src/cli/index.ts(untested legacy CLI),src/server.ts(subprocess blind spot — V8 coverage doesn't follow into child processes; validated byserver-stdin-shutdown.test.tsinstead),src/pipeline/watcher.ts(genuinely untested), the three plugin-dependent toolsactive-note.ts/base-query.ts/dataview-query.ts, andsrc/tools/find-path-between.ts(missing direct wrapper test). - New npm scripts:
test:coverage(runs insidepreflightand CI),test:coverage:watch. Plaintestandtest:watchstay coverage-free for fast local TDD loops. - CI integration:
.github/workflows/ci.ymlnow runsnpm run test:coveragein place ofnpm test(the plain step, not wrapped in a retry — corrupt-HF-cache recovery already lives upstream inscripts/prefetch-test-models.mjs). New "Upload coverage report" step usesactions/upload-artifact@v4withif: always()and 14-day retention so the HTML report is reachable from every CI run green or red. Step-level cross-reference comments point betweenci.ymlandscripts/preflight.mjsso the two invocation sites can't silently drift. - RELEASING.md gains a full "Test coverage" section covering gate shape, the exclude-based grandfather mechanism (with the vitest 4 behaviour explained), two discipline principles, manual ratchet cue, and escape hatch (write the test → adjust global threshold → add exclude, in that order of preference).
@vitest/coverage-v8 pinned at ~4.1.0 — ships in lockstep with vitest major.minor, so patch-pin forces deliberate review on any minor bump.
Promote script: cherry-pick flow (no dev force-push). scripts/promote.mjs previously rebased dev onto main and git push --force-with-lease origin dev when releasing a commit older than dev HEAD ("cherry-pick release"). The rebase rewrote every dev commit after the target, so planned multi-release sequences had to re-resolve SHAs between each promote. The new flow uses git cherry-pick -x to copy pending commits from dev onto main as new linear commits, leaving dev untouched. Pending commits are detected via git cherry origin/main <target> — patch-id equivalence auto-skips content shipped in earlier promotes, so subsequent cherry-pick releases Just Work with zero tracking ref. Net result: dev SHAs are stable across any number of releases.
- Trade-off (deliberate): dev's
package.json/server.jsonno longer auto-sync to main's latest release. Nothing reads dev's version at runtime;release.yml'sjqrewrite overrides from the tag at publish time. Manual one-liner to sync:npm version <ver> --no-git-tag-version --allow-same-version && git commit -am "chore: sync dev" && git push origin dev. - Preflight now mandatory + automatic:
npm run promoteinvokesnpm run preflightas its first step and aborts before touching main if anything is red. Manual pre-check is no longer required. Bypass with--skip-preflight(rare — GHA outage, known-flaky dep). - Preflight extended to mirror
ci.yml: two new steps —docs:build(strict MkDocs build) andcodespell(best-effort; warns + skips if binary is missing,pip install codespellto enable). Gap-closes preflight vs CI so a green local run is a strong signal for a green CI run. - New flags:
--dry-run(preview pending commits + preflight, no mutation) and--skip-preflight(bypass the gate). Order-independent with existing bump/target args. - Conflict handling: cherry-pick conflicts on main exit 1 with a resolution hint, leaving main in the conflicted state. Run
git cherry-pick --continue/--abortas needed, orgit reset --hard origin/mainto start over. RELEASING.mdrewritten: "Whatpromoteactually does", "Devpackage.jsonlags main's releases", "Manual / fallback flow" (updated to cherry-pick steps), and "Branch protection →dev" (force-push still allowed for one-off surgery, but no longer used bypromote).
Auto-sync workflow: dev's package.json stays current without manual steps. New .github/workflows/sync-dev-version.yml fires on every v* tag push (same trigger as release.yml) and bumps dev's package.json + server.json + package-lock.json to match the tag. Each run produces a one-step bump commit whose diff is patch-id-equivalent to main's npm version bump, so git cherry in subsequent promote runs silently skips it — no duplicate cherry-picks on main, no cherry-pick landmines. Uses the default GITHUB_TOKEN (with permissions: contents: write scoped to this job), which by GitHub's design does NOT trigger additional workflow runs — so the sync commit on dev does not re-fire ci.yml (no infinite loops). If dev somehow ends up more than one patch step behind the tag (skip-ahead scenario), the workflow flags a ::warning:: in the run log and applies the sync anyway; in that case a future git cherry will mark the sync commit + (not patch-id-equivalent) and a later promote may conflict on it — manual catch-up via incremental bumps is the recovery.
v1.6.12 — 2026-04-23 — Test-layout refactor¶
No user-visible change. Pure test-suite reorganisation + a new shared-helpers directory. Upgrading from v1.6.11 is drop-in — no schema migration, no config change, no runtime behaviour shift.
- Split five oversized test files into focused siblings grouped by feature:
test/integration/server-init-timing.test.ts(753 lines, 18 tests) → 4 files undertest/integration/server-init-timing/(search, list-notes, write-tools-immediate-response, write-tools-eventual-reindex)test/tools/move-note.test.ts(589 lines, 13 tests) → 5 files undertest/tools/move-note/(rewrite-inbound-links, stub-pruning, dry-run, ghost-link-fix, rename-node)test/vault/editor.test.ts(427 lines, 32 tests) → 5 files undertest/vault/editor/(position, replace-window, patch-heading, patch-frontmatter, bulk)test/obsidian/client.test.ts(424 lines, 16 tests) → 3 files undertest/obsidian/client/(discovery-auth, dataview, base)test/integration/graph-tools.test.ts(419 lines, 7 tests) stays as one file but now imports shared helpers and uses acleanupCtxhelper for the fire-and-forget reindex-drain teardown- New
test/helpers/directory hosts repo-wide test utilities:mock-server.ts(MCPmakeMockServer+unwrap),mock-embedders.ts(InstantMockEmbedder+SlowMockEmbedder),init-timing-ctx.ts(controllable init-state ServerContext),reindex-spy.ts(fire-and-forget spy + poll helper),graph-ctx.ts(simple real-embedder ctx + teardown) - Full suite at 492/492 passing, zero type errors, preflight green, no source (
src/) changes
v1.6.11 — 2026-04-23 — Schema rename to target_subpath + explicit migration chain + auto-heal on Node-ABI mismatch¶
No user action required. Internal refactors + a new best-effort recovery path. Upgrading from v1.6.10 is drop-in: the migration chain handles schema v4 → v5 automatically on next boot, existing data survives the ALTER TABLE RENAME COLUMN, no reindex needed. Fresh installs get the new column name from day 1.
Schema rename (internal). edges.target_fragment → edges.target_subpath. Aligns the column with the Obsidian ecosystem's convention (Obsidian API LinkCache.subpath, Dataview Link.subpath, Juggl — all use subpath; we were the odd one out using target_fragment, a legitimate HTML/URL prior-art name but non-standard here). Zero public-API impact — the column isn't visible to agents or tool responses.
Explicit migration chain. Replaced the one-off "if schema_version != SCHEMA_VERSION" branch in bootstrap() with a proper SCHEMA_MIGRATIONS array keyed by target version. The runner walks the chain in order, bumping schema_version incrementally so a crash mid-chain is safe. A belt-and-braces unconditional pass at the end runs every migration helper (all PRAGMA-guarded idempotent) so DBs where schema_version got stamped ahead of the actual schema get healed automatically. This is the infrastructure for future schema changes — add a migration in two places (helper + array entry) and it plays forward correctly from any historical schema version.
Auto-heal on Node-ABI mismatch (v1.6.10 static error → v1.6.11 best-effort rebuild). When better-sqlite3 fails to load because its compiled ABI doesn't match the current Node (typical after a Node upgrade leaves a stale cached binary in ~/.npm/_npx/), the server now detects this, spawns a detached npm rebuild better-sqlite3 in the background, logs the rebuild to /tmp/obsidian-brain-rebuild-*.log, and tells the user to restart in ~60 seconds. A per-ABI marker at ~/.cache/obsidian-brain/abi-heal-attempted-<ABI> prevents infinite retry loops — if the rebuild itself keeps failing (typically a missing C++ toolchain), the second restart shows a "manual fix required" message pointing at the log. Windows falls back to the v1.6.10-style static error message (detached subprocess semantics differ).
src/pipeline/bootstrap.ts: introduced explicitSCHEMA_MIGRATIONSarray at module scope; bootstrap loops through it, bumpingschema_versionincrementally; belt-and-braces unconditional second pass at the endsrc/store/db.ts:SCHEMA_VERSION = 5,CREATE TABLE edgesnow usestarget_subpath,renameTargetFragmentToSubpath()migration helper added (PRAGMA-guarded idempotent),ensureEdgesTargetFragmentColumn()extended to be a no-op on v5+ DBssrc/store/edges.ts,src/types.ts,src/vault/parser.ts,src/pipeline/indexer.ts: renametargetFragment→targetSubpathin types + SQL + parser outputsrc/context.ts:tryAutoHealAbiMismatchwrapsdoAutoHealin an outer try/catch so any unexpected failure degrades cleanly to the v1.6.10 static message. Spawns plainnpm rebuild better-sqlite3(dropped the--update-binaryflag — that was a node-pre-gyp passthrough,better-sqlite3usesprebuild-installwhich doesn't recognize it).rebuildCwdfixed to point at project root instead of insidenode_modules. Stale binary atbuild/Release/better_sqlite3.nodeis pre-deleted soprebuild-installalways fetches a fresh correct-ABI tarballpackage.json: dropped the same--update-binaryflag from the postinstall hook for the same reasontest/pipeline/bootstrap.test.ts: newv4 → v5rename-migration test; existingpre-v4and belt-and-braces tests updated totarget_subpathtest/pipeline/indexer.test.ts,test/tools/find-connections.test.ts,test/tools/read-note.test.ts: rename references updated; pre-v5 state simulated by droppingtarget_subpath(since fresh:memory:now starts at v5)test/context.test.ts: auto-heal tests for the three paths (first attempt spawns rebuild, marker-exists path skips spawn, non-ABI errors pass through).withEnvhelper madeasyncso env vars stay set until the async test body resolves.fs.unlinkSyncmocked so the "delete stale binary" step doesn't touch the real repo'sbetter_sqlite3.node
v1.6.10 — 2026-04-23 — Clean shutdown (no more libc++abi crashes) + Node-ABI mismatch defense¶
⚠ Shutdown-crash fix. On shutdown the server used to call process.exit(0) immediately after closing the chokidar watcher, leaving the ONNX Runtime thread pool (used by the default transformers.js embedder) mid-flight. V8 tore down the addon's heap while worker threads were blocked on pthread_mutex_lock, producing libc++abi: terminating due to uncaught exception of type std::__1::system_error: mutex lock failed: Invalid argument on stderr and an abnormal SIGABRT exit. Hosts (Claude Desktop, Jan) saw the server as unstable and could back off. No data loss — WAL mode is crash-safe — but noisy. Now shutdown explicitly awaits embedder.dispose(), closes the SQLite handle, then lets the event loop drain naturally (with a 4 s hard-exit fallback in case something refuses to release).
Node-ABI mismatch — first-class error + passive defense. If a cached ~/.npm/_npx/.../better-sqlite3.node was compiled for a different Node major than the runtime, Node emits a raw NODE_MODULE_VERSION X ... requires Y error that names an opaque hash-keyed path and gives no remediation hint. The server now detects this at startup and rewrites the error to include the one-line fix (rm -rf ~/.npm/_npx). A postinstall hook (npm rebuild better-sqlite3 --update-binary) makes future npx @latest installs rebuild against the current Node automatically, closing the trap on Node upgrades.
src/server.tsshutdown: explicitawait ctx.embedder.dispose()(if ready) +ctx.db.close()+process.exitCode = 0instead ofprocess.exit(0), with a 4 s.unref()fallback timersrc/context.ts: wrapopenDb()in a try/catch that recognisesNODE_MODULE_VERSION/ERR_DLOPEN_FAILEDand re-throws with remediation + link to docspackage.json:"postinstall": "npm rebuild better-sqlite3 --update-binary || true"— rebuilds the native module against the current Node on every fresh installdocs/troubleshooting.md: extended theERR_DLOPEN_FAILED: NODE_MODULE_VERSION mismatchsection with the npx-cache-poisoning scenariotest/integration/server-stdin-shutdown.test.ts: new SIGTERM test asserts exit code 0 plus stderr contains nolibc++abi/mutex lock failedtest/context.test.ts(new): mocksopenDbto throw ABI and non-ABI errors; verifies the guard rewrites the first case, passes through the second
No data migration or reindex needed — upgrading from v1.6.9 is drop-in.
v1.6.9 — 2026-04-23 — find_connections / read_note migration fix + Jan compatibility¶
⚠ Data-integrity fix for upgraders. Any database created before v1.6.5 was missing the edges.target_fragment column. v1.6.5 introduced the column in the CREATE TABLE IF NOT EXISTS body (so fresh installs were fine) and shipped an idempotent ensureEdgesTargetFragmentColumn() migration helper, but the call site inside bootstrap() was never wired up. Upgraders saw Error: no such column: target_fragment from find_connections and read_note (full mode) from v1.6.5 onward — even across rebuilds — because bootstrap() bumped schema_version to 4 without actually running the ALTER TABLE. search, list_notes, and dataview_query were unaffected because they don't touch the edges table. This release actually runs the migration.
Jan compatibility. v1.6.8 added process.stdin end / close → process.exit(0) handlers to stop zombie servers when the host crashed. Unfortunately Jan (jan.ai) briefly closes stdin during its local-LLM model load between the MCP initialize handshake and the first tools/list, which tripped those handlers and killed the server mid-boot — every subsequent tool call got Transport closed. Replaced with a cross-platform orphan watcher that probes the original parent PID once a minute via process.kill(pid, 0), plus the MCP SDK's own transport.onclose for normal shutdowns. Ghost-process defense is preserved; Jan no longer trips it.
src/pipeline/bootstrap.ts: callensureEdgesTargetFragmentColumn(db)inside the schema-version-bump branch AND once unconditionally before return (belt-and-braces, matches howensureVecTablesis already called on every boot; the helper is PRAGMA-guarded so double-call is free)src/server.ts: removedprocess.stdin.on('end'/'close')→process.exit(0). Addedtransport.oncloseplus asetInterval(60 s,.unref()) that callsprocess.kill(originalPpid, 0)and shuts down onESRCH. Cross-platform: macOS/Linux catch reparenting-to-PID-1; Windows catches the dead-parent-PID case. One syscall per minute — zero measurable costtest/pipeline/bootstrap.test.ts: two new regression tests — pre-v4 DB withschema_version=3triggers the bump-branch migration, and pre-v4 DB withschema_version=4already current triggers the unconditional heal pathtest/tools/find-connections.test.ts(new): handler smoke against a pre-v4 DB — would have caught the original bug at PR timetest/tools/read-note.test.ts(new): handler smoke for bothbriefandfullmodes
Cleanup if you were stuck on v1.6.5–1.6.8 with a pre-v4 DB: nothing to do — next boot under v1.6.9 runs the ALTER TABLE automatically. Existing rows get target_fragment = NULL (valid — only heading / block wiki-links populate it). No reindex required.
v1.6.8 — 2026-04-23 — Exit cleanly when MCP client disconnects (no more zombie processes)¶
⚠ Zombie-process fix. When an MCP client (Claude Desktop, Jan, Cursor, Codex, VS Code) crashed or was force-quit without cleanly shutting down the servers it spawned, obsidian-brain server kept running — reparented to launchd (macOS) or init (Linux) — until the user manually killed it. Across many client restarts / crashes, zombies accumulated indefinitely.
The stdio transport signals "parent gone" by closing the pipe (stdin EOF on the server side). Previously the server only listened for SIGINT and SIGTERM, which crashed clients don't send. Now process.stdin end and close events trigger the same graceful shutdown path, with a shutdown-reason logged to stderr so users watching logs understand why the process exited.
src/server.ts+src/cli/index.ts: stdinend/closeevents call the shutdown handler (alongside the existing SIGINT / SIGTERM signals), with an idempotentshuttingDownguard so duplicate triggers don't double-fire- Shutdown now logs its reason (
SIGINT/SIGTERM/stdin EOF (MCP client disconnected)/stdin closed (MCP client disconnected)) to stderr - New
test/integration/server-stdin-shutdown.test.tsspawns the compiled CLI, closes stdin, and asserts the child exits within 3 s
To clean up any zombie obsidian-brain server processes from before this fix: pkill -f 'obsidian-brain server' (macOS/Linux).
v1.6.7 — 2026-04-23 — MCP init timeout fix + non-blocking write tools¶
⚠ Behavior change for write tools. create_note, edit_note, apply_edit_preview, move_note, delete_note, and link_notes now return as soon as the write completes; the subsequent reindex runs in the background instead of being awaited inside the response. A newly-written note becomes searchable within a few seconds (same window as the file watcher has always had for out-of-band edits). Agents or scripts that implicitly relied on synchronous write-then-search must either await a small delay or explicitly call reindex before the follow-up search.
Init timing fix. The MCP initialize handshake no longer waits for the embedding-model download. Previously, on a fresh install with slow internet, the ~34 MB model download took longer than MCP clients' (Claude Desktop, Jan, Cursor) handshake timeout, leaving users locked out with a "tools failed" message. Now server.connect(transport) runs immediately; the model download + first-time index proceed in parallel. Tools that don't need the embedder (list_notes, read_note, find_connections, find_path_between, rank_notes, all write tools, fulltext search, plugin-dependent tools) respond instantly. Semantic search returns a structured {status:'preparing', message:…} response during the download window — within the client timeout — instead of hanging.
If the background init fails (e.g. model not found, network error), semantic search returns {status:'failed', message:…} with an actionable message; restart the MCP server to retry.
- Reordered
server.connect(transport)to run before the embedder + first-time-index pipeline search({mode:'semantic' | 'hybrid'})returnspreparing/failedstatus immediately when the embedder isn't ready- Six write tools fire-and-forget their post-write reindex; the
reindex: 'failed'envelope is removed from their return types fulltextsearch, all read tools, graph tools, and write tools are unblocked from first-run model download- Embedder auto-recovers from a corrupt local Hugging Face cache: on a
Protobuf parsing failed/Load model failed/Unable to get model fileerror on first load, the model's cache subdirectory is wiped and re-downloaded once automatically (previously required a manualrm -rfof the HF cache) - 18 new integration tests in
test/integration/server-init-timing/drive a slow-init mock embedder end-to-end; full suite at 479/479 passing with zero stderr noise from background reindexes
v1.6.6 — 2026-04-23 — Docs + website overhaul + release automation¶
Server runtime behavior unchanged. Large docs, website, and maintenance-automation release.
Docs + website¶
- New non-technical macOS guide (
docs/install-mac-nontechnical.md) — front-to-back walkthrough covering Homebrew, Node 20+, the/usr/local/binsymlink that lets Claude Desktop and Jan seenode(GUI apps inherit a minimal PATH that excludes/opt/homebrew/bin), Full Disk Access setup, and the first-boot model-download wait. - Four new troubleshooting sections: GUI-app
ENOENTonnode/npx, macOS Full Disk Access silent failure (vault reads empty / HF model download hangs), stale~/.npm/_npxcache loading an old version, and corrupt transformers.js model cache. - Jan config shape corrected:
docs/jan.mdanddocs/install-clients.mdnow document the unwrapped{ "obsidian-brain": {...} }top-level shape Jan uses — different from Claude Desktop'smcpServers-wrapped shape. - Website simplification: dropped the custom
home.htmlhero + animated SVG, all four custom stylesheets (theme.css,hero.css,features.css,overrides.css), the IBM Plex Sans + Fraunces + JetBrains Mono font stack, and the vellum/violet/berry palette. Now runs on stock Material (primaryblue, white background in light mode,slatescheme in dark) with zero custom CSS. - Proper landing page (
docs/index.md): plain markdown, install-in-60-seconds code snippet, 2×3 feature grid (Find / Map / Write / Private / Fast / No plugin), "Why not Local REST API?" differentiation section. Left nav + right TOC hidden on the landing viahide: [navigation, toc]frontmatter. - MkDocs strict-mode hardening:
validation.links.anchors: warnpromotes previously-silent INFO-level link warnings to WARN, somkdocs build --strictnow fails on broken internal anchors. Fixed 3 pre-existing broken anchor links inarchitecture.md+troubleshooting.mdthat had been shipping since v1.5.x. - GitHub issue templates: structured bug-report form capturing client, OS, Node version/path, log excerpt, config, and the three sanity-checks that catch most reported issues (
@latestin config, cleared npx cache, Full Disk Access); lean feature-request form;config.ymldisables blank issues and links to troubleshooting / install-clients / mac walkthrough. - README tweaks: signpost to the mac walkthrough below the first-boot note, and a fourth troubleshooting bullet for the stale-npx-cache symptom.
Release + maintenance automation¶
RELEASING.md(repo root, 364 lines) — end-to-end release reference coveringnpm version patch|minor|majorinternals, the one-commandnpm run promoteflow, what fires after the tag (OIDC npm + MCP Registry + GitHub Release), plugin same-major.minor rule, HF cache key bump, env-var hand-edit notes, rollback steps, pre-release checklist.npm run promote(scripts/promote.mjs) — one-command dev→main + version + tag + push. Guards: branch isdev, tree clean,main..devnon-empty, FF-only merges both ways. Auto-returns todevand FF-mergesmainback sodev'spackage.jsonstays current. Accepts optionalpatch|minor|majorarg..github/workflows/ci.yml— validation-only CI on every PR and every push tomain/dev. Runsnpm ci,npm run build,npm test(454 vitest tests),npm run smoke(17 MCP tools),npm run docs:build --strict, generator drift checks, plugin version check, codespell. Never publishes — publishing remains tag-only viarelease.yml..github/pull_request_template.md— checklist: CHANGELOG entry, server.json env-vars sync,.describe()updates, plugin version impact, local smoke + docs checks, HF cache-key bump..github/dependabot.yml— weekly grouped updates for npm, pip (website toolchain), and github-actions.release.ymlheader spells out the three separate guarantees that prevent dev from publishing: trigger filter (tags: ["v*"]only), tag origin (only promote creates v* tags on main), main-branch guard step (refuses tags not reachable fromorigin/main).
Generated docs — single source of truth¶
docs/configuration.mdenv-var table now auto-generated fromserver.json.packages[0].environmentVariables[]. Between<!-- GENERATED:env-vars -->markers.npm run gen-docsregenerates;-- --checkfor CI drift detection. Legacy aliases section and per-var narrative (forEMBEDDING_MODEL/EMBEDDING_PRESET/EMBEDDING_PROVIDER) preserved outside markers.docs/tools.mdper-tool argument tables now auto-generated from Zod schemas vianpm run gen-tools-docs(runs undertsx). 17 per-tool<!-- GENERATED:tool:* -->slots; narrative (descriptions, examples, "Since vX.Y" notes, Claude prompt hints, capability matrix) preserved byte-for-byte outside slots.edit_noteslot is markedmanual— its 15+ mode-dependent fields don't fit a flat table.- 14
src/tools/*.tsfiles got.describe()annotations on every Zod field that lacked them. Argument descriptions now live in the schema (source of truth) rather than duplicated in markdown. Runtime behavior unchanged —.describe()attaches metadata only. preversionhook extended — runsgen-docs,gen-tools-docs,check-pluginand stages the regenerated docs, sonpm version Xcan't tag a release whose docs are out of sync with the schemas they describe.
Roadmap — low-friction idea capture¶
docs/roadmap.mdrestructured (97 → 65 lines): four sections — Recently shipped (`- v1.7.24 (2026-05-16) — embeddings.md BYOM callout + 5 devDep bumps- v1.7.23 (2026-05-16) — BYOM Ollama auto-pull gate + logger sweep + SIGTERM unit test
- v1.7.22 (2026-05-15) — structured stderr (NDJSON) + Ollama preparing-state + dependabot security bumps + SIGTERM drain integration test
- v1.7.21 (2026-04-27) — install.sh vault-picker fix + auto
ollama pull+ docs/test polish - v1.7.20 (2026-04-27) — Ollama prefix-lookup bug + 13 audit polish items
macro, auto-pulls from CHANGELOG at build time), Planned / In progress (hand-curated), Ideas (` markers for append-only firehose), Versioning policy. npm run idea -- "cross-vault search"(scripts/idea.mjs) appends a dated bullet between the Ideas markers. Zero friction.website/main.py: new@env.macro recent_releases(n=5)function parses CHANGELOG headers and returns a markdown bullet list. Surfaces every tagged release on the roadmap without manual maintenance.
Plugin version-matching¶
npm run check-plugin(scripts/check-plugin-version.mjs) — reads./package.jsonversion and../obsidian-brain-plugin/manifest.jsonversion, compares major.minor only. Exits 1 on mismatch, 0 with a warning if the sibling plugin dir isn't checked out (CI case), skipped ifSKIP_PLUGIN_CHECK=1.
Dev-loop ergonomics¶
npm run docs— start the local MkDocs server on 127.0.0.1:8000 with hot reload.npm run docs:build— same strict build as CI, locally..gitignore— ignore__pycache__/+*.pyc(needed now that the website build imports a local Python module).
v1.6.5 — 2026-04-23 — Heading/anchor stub lifecycle (schema v4)¶
[[Target#Section]]and[[Target^block]]now migrate the same way bare[[Target]]forward-references do. Previously they became_stub/Target#Section.mdstubs thatresolveForwardStubsexplicitly skipped — so even afterTarget.mdexisted, the graph kept a dangling heading-anchor stub indefinitely.- Schema bump 3 → 4: new
edges.target_fragment TEXTcolumn holds the#headingor^blocksuffix, whiletarget_idstays bare. IdempotentALTER TABLEmigration runs on bootstrap; upgraders get a one-time reindex to clean up pre-v1.6.5 fragment-embedded stubs. - Rename flows preserve fragments through
renameNode:target_idupdates,target_fragmentrides alongside.
v1.6.4 — 2026-04-23 — Path-qualified wiki-link rewriting¶
move_notenow rewrites path-qualified wiki-links like[[notes/BMW]]and[[notes/BMW.md]]alongside bare[[BMW]]. A cross-folder rename (e.g.notes/BMW.md→cars/BMW & Audi.md) now correctly updates all three reference shapes: bare stays bare, path-qualified gains the new full path, and.mdsuffix is normalised.- The same-stem early-out is removed from
rewriteInboundLinks/previewInboundRewrites— a pure cross-folder move with an unchanged basename still rewrites any path-qualified inbound references. Bare-stem references with an unchanged stem are left alone (they still resolve via Obsidian's stem lookup post-move).
v1.6.3 — 2026-04-23 — renameNode primitive, inbound edges survive rename¶
- New
src/store/rename.ts— one transactional helper (renameNode) that rewrites every row keyed on a node id in place: nodes, edges in/out, chunks (composite${nodeId}::${chunkIndex}ids + node_id), sync path, community membership JSON. UsesPRAGMA defer_foreign_keys = ONso chunks-to-nodes FK is checked at commit rather than mid-transaction. move_noterewired to use it: disk move → rewrite inbound source files →renameNode(DB atomic) → absorb any residual forward-reference stub viamigrateStubToReal. Inbound edges now survive the rename intact; graph analytics membership and chunk embeddings are preserved (no re-embed on rename).- Removes the delete-then-upsert pathway that previously dropped every inbound edge in
pipeline.index()'s deletion-detection loop — the root mechanism behind the v1.6.2 ghost-link symptoms.
v1.6.2 — 2026-04-23 — move_note ghost-link fix¶
move_notenow rewrites inbound wiki-links correctly when a source's edge targets a stub path (_stub/<oldStem>.md). Pre-v1.5.8 vaults and any note created via the watcher path before the target was indexed could carry stub-target edges indefinitely; the rewrite step silently skipped them, leaving ghost[[oldName]]links on disk and dangling graph edges.rewriteInboundLinksnow merges both real-target and stub-target inbound edges.indexSingleNote(the watcher's per-file reindex path) now migrates forward-reference stubs the same waycreate_notedoes. A note added via Obsidian for a previously-forward-referenced stem will now repoint stub inbound edges to the new real node on the spot, instead of leaving them for a full vault reindex to clean up.- After
rewriteInboundLinkswrites new content to source files, their sync mtime is zeroed so the subsequent reindex reparse cannot be suppressed by theprevMtime >= mtimeskip-check on filesystems with 1-second mtime resolution.
v1.6.1 — 2026-04-23 — Multilingual preset tightening¶
EMBEDDING_PRESET=multilingual— framing flipped: transformers.js multilingual now positioned as the one-env-var config-only path. Works end-to-end (verified: 384-dim output, cross-lingual EN↔JA cosine 0.76).- Corrected
presets.tssize metadata: combined download is ~135 MB (118 MB ONNX + 17 MB tokenizer.json), not 118 MB. docs/embeddings.mdmultilingual section rewritten — Ollama-for-multilingual demoted to "Advanced" alternative.- Auto-GitHub-Release step added to
release.yml— every tag now auto-creates its Release page with notes from this changelog, marked--latest. (Back-filled v1.5.8 + v1.6.0 manually before this shipped.) - Docs + README + website reorg: single-source-of-truth per fact. README 773 → 121 lines; new
docs/configuration.md,docs/embeddings.md,docs/migration-aaronsb.md,docs/development.md,docs/CHANGELOG.md. MkDocs nav reshuffled.
v1.6.0 — 2026-04-22 — Agentic-writes safety bundle¶
Paired plugin: v1.6.0. One new MCP tool; tool count 16 → 17.
dryRun: trueonedit_note,move_note,delete_note,link_notes— returns a preview without writing.- New tool
apply_edit_preview(previewId)— commits a preview returned byedit_note({dryRun: true}). File-drift guarded; 5-minute TTL. - Bulk
edits: [...]onedit_note— atomic chain; error names the failing index, nothing lands on disk. fuzzyThreshold: 0–1onreplace_window(default 0.7).from_buffer: trueonedit_note— retries a priorreplace_windowNoMatch withfuzzy: true, fuzzyThreshold: 0.5.- New runtime dep:
diff@^8for unified-diff generation.
v1.5.8 — 2026-04-22 — Stub-lifecycle + FTS5 + hybrid-chunks¶
Paired plugin: v1.5.5 (patch drift acceptable).
- Stub-lifecycle fixes:
move_noteanddelete_noteno longer orphan stubs; forward-references ([[X]]beforeX.mdexists) auto-upgrade when the real note is created. - FTS5 crash on hyphenated queries fixed (e.g.
foo-bar-baz) — conditional phrase-quoting insrc/store/fts5-escape.ts. search({mode: 'hybrid', unique: 'chunks'})now returns chunk metadata (was semantic-only).reindex({})response includesstubsPruned: N— migration path for upgrading users with orphan stubs in their DB.
v1.5.7 — 2026-04-22¶
- Advertised version now reads from
package.jsonat runtime viacreateRequire. No more drift between tag andserver.versionininitialize.
v1.5.2 — 2026-04-22 — Embedding presets¶
Paired plugin: v1.5.2.
- New
EMBEDDING_PRESETenv var:english/fastest/balanced/multilingual. - Default model flipped to
Xenova/bge-small-en-v1.5(wasall-MiniLM-L6-v2). Auto-reindex on first boot. - README restructured: honest ≤60 MB budget, multilingual via Ollama.
v1.5.1 — 2026-04-22¶
- BGE/E5 asymmetric-model prefix fix — query-side prefix is now applied (was silently dropped).
- Stratified migration via
prefix_strategy_versionmetadata; BGE/E5 users get a targeted reindex on upgrade.
v1.5.0 — 2026-04-22 — Agent UX + Ollama¶
Paired plugin: v1.5.0.
- Ollama embedding provider (
EMBEDDING_PROVIDER=ollama). next_actionsresponse envelope onsearch/read_note/find_connections/delete_note:{data, context: {next_actions}}. Clients ignoringcontextkeep working.move_noterewrites all inbound wiki-links across the vault (linksRewritten: {files, occurrences}).edit_note({mode: 'patch_heading'})throwsMultipleMatchesErrorwith per-occurrence line numbers when a heading is ambiguous;headingIndex: Ndisambiguates.read_note({mode: 'full'})returnstruncated: truewhen the body exceedsmaxContentLength.includeStubs: falseondetect_themes+rank_notes.- Graph analytics credibility guards:
rank_notes(pagerank)defaultsminIncomingLinks: 2; low-modularity Louvain clustering surfaces awarning; betweenness normalised 0–1.
v1.4.0 — 2026-04-22 — Retrieval foundation + Bases¶
Paired plugin: v1.4.0.
- Chunk-level embeddings: each note is split at markdown headings (H1–H4), oversized sections further split on paragraph / sentence boundaries; code fences and
$$…$$LaTeX blocks preserved. SHA-256 content-hash dedup means unchanged chunks don't re-embed. - Hybrid RRF search is the default:
search({query})fuses chunk-level semantic + FTS5 full-text ranks via Reciprocal Rank Fusion. - Pluggable
Embedderinterface;EMBEDDING_MODELenv var with auto-reindex on change. - Obsidian Bases integration via companion plugin + new
base_querytool (Path B — own YAML + expression evaluator). - FTS5 polish: porter stemming + column-weighted BM25 (5× title vs body).
v1.3.0 — v1.3.1 — Dataview¶
Paired plugin: v0.2.0 → v0.2.1.
dataview_queryMCP tool via companion plugin. Returns discriminated union:table/list/task/calendar.- 30s default timeout (Dataview has no cancellation API).
v1.2.0 — v1.2.2 — Companion plugin foundations¶
Paired plugin: v0.1.0.
active_notetool (first plugin-dependent tool).- Defensive hardening: per-tool timeout, SQLite WAL
busy_timeout = 5000, embedder request serialisation. - Theme-cache correctness;
patch_headingscope: 'body';valueJsonfor stringifying harnesses.
v1.0.0 — v1.1.x — Foundations¶
- Core semantic search + knowledge graph + vault editing over stdio MCP (v1.0.0).
- Live file watcher (chokidar) + offline-catchup on boot (v1.1.x).