Skip to content

v0.15.0 - The Port

Latest

Choose a tag to compare

@eugeniughelbur eugeniughelbur released this 04 Sep 15:56
4632e7e

The Windows release, plus everything that landed on main since v0.14.0. Headline: the plugin now works on Windows (Git Bash / MSYS2 / Cygwin) the way it does on macOS and Linux, by @motkoning in #243 and #248. Also in this release: Gemini-first /podcast with a free Groq Whisper fallback, write-time tag syntax checks, the opt-in tag taxonomy audit, Simplified Chinese triggers, /obsidian-reindex, the AI-First Lint Obsidian plugin, platform ownership, AI-FIRST.md, the reproducible retrieval benchmark, the generated docs site, typed edges, the Telegram bot sender allowlist, and the callout form of the AI-first preamble.

Full test suite: 762 passed on macOS; 751 passed on Windows 11 per the contributor's run of the merged tree.

Added

  • /podcast summarizes Gemini-first with Grok fallback, and Apple ?i= episode links resolve to the right episode (#233, by @konsone in #235). Summarization mirrors /youtube: GEMINI_API_KEY set means Gemini (free tier, 1M context) with a transparent fall back to Grok on any failure; no key means the old Grok-only behavior. This is what makes the 480k transcript cap from #234 free on the default path. Apple's ?i=<trackId> never appears in RSS guids, so /podcast used to fall back silently to the most recent episode; a second iTunes lookup (entity=podcastEpisode) now resolves the id to its title and _pick_entry matches on that. Covered by tests/test_podcast_resolution.py.

  • Tag syntax is checked at write time and by /obsidian-health (#221, raised by @konsone). Obsidian renders a tag it cannot parse struck through, with no error in the UI, the CLI, or the file, so an agent that wrote tags: [33] or [2.0] never learned it had. hooks/validate-ai-first.sh check 7 and vault_health.py's new check_tag_syntax (issue type invalid_tag, warning) apply Obsidian's rule: letters in any script, digits, _, - and / for nesting, no spaces or dots, and at least one character that is not a digit. Each finding names the tag and the fix (store-33, v2-0). The hook reads inline [a, b], scalar, and - item block forms; vault_health.py reads inline and block through the same parse_tags() the taxonomy audit (#230) uses, so the two tag checks share one parser. The canonical-taxonomy half of the issue (_meta/taxonomy.md, --consolidate) stays open as an opt-in for a later PR.

  • /podcast gained a free Groq-hosted Whisper transcription fallback (#233). The transcript chain was rss-transcript-tag -> OPENAI_API_KEY Whisper ($0.006/min) -> show-notes, so a podcast without publisher transcripts either cost money or collapsed to show notes, and without OPENAI_API_KEY at all there was no audio path. scripts/research/lib/groq.py inserts the free Groq tier (GROQ_API_KEY, whisper-large-v3-turbo) as the middle step, provenance groq-whisper-api. Design is downsample-first: one re-encode to 32kbps mono (Whisper resamples to 16kHz mono server-side anyway) puts a ~4.5h episode inside Groq's 25MB request cap in a single call; only longer episodes are split into -ss/-t chunks from the already-re-encoded file - bitrate exactly known, 10s overlap between chunks, sentence-boundary dedup at the seams, last chunk keeps its tail. Metadata is stripped on re-encode after a test episode carried an 18MB XMP blob in its chapters. Failure is episode-level all-or-nothing: any chunk 429 (free-tier ASH limit), oversize, or empty result returns None and the chain falls through to the OpenAI step unchanged - providers are never mixed inside one transcript. commands/podcast.md and references/ai-first-rules.md document the new step and the groq-whisper-api transcript-source value. Covered by 27 offline tests in tests/test_groq.py: a real local HTTP server exercises 429/200/empty/unconfigured paths over an actual socket (asserting the return value, the request count and the Authorization header), real ffmpeg/ffprobe drive the chunk-split math on a synthetic fixture, and the fallback-chain tests assert exact call counts proving a step is never invoked when it should not be.

  • /obsidian-merge: a command to merge the near-duplicate pairs /obsidian-health finds and stops at (#220, requested by @konsone). Health is read-only by contract, so the merge itself was always a manual, skipped step - the same pairs kept showing up run after run. scripts/merge_notes.py does the mechanical half: union the two notes' frontmatter (canonical's value wins a conflict, the loser is recorded under a new merged_from: block), fold the retired note's title into the canonical note's aliases:, and replace the retired note with a short type: redirect stub (schema added to references/ai-first-rules.md § Documented exceptions) rather than deleting it, so old wikilinks keep resolving. Default is dry run - it previews the exact frontmatter diff and both proposed notes; nothing is written until --apply, and dry-run and apply share one compute_merge() so the preview can never drift from what gets written. --from-health resolves a pair from vault_health.check_duplicates() run live (this repo has no persisted health-report file); a duplicate group of more than 2 files is never auto-paired - it is reported and needs explicit --canonical/--retire, since nothing in the health check says which note should survive. The merged BODY is deliberately not composed by the script - commands/obsidian-merge.md composes it (one ## For future agent preamble, both notes' provenance trails kept, contradictions between them listed rather than silently resolved, per references/ai-first-rules.md) and hands it to the script via --merged-body-file. Covered by tests/test_merge_notes.py: dry run writes nothing, --apply writes the redirect stub and folds the alias, frontmatter conflicts resolve canonical-wins, no conflict means no merged_from noise, --from-health resolves a real pair (and refuses a 3-file group), and a missing --merged-body-file errors before anything is written. Follow-ups landed on merge: list-valued fields (tags, aliases, related-*) present on both sides are unioned instead of treated as a conflict, so a merge never drops tags only the retired note carried; and when both notes share a filename stem (the common duplicate, Ideas/X.md vs Archive/X.md) the redirect links to the path-qualified [[Ideas/X]], because a bare [[X]] is ambiguous and can resolve to the redirect note itself.

  • Behavior eval: does the vault make the answer better, not just the ranking (#197, built by @AaronProbha18 in #223). scripts/eval/behavior_eval.py runs each question from a new behavior case set twice - once with vault retrieval as context, once from the model alone - and has a different model grade both blind against the case's known answer_key, then reports the overall delta, a per-category breakdown, and every case where the vault made the answer worse, never truncated. The case set lives in corpus.py (60 cases: fact 20% / decision 15% / relationship 10% / synthesis 30% / contradiction 25%; synthesis questions need two notes combined, contradiction questions need the reconciling note over a superseded daily-log line). Answers come from research.lib.grok, judging from the new research.lib.gpt (OPENAI_API_KEY, default gpt-4o-mini via GPT_JUDGE_MODEL), and a startup guard refuses to run if the two ever resolve to the same model. Opt-in and keyed; CI tests the plumbing with every LLM call mocked and never the scores. Follow-ups landed on merge: a question where search returns nothing is now answered with no notes and scored like any other case (it was dropped as "unjudged", which hid the vault's worst failures from the regression bucket), both arms are told not to mention notes or sources so the judge cannot tell the conditions apart from the text, and each case carries retrieval_empty. The corpus gained one contradiction line per daily note, so the published corpus hash in scripts/eval/BENCHMARK.md moved to 5773dc7f39b0c3b0; lexical retrieval results on all three sets were re-run and are unchanged, hybrid was not re-run.

Changed

  • The bash scripts share one home-resolution helper, and the Windows tests moved out of tests/test_smoke.py (review follow-ups to #243/#248). The USERPROFILE-on-Windows block from #242 had been pasted into seven shell scripts. It now lives in scripts/platform-home.sh (osb_platform_home sets OSB_WIN and OSB_HOME), sourced by install.sh, update.sh, scripts/setup.sh, scripts/run-command.sh and the Telegram setup.sh. Two inline copies stay on purpose, because those files must run standalone: hooks/validate-ai-first.sh (copied by hand into other harnesses' hook systems) and scripts/quick-install.sh (curl | bash, before any checkout exists). tests/test_platform_home.py fails when either drifts from the helper, the same fence the tokenizer copies (#159/#188/#192) showed this repo needs. The six Windows-compat tests from #243 moved verbatim to tests/test_windows_compat.py. No behavior change.

  • The AI-first preamble may be written as an Obsidian callout (#237, raised by @molochplaisir). Rule 2 named one spelling, the ## For future agent heading, and hooks/validate-ai-first.sh check 4 and the MCP validate_note matched that heading only, so a vault whose ingest pipeline writes the preamble as a folded callout (> [!info]- For future agent, so a human sees the note content first) got a false "missing preamble" warning on every write. The callout carries the same title and the same 2-3 sentence summary, is plain text, and needs no plugin, so it is now an accepted equivalent: references/ai-first-rules.md rule 2 says so, and the hook, validate_note, and vault_health's duplicate-similarity stripper recognize both spellings (any callout type, folded or not; the legacy labels AI, Claude, Codex included). The heading stays the default every command writes. A bold line or a paragraph without the title is still not a preamble. Covered by tests/test_smoke.py::test_validate_hook_accepts_the_callout_preamble and ::test_mcp_validate_note_accepts_the_callout_preamble.

  • The transcript caps from #234 are documented, validated, and the note says who summarized it. YOUTUBE_TX_LIMIT, PODCAST_TX_LIMIT and GROQ_API_KEY are in .env.example; a cap that is not a whole number (480k, 1e6) exits naming the variable via the new config.get_optional_int() instead of an int() traceback; and the ## For future agent preamble of /youtube and /podcast notes names the model that actually wrote the summary (Gemini or Grok) instead of always saying Grok. commands/podcast.md now states the free-tier limit the Groq step runs under (7,200 audio-seconds per hour, so about 2h per episode) rather than only the 25MB byte fit.

  • /obsidian-ingest checks for a previous ingest before writing a raw note (#218, raised by @konsone). The raw-source schema already carried source_url and content_hash, but nothing read them back, so ingesting the same article twice produced two raw notes and a second round of rewrites. Step 5 now defines content_hash (first 16 hex of SHA-256 over the verbatim text) and searches raw/ for it and for the normalized source_url first: same hash means re-read the existing raw note instead of writing a second; same URL with a new hash means the source changed, so the new raw note carries supersedes: and the old one goes to the Contradictions agent. The archive and rebuild half of the issue was declined on the issue thread.

  • Batch ingests get a documented unit-of-work boundary instead of a staging mode (#222, raised by @konsone). references/write-rules.md gains a "Batch writes" section with the git-branch recipe and the LiveSync snapshot equivalent, and /obsidian-ingest points to it. A _staging/ redirect inside the command was declined because the agent's own Write and Edit tools would not honor it, leaving half a batch live while the user believed it was staged.

  • The vault preamble vocabulary is now ## For future agent, replacing ## For future Claude (discussion #182, raised by @konsone). The framework has shipped cross-platform since v0.10 (Codex CLI, Gemini CLI, OpenCode, Hermes, Pi, agent-skills), and naming one vendor's agent in every note's preamble was historical, not deliberate. All 46 commands, references/ai-first-rules.md, SKILL.md, the adapters, and the write-time validator now use future agent. No vault migration needed: validate-ai-first.sh check 4 accepts ## For future agent, ## For future AI, ## For future Claude, and ## For future Codex, so existing notes stay valid; new writes use the neutral form.

  • Improve spanish commands triggers Native speaker audit of all 46 command triggers. Replaced literal translations with natural phrases people actually say, e.g., "ponme al tanto de todo" instead of "carga mi mundo". Improves trigger recognition for Spanish users without breaking routing precedence. (19 commands refined)

  • MCP tool names carried the plugin name twice (#177, reported by @mpuglin). The mcpServers key in .claude-plugin/plugin.json was also obsidian-second-brain, and Claude Code composes tool names as mcp__plugin_<plugin>_<server>__<tool> - so every tool arrived as mcp__plugin_obsidian-second-brain_obsidian-second-brain__obsidian_search, 57 characters of prefix of which 21 were the name repeated for nothing. In a dropdown or a permission prompt the prefix crowds out the part that identifies the tool. The server key is now vault, giving mcp__plugin_obsidian-second-brain_vault__obsidian_search. The plugin name, install identity, marketplace entry and slash-command namespace are all unchanged; the reported request was to rename the plugin itself to o2b, which was declined because the name is the install identity of the repository and the commands are already obsidian-*, so o2b:obsidian-daily would save little. Upgrade note: if you allowlisted these tools by their full name in settings.json, update the server segment from obsidian-second-brain to vault.

Security

  • The Telegram journal bot accepted messages from any sender (discussion #215, raised by @robertcamero). telegram_journal.py polled getUpdates and processed every message it got, with no check on who sent it. A bot's username is discoverable, so anyone who found it could append to the daily note, create entity stubs, spend the OpenAI/Anthropic keys, and - the part that matters most for an AI-first vault - plant text a later agent reads as trusted memory. There is now a required TELEGRAM_ALLOWED_CHAT_IDS (comma-separated chat ids) and the gate fails closed: an unlisted sender is refused and logged, never processed. With the variable unset the bot processes nothing; the one thing it does is reply with the sender's chat id so the owner can complete setup, and once a list exists strangers get silence. setup.sh prompts for the id, the env template and README document it. Upgrade note: existing installs stop saving until TELEGRAM_ALLOWED_CHAT_IDS is set; message your bot once and it tells you the id to add. Covered by tests/test_telegram_ingest.py (empty list refuses, listed id passes, unlisted id refused, int and string ids compare equal).

  • Fill-links could write outside the vault (discussion #215, raised by @robertcamero). create_stub() built the new note's path as folder / f"{name}.md" where name is whatever sat inside a [[wikilink]] the model wrote, so a link like [[../../somewhere/else]] resolved to a write outside the vault. Names now pass through safe_note_path(): path separators, .., dot-names and NUL are refused, and the resolved parent must be exactly the target folder. Refusals log to stderr and skip the stub; the capture itself still saves. Media and PDF filenames were already date-prefixed and character-stripped and are unchanged. Covered by tests/test_telegram_ingest.py (traversal, absolute path, dot-names refused; plain and Unicode names accepted).

  • Lockfile carried two dependencies with published advisories (discussion #215). cryptography 47.0.0 -> 50.0.1 (GHSA-537c-gmf6-5ccf) and urllib3 2.6.3 -> 2.7.0 (GHSA-mf9v-mfxr-j63j, streaming decompression bypass - relevant because the research toolkit fetches arbitrary external URLs and feeds). Both are transitive via google-api-python-client; uv.lock only, no pyproject.toml change.

Fixed

  • /obsidian-ingest rewrote existing notes without confirmation, and content_hash keyed on the raw capture (#239, raised by @konsorsiumai). Two spec gaps. references/ai-first-rules.md has said since #215 that a write which modifies an existing note on the strength of an external source is a proposal the user confirms, but the rule never reached commands/obsidian-ingest.md: step 6 mandated rewriting existing pages and the same-hash re-read from #218 routed straight into it, so a re-ingest could rewrite a daily note, Home.md, entity and idea notes and log.md with no question asked. Step 6 now carries the rule in full (new pages proceed; rewrites of existing notes are collected as drafted proposals and confirmed once as a batch, re-reads included), step 7 runs only for pages actually written, and the report lists the proposals with their outcome. Separately, content_hash was defined over "the verbatim source text", and a JS-rendered page yields different bytes on every fetch (JS shell vs rendered DOM, navigation chrome, a + where the page had -), so an unchanged source hashed as "changed" and the branch table had no row for it. The hash is now computed over a canonical form (article body only, LF, list markers normalized, whitespace collapsed) while the raw note body stays verbatim, and the same-URL-different-hash branch diffs the canonical texts first: capture noise is a re-read, only a real change writes a supersedes: raw note. Both are prompt-level contracts; tests/test_untrusted_source_handling.py::test_ingest_treats_rewrites_of_existing_notes_as_proposals pins the wording so it cannot drift out again.

  • validate-ai-first.sh exited 0, silently, when a write payload named a tool but carried no path key it knew (#171, the open item from @tonydzi's codex-cli reports). The hook read file_path and filePath (and args.*); a host that sends the path under another key (path, uri) got exit 0, indistinguishable from "not a vault file", and the write went unchecked with no trace. The hook now reads notebook_path/notebookPath as well (the NotebookEdit shape its own matcher names; an .ipynb then drops at the .md gate as before), and when a payload names a tool_name but no known path key it prints one stderr line naming the tool and the payload keys and exits 1: non-blocking, the write stands, but the miss is visible. Input with no tool_name stays silent. Covered by tests/test_smoke.py::test_validate_hook_is_loud_when_the_payload_has_no_known_path_key.

  • Two tests from #243/#248 could not fail on the CI runner (found in review). tests/test_research_cjk_and_models.py's cp1252_default fixture substituted cp1252 only when Path.open() received encoding=None, but read_text()/write_text() resolve None to the string "locale" before calling open() (Python 3.10+), so the fixture intercepted only the direct open("a") in append_to_log and the scan test passed against the pre-fix code; the guard now matches "locale" too, and the pre-fix call shape fails under it on every platform. tests/test_windows_compat.py::test_retrieval_eval_external_cmd_splitting built its Windows cases only when the runner's own os.name was "nt", so the non-POSIX branch of _split_external_cmd had zero CI executions; the test is now parametrized over both branches and sets os.name in the subprocess, which is the only thing the function keys on.

  • On Windows, the bash and Python halves could resolve the optional ~/.config/obsidian-second-brain/.env to two different folders, so a user who followed the README configured one half only. Python's Path.home() reads USERPROFILE and ignores HOME (Python 3.8+), while the bash scripts (install.sh, scripts/setup.sh, update.sh, scripts/quick-install.sh, scripts/run-command.sh, the Telegram setup, and the write-time hook) read $HOME. Git Bash normally sets HOME to USERPROFILE, but a machine whose Windows environment defines HOME (a corporate roaming home on another drive) split the config: the installer and the hook used one drive, the research toolkit, the eval, and the MCP server another, and the classic installer also wrote its hook and commands under a ~/.claude that Claude Code (which keys on USERPROFILE too) never reads. On Windows shells the bash scripts now resolve the home as USERPROFILE (via cygpath), matching Python and Claude Code; on macOS and Linux HOME is still the home and nothing changes. The research loaders and the retrieval eval also honor OBSIDIAN_ENV_FILE now, the override the MCP server and the hook already accepted, and install.sh and scripts/setup.sh write the file there when it is set, so one variable steers every half. On Windows the value must be a native path (C:/... or with backslashes, both of which bash and Python open), because the Git Bash spellings /c/... and /cygdrive/c/... are meaningful to bash only; the installers also print the resolved .claude paths instead of ~/..., which bash expands from HOME. README documents both, with the USERPROFILE-based commands for a corporate-HOME machine and the caveat that a Cygwin- or MSYS-built Python follows HOME. Covered by tests/test_windows_compat.py::test_validate_hook_env_fallback_uses_the_platform_home (branches per platform) and ::test_research_config_honors_env_file_override.

  • scripts/link_graph.py crashed on Windows for any vault whose titles carry a character outside cp1252, and seven other tests failed there on POSIX assumptions. The script printed JSON with ensure_ascii=False to a pipe that Windows encodes as cp1252, so a decomposed title (u + U+0308, the macOS filename form its own test exists for) raised UnicodeEncodeError; stdout is now forced to UTF-8 the way bootstrap_vault.py already does, and retrieval_eval.py's JSON report gets the same guard. retrieval_eval.py --mode external also lost the backslashes of a Windows engine path to POSIX-mode shlex.split; on Windows the command is now split in non-POSIX mode with one layer of surrounding quotes removed, so quoted paths with spaces and quoted literal arguments survive too, and on every platform a JSON array is accepted as the exact form for anything shell quoting cannot express (embedded quotes, empty arguments); covered by tests/test_windows_compat.py::test_retrieval_eval_external_cmd_splitting. The remaining failures were the tests' own POSIX assumptions, and the fixes change nothing on macOS or Linux: two file-mode checks skip only the mode-bit assertion on Windows, which keeps no POSIX owner/group distinction so chmod 600 cannot be verified through st_mode there (their other assertions still run), three home-redirection tests also set USERPROFILE (what Path.home() reads on Windows; HOME is ignored there), the background-agent test compares the vault path in the form Git Bash reports, and both link-graph tests decode UTF-8 explicitly. On Windows the suite had 8 failures (583 passed) at the base this work started from and none attributable to this change at the current base; six failures in newer code that this change does not touch remain and are listed in the pull request as pre-existing.

  • validate-ai-first.sh was a silent no-op on Windows. Claude Code passes the written file's path with backslashes (C:\Users\...) while OBSIDIAN_VAULT_PATH is written with forward slashes, so the vault-scope prefix match never hit and the hook exited 0 before reading the note. Found on a fresh Windows plugin install where two deliberately bad writes (no frontmatter, via Write and via Edit) produced no warning, while the same script run by hand with a forward-slash path warned. On Windows shells both paths are now normalized before the comparison: /c/... and /cygdrive/c/... are mapped to the drive form first (each runtime misreads the other's spelling), cygpath -m then gives the mixed form, a trailing separator is stripped, and the comparison is case-insensitive because the filesystem is. On macOS and Linux the paths are compared exactly as given, so a legal backslash in a filename stays intact. The .env fallback drops a trailing carriage return (a file written on Windows and read by macOS or Linux bash kept it in the vault path and never matched) and accepts a native backslash path in OBSIDIAN_ENV_FILE; a note saved with CRLF line endings is validated from a carriage-return-free copy instead of failing every delimiter check. Bash 3.2 features only, so the macOS system bash keeps running the hook unchanged. Covered by tests/test_windows_compat.py::test_validate_hook_matches_windows_backslash_paths (runs on Windows, skipped elsewhere).

  • merge_notes.py carried the retired note's date and status into the canonical note, and reported no conflicts, when the canonical note started with a UTF-8 BOM. note_io.read_exact returns the text byte-exact, BOM included (the rule from the BOM fix: files keep their BOM, readers stop being blind), but export_okf.parse_note anchored on --- at the very first character and so saw no frontmatter at all; with the canonical side empty, every field of the retired note joined the union unopposed and nothing counted as a conflict. export_okf.py itself was never affected, because it reads with utf-8-sig. parse_note now skips a leading BOM the way vault_scan.split_frontmatter already does, and the merge writes each rewritten note with the BOM it carried, so a byte an editor put there stays there. Found while fixing the CRLF case (the entry below); covered by tests/test_merge_notes.py::test_bom_notes_keep_their_frontmatter_through_a_merge, ::test_a_bom_on_the_retired_note_stays_on_its_redirect_stub and tests/test_frontmatter_parity.py::test_parse_note_skips_a_bom_like_the_canonical_parser.

  • cache.get() with a zero TTL could return the entry it was asked to expire, so tests/test_research_sources.py::test_cache_roundtrip was flaky on Windows. The check was age > 0, with the age computed as time.time() minus the file's mtime. Those two timestamps come from clocks of different resolution, so a read that follows a write closely can compute an age of exactly zero or even a negative one (on the Windows machine this surfaced on, time.time() advances in 15.6 ms steps and the test failed in 1 of 8 isolated runs), and no comparison against such an age can implement "expire at once". A TTL of zero or less is now decided before the clock is consulted, and every lookup is a miss, which is what the test and RESEARCH_CACHE_TTL_HOURS=0 already meant; positive TTLs are unchanged, and put() still writes, so the entry is there the moment a positive TTL is configured again. ::test_cache_zero_ttl_is_a_miss_without_consulting_the_clock makes the clock raise for a TTL of 0 and of -1 and freezes it at the entry's own mtime for the positive case, so it is deterministic on every platform and fails without the fix.

  • On Windows the research toolkit saved notes in the system's ANSI code page (cp1252 on a Western-European system): a /notebooklm synthesis or a research note (/research, /research-deep, /youtube, /podcast, /x-read, /x-pulse) carrying a character the page cannot encode, a CJK word for instance, failed with UnicodeEncodeError; one carrying only characters it can encode, an accented letter say, was written as code-page bytes that Obsidian reads as mojibake; and append_to_daily, which rewrites the whole daily note, left it empty on the first kind, because write_text() truncates before it encodes. notebooklm.py's save and lib/vault.py's write_note, append_to_log and append_to_daily named no encoding, which is UTF-8 on macOS and Linux and the code page on Windows. They now write (and append_to_daily reads) UTF-8, the encoding Obsidian expects; a daily note or a log.md that is not UTF-8 (one an earlier Windows run wrote in its code page) is left untouched and the append reports that it did nothing, rather than rewriting the note lossily or leaving the log in two encodings; the /notebooklm save moves into save_note() so it can be exercised without Gemini. Raised in review as the missing half of the read-side entry below; covered by tests/test_research_cjk_and_models.py::test_vault_writes_are_utf8_under_a_cp1252_default, which emulates the Windows default on every platform, ::test_append_to_daily_leaves_a_note_it_cannot_decode_alone and ::test_append_to_log_leaves_a_log_it_cannot_decode_alone.

  • The /research-deep and /notebooklm vault scans found nothing for a CJK topic on Windows, and every note excerpt /research-deep sent onward was mojibake for any non-ASCII character. vault_scan() in research_deep.py and notebooklm.py, and the two excerpt reads in research_deep.py, called read_text(errors="ignore") with no encoding, which is the platform default: UTF-8 on macOS and Linux, and on Windows the system's ANSI code page (cp1252 on a Western-European system), where a UTF-8 note's Japanese decodes to other characters and the CJK-aware tokenizer from #212 had nothing to match against. The reads now name encoding="utf-8", the encoding Obsidian writes, so Windows reads exactly what the other platforms already did. Found by tests/test_research_cjk_and_models.py::test_vault_scan_finds_cjk_topic on Windows; ::test_vault_scan_and_excerpts_read_utf8_under_a_cp1252_default emulates that default, so the case runs on the ubuntu CI runner too.

  • freshness_lint.py named files with backslashes on Windows (Boards\Work.md), so a finding's file field differed by platform. The field is the contract of the --json output and of the tests, and the vault's own link form is forward-slash, so the relative path is now rendered with as_posix(), the way vault_health already keys its notes (B40 in tests/test_frontmatter_parity.py); output on macOS and Linux is unchanged. Found by tests/test_freshness_lint.py::test_obsidian_comments_are_invisible_not_content on Windows; ::test_findings_name_files_with_forward_slashes pins the rendering with a PureWindowsPath, so the case runs on the ubuntu CI runner too.

  • merge_notes.py lost the frontmatter of any note saved with CRLF line endings: with both notes CRLF it rewrote the canonical note with all of its original frontmatter fields lost (only the alias the merge adds survived), with one it silently dropped that note's fields from the merge (the canonical note's own values replaced by the retired note's, or the retired note's fields never unioned) and reported no conflict. The script hands export_okf.parse_note the byte-exact text note_io returns, and the parser's fence pattern accepted spaces and tabs after --- but not a carriage return, so ---\r\n never matched and the note read as having no frontmatter at all. export_okf.py itself was not affected, because its own read is in text mode and normalizes the newlines first; vault_health and vault_stats read the byte-exact text correctly, because their patterns use a whitespace class that absorbs the \r, which is the S9 disagreement tests/test_frontmatter_parity.py exists to pin, on line endings this time. export_okf.FM_RE and its twin vault_scan.FRONTMATTER_RE now accept \r\n on both fences (as build_site.py's own pattern already did); PyYAML reads the carriage returns inside the block as line breaks, and LF notes match exactly as before. Found by tests/test_merge_notes.py on Windows, where write_text() saves the fixtures with CRLF and four of its cases failed; the parity test gains a CRLF fixture across all four patterns, and ::test_crlf_notes_keep_their_frontmatter_through_a_merge pins each side and both on every platform.

  • /notebooklm produced an empty filename for Cyrillic and any non-Latin topic (#227, reported and fixed by @konsone in #228). notebooklm.py kept a private slugify() that dropped everything outside [a-z0-9\s-], so a Russian topic became YYYY-MM-DD - .md and an empty Gemini File Search display_name. It now uses the shared lib.vault.slugify() (Unicode word characters, the one /youtube already used) plus the or "untitled" fallback for symbol-only topics. One visible change for ASCII topics: the shared slugify keeps spaces where the private copy wrote hyphens, so a note is now 2026-08-26 - smart home automation.md rather than 2026-08-26 - smart-home-automation.md, which is the form every other research command already writes.

  • The MCP server registered zero tools on older mcp 1.x releases (#229, reported and fixed by @mpuglin). server.py opened with from __future__ import annotations, which turns every annotation into a string; fastmcp in mcp 1.9.x calls issubclass(param.annotation, Context) while registering tools, so the first @mcp.tool() raised issubclass() arg 1 must be a class and the client listed no vault tools. The import is removed and a comment in its place explains why it must stay out. Scope note, corrected from the PR text: on a current index mcp<2 resolves to the latest 1.x (1.29.1 at merge time), where the server already worked, so this is a hardening for environments whose resolver lands on an older 1.x, not a fix for every install.

  • The MCP server adopted whatever project the user was working in, writing a uv.lock into their repository and syncing their dependencies into a .venv beside it. The manifest launched the server with uv run --with 'mcp<2' python ..., and Claude Code starts an MCP server with the user's working directory as cwd. uv run discovers a project from cwd upward, so in any repository carrying a pyproject.toml uv treated that repository as the project it had been asked to run in. Measured on Windows with uv 0.11.2 against a repo whose pyproject.toml carries only pytest configuration, with no [project] and no [tool.uv] table: that was still enough for uv to create a .venv on Python 3.14 (the repo targets 3.12) and write a 52-byte uv.lock at the root on every session start, and since uv.lock was untracked there it surfaced as repository noise the user had to explain. Against a repo declaring a real dependency the same launch installed that dependency into the user's .venv, so this mutated environments rather than only littering them. Nothing in the server needs a project: its only non-stdlib import is mcp, which --with already supplies, and vault_ops sits beside it. The launch now carries --no-project, which skips discovery entirely, everywhere the command appears: the plugin manifest, scripts/setup.sh, SKILL.md, README.md, and the integration's own README, server.py, live_test.py and vault_ops.py. Not a Windows problem: cwd-based discovery behaves identically on macOS and Linux, where a user's repository is if anything more likely to declare real dependencies. Covered by tests/test_plugin_manifest.py::test_mcp_launch_isolates_from_the_working_directory_project (the manifest must carry the flag) and ::test_no_documented_command_adopts_the_users_project (no copy anywhere in the tree may drop it again).

  • freshness_lint flagged quoted examples of the illegal form - a note explaining the freshness policy could not pass FRESH-1 (#204). A line that quotes "the pipeline has 13 deals" as teaching material is quotation, not a claim, but the linter had no way to know. Per the direction agreed on the issue: a line-scoped <!-- freshness: example --> directive now suppresses FRESH-1 for exactly the line it sits on - an HTML comment, so invisible in rendered markdown and greppable in source. Deliberately narrow: FRESH-2 and FRESH-3 on the same line still fire (the directive says "this claim is quotation", not "skip this line"), there is no block form (fences and blockquotes remain the whole-region exemptions), and the directive is recognized only where FRESH-1 could apply - backticked it is documentation, inside a code fence or %% comment it is invisible, in frontmatter or a FRESH-4 snapshot container it is inert, so none of those suppress and none warn. The lint also lints itself: a directive that suppressed nothing warns as FRESH-5 (unused suppression), as do redundant duplicates on one line, so defensive copies get removed instead of accumulating. Covered by tests/test_freshness_lint.py (suppresses only its own line, example line fails without the directive, FRESH-2 and FRESH-3 unmuted on the same line, unused directive warns, backticked directive stays documentation, directive hidden in a %% segment stays inert while the visible claim still fires, directive after a closing %% still counts, snapshot regions stay untouched, duplicates warn, frontmatter inert).

  • /notebooklm's vault_scan kept the pre-#159 tokenizer and returned zero notes for CJK topics (#212, reported by @hamidasiblog) - and the grep the reporter suggested found a fourth copy in research_deep.py. The whitespace split + len(w) > 2 filter survived #159, #188 and #192 because every fix landed in one path and not the others; #188 even edited the very function the copy lives in. Both vault_scans now tokenize via the new lib/vault_terms.topic_terms(), which delegates to vault_ops._query_terms - the same CJK-aware tokenizer search uses, lowercased, stopword-free, CJK runs as bigrams. A fence test (test_no_stale_tokenizer_copies_left_in_command_paths) fails if a split-plus-length-filter copy regrows anywhere under scripts/research/; the scripts/eval/ copies keep their own semantics deliberately (gold matching) and are out of its scope. Covered by tests/test_research_cjk_and_models.py, including an end-to-end scan where 朝ラボの習慣 must find the note that contains it.

  • /youtube could never fetch a non-English transcript (#210, reported by @hamidasiblog). get_transcript() called api.fetch(video_id) bare, and youtube-transcript-api defaults to ('en',) - so a Japanese video with retrievable auto-captions failed with NoTranscriptFound, which reads as "this video has no captions" when the transcript was one argument away. Preferences now come from TRANSCRIPT_LANGUAGES (comma-separated, default en, documented in .env.example), and a preference miss falls back to whatever language the video actually has before giving up, with a stderr line naming the language used. Covered by tests/test_research_cjk_and_models.py (configured preference passed through, fallback to available language, true-absence still returns None).

  • /youtube and /podcast silently truncated transcripts at 24k chars, discarding 90%+ of long-form content (#233). Both TX_LIMIT constants predate the Gemini summarization path and its 1M-token context, and still carried comments claiming they were "plenty for grok-4 context." Measured on a 3-hour episode: the model saw only the first ~6K tokens of a 289,911-char transcript. Both caps now read YOUTUBE_TX_LIMIT / PODCAST_TX_LIMIT via config.get_optional(), default 480k chars (~120k tokens), keeping the truncation note for anything still larger.

  • The pinned Gemini default 404s for new API keys, so Gemini summarization failed out of the box (#211, reported by @hamidasiblog). Model access is per-key-cohort, measured in both directions on 2026-08-16: the reporter's new key gets "no longer available to new users" on gemini-2.5-flash, while a pre-retirement key generates with it and 404s on the -latest aliases - and GET /models returns 200 for names a key cannot generate with, so only a generation call can validate a model. No single pinned name works for everyone: lib/gemini.py now walks a fallback ladder (gemini-2.5-flash, then gemini-flash-lite-latest before gemini-flash-latest - the reporter's quota table shows the Lite tier carries ~25x the free-tier daily quota, and these are summarization workloads - then gemini-3.1-flash-lite) on 404 and remembers the first model that generates; /notebooklm reuses the same ladder at its File Search call. An explicit GEMINI_SUMMARY_MODEL / NOTEBOOKLM_MODEL is never laddered - a configured name that 404s fails loud, and every terminal error now names the env var and config path to fix. Covered by tests/test_research_cjk_and_models.py (ladder walk + memoization, explicit-model fail-loud, exhaustion message).

  • Bootstrap's closing message promised "Claude will read it automatically on every session", a claim three separate conditions have to make true (#206). The auto-read is hooks/load_vault_context.py's doing, and it fires only when the SessionStart hook is registered (install.sh or the Claude Code plugin), OBSIDIAN_VAULT_PATH is set in the process environment (scripts/setup.sh writes it into settings.json; the hook does not read the .env fallback), and the session starts inside the vault. A user who runs bootstrap_vault.py from a bare clone - the script's own documented usage - satisfies none of these and gets sessions that never load the manual, with nothing saying why. The same claim-vs-wiring gap as the write-time hook docs fixed for #171. The message now leads with the thing the user must ensure (the agent reads _CLAUDE.md before working in the vault) and names the wiring that automates it, instead of promising the automation unconditionally.

  • A freshly bootstrapped default vault opened with a folder map that lied twice and 2 persistent out-of-the-box freshness errors (#205). Three stacked defects, all found on a first bootstrap + first lint of an untouched vault. (1) _CLAUDE.md's folder map listed a Jobs/<job>.md row per --jobs entry (Jobs/Work.md by default) while bootstrap never writes such a file - a phantom note in the exact file the agent is told is ground truth. The rows are removed; the Jobs/ folder row already documents the folder. (2) The map was built from the preset's base folder list, but bootstrap extends that list before creating folders (the default preset's Side Biz/Deals/* tree), so real folders were invisible to the map. Both claude_md_personal and claude_md_assistant now build the map from the folders actually created. (3) The Kanban plugin footer %% kanban:settings that render_kanban writes on every seeded board parses as a typed pointer, and no .freshness.json maps it, so freshness_lint.py reported FRESH-3 twice on a vault nobody had touched. Fixed in the linter rather than by shipping a mapping, because the footer is not a pointer at all: %%...%% is Obsidian comment syntax, invisible in rendered markdown, and the lint already treats same-line HTML comments and inline code as quotation-grade non-content - Obsidian comments now get the same rule via a per-line visibility scan: every %% toggles comment state, delimiters quoted in backticks or inside code fences stay literal, visible text sharing a line with a delimiter is preserved and checked (an earlier cut of this change dropped whole delimiter lines, which would have silently muted real claims - caught in pre-submission review by two independent reviewers), fence markers inside a comment are inert so an odd fence count cannot leak past the closing %%, and an unclosed comment hides the rest of the note exactly as Obsidian renders it. This extends the showroom rule (stress-test fix 9/24) to the freshness lint: a fresh vault must pass its own inspections, all of them. Covered by tests/test_showroom.py::test_fresh_bootstrap_passes_freshness_lint, ::test_folder_map_matches_what_bootstrap_created (two-directional: every listed path exists AND every contracted folder is listed, in personal and assistant mode), and tests/test_freshness_lint.py::test_obsidian_comments_are_invisible_not_content plus the delimiter-line and quoted-syntax tests beside it; each verified to fail when its fix is reverted.

  • The MCP server counted example wikilinks inside code as real links, so obsidian_vault_health reported fenced syntax demos as wanted notes (#203). #82 taught the CLI vault_health.py to strip fenced blocks and inline code before counting links in the wanted-notes check, and #93 extended that stripping to the stored links feeding orphan detection; the MCP connector's vault_ops.py kept its raw regex through both - the same "fixed in one path, not the other" shape as #160. Every bootstrapped vault ships the pattern in-tree: _CLAUDE.md's kanban convention demonstrates [[Related Project]] [[Person]] inside a fence, the log pointer /obsidian-init writes carries its entry template in one, and syntax demos like `[[wikilinks]]` rang as unresolved links in obsidian_validate_note - persistent false positives, re-reported on every run until someone writes a note that exists only as an example. _wikilinks() now strips code before extraction, which fixes all three consumers at once (vault_health, backlinks, validate). Scope is deliberately the CLI's exact stripping - triple-backtick fences and single-backtick spans; tilde fences and multi-backtick spans are a separate, pre-existing gap shared with the CLI. Covered by tests/test_smoke.py::test_mcp_vault_health_ignores_code_example_links, which also asserts a real link to an unwritten note is still counted; verified to fail when the fix is reverted.

  • Docs claimed the write-time hook ships in every platform build; it ships in claude-code only (#171, measured by the codex-cli platform owner). SKILL.md said the hook script lands in dist/<platform>/hooks/ "for all platform builds", and the AI-first rules table said substitution Unicode is "caught by validate-ai-first.sh check 5" - both read as enforced on builds that contain no such file and no host wiring. SKILL.md now states the hook is claude-code-only and points other platforms at the source repo's hooks/validate-ai-first.hook.yaml + .sh for manual wiring; the rules table scopes check 5 to Claude Code; the codex-cli INSTALL.md gains a "Write-time validation hook (not included)" section. The stale "warning on stderr" behavior description in SKILL.md was also updated to the JSON (systemMessage/additionalContext) contract introduced by #202.

  • validate-ai-first still enforced nothing useful in the VS Code Claude Code extension after it was wired into hooks.json (follow-up to #171, found by @born-in-autumn). Stacked gaps: (1) the extension writes with tool_name=create_file and tool_input.filePath (camelCase) while the stock matcher only listed Write|Edit|MultiEdit|NotebookEdit and the script only read file_path, so the hook exited 0 with no warning; (2) after matcher/filePath were fixed, the hook did warn in the extension hook log (NonBlockingError / later Success + JSON) but the chat UI and the model still saw nothing - plain stderr + exit 1 is log-only, and additionalContext alone does not surface to the user. Matcher now includes create_file; path extraction accepts filePath; warnings exit 0 with JSON that carries systemMessage (user-visible per Claude Code hooks docs), plus decision/reason and hookSpecificOutput.additionalContext for the model path (stderr still mirrored for logs). Covered by tests/test_smoke.py::test_validate_hook_accepts_vscode_extension_payload and a matcher assertion in tests/test_plugin_manifest.py.

  • /podcast downloaded a full transcript, could not read it, and quietly summarized the show notes instead. _parse_json_transcript accepted only the Podcast Index spelling of the per-segment string, segments[].body. Whisper-derived exports spell the same field segments[].text, and several hosts ship those: flightcast, Deepgram, AssemblyAI. Found on a flightcast-hosted show where the <podcast:transcript> tag resolved, the JSON downloaded, and 610 usable segments (~43,000 words) were then discarded because the key was named text. The parser returned None, the caller fell through past Whisper (no OPENAI_API_KEY) to the show-notes path, and the note came out thin with an empty Notable Quotes section. Nothing in the output said a complete transcript had been in hand: the stderr line named the schema as unsupported, which reads as a limitation rather than a near miss, and transcript-source in the saved note recorded show-notes truthfully. Both spellings are now tried in turn, body first so existing feeds are untouched. The two stderr messages no longer claim segments[].body is the only supported shape, since that is no longer true. Covered by tests/test_podcast_transcript_schema.py, including the empty-string case (a segments[].text array of blanks must still fall through rather than return an empty transcript); verified to fail when the fix is reverted.

  • /research-deep labelled a bounded excerpt as "full-page text" and truncated it silently (part of #194, reported by @feariangod). web_reader.read() cuts each extracted page at MAX_EXTRACT_CHARS (8,000), which is the right call - extraction is paid and the synthesis prompt has finite context - but the cut left no trace, and the block was then handed to the synthesizer under the heading "Extracted source content (full-page text)" with each page titled "Full text:". So on any page longer than the cap the model was told it had read the whole thing while holding the opening section, which makes a missing topic look like a claim the source does not make rather than a section that was never sent. Truncated pages now carry an inline [TRUNCATED: ...] marker naming both lengths and stating explicitly that absence below is not evidence of absence in the source; a page under the cap is passed through untouched, with no spurious marker. The prompt heading, the per-page title, web_reader's docstring and commands/research-deep.md all stop calling it full text and name the cap. Covered by tests/test_research_sources.py::test_web_reader_caps_urls_and_truncates, extended to assert the payload is still capped, that the marker is present, and that a short page does not gain one. This is the narrow, verifiable half of #194; the proposed capture_scope/content_hash raw-source schema and strict-local ingest mode remain open for discussion on that issue.

  • The bounded-recall hook shipped permanently inert on CJK vaults, and silently (#192, reported by @hamidasiblog). #159 made search itself CJK-aware, but hooks/obsidian-recall.py kept a private copy of the old tokenizer for its abstention gate - {t for t in re.split(r"\W+", s.lower()) if len(t) > 3} - and that copy was not part of the fix. Python's \w is Unicode-aware, so \W+ never splits a Chinese/Japanese/Korean run: a CJK prompt collapsed into a single token equal to the whole phrase, which then had to appear verbatim in the top hit's title and snippet to satisfy MIN_TERM_OVERLAP. It essentially never did. The retrieval was fine throughout; only the gate was stale, so the hook returned relevant notes and then threw them away. The failure mode is worse than a visible break, because abstention is a normal outcome and the log line ({"abstained": true, "reason": "low confidence"}) is indistinguishable from a genuinely weak match. Reporter measured it on 30 frozen cases: lexical search put a gold note in the top 4 for 10 cases and the gate discarded 7 of them, including 5 of 5 on the two hand-written Japanese sets - every case the search got right. The gate now delegates to vault_ops._query_terms, the same tokenizer search uses, rather than keeping a second definition that has to be remembered separately. Latin behavior shifts slightly and for the better: _query_terms drops stopwords, so an overlap of "there"/"would"/"which" no longer counts as a meaningful match the way a bare len(t) > 3 did. Covered by tests/test_smoke.py::test_recall_hook_abstention_gate_is_cjk_aware, which asserts both directions - a relevant Japanese prompt injects, an unrelated one still abstains, so the fix cannot degrade into a gate that always passes - and verified to fail when the change is reverted.

  • /research-deep saved an empty synthesis under a real title, and reported success. Phase 4 called sonar-reasoning-pro with max_tokens=3500. On a reasoning model that number is the whole allowance, the reasoning is spent first, and the tokens it costs are not itemized in completion_tokens - so a Phase 4 prompt around 4k tokens (it carries the findings plus any Tavily full-text) came back with completion_tokens: 0, no content, and finish_reason: "stop". A success by every field a caller inspects. lib/perplexity.py then returned "", research_deep.py wrote it into the note body, and the except branch that exists precisely to write a labelled "Synthesis unavailable" fallback never fired, because nothing raised. Measured with a ladder on one fixed prompt rather than reasoned about: 3500, 4000, 4500 and 5000 all returned zero visible tokens; 8000 answered but hit finish_reason: "length" in 2 of 3 runs, arriving with 2 and 4 of the 6 required sections; 16000 completed 3 of 3 with all six. Now 16000. The ceiling is close to free, since billing is per token actually emitted (~2,300 here, ~$0.032/run), so it costs nothing until it is used. Separately, lib/perplexity.py now raises on an empty completion instead of returning it, with the budget named in the message - every call site already degrades gracefully on an exception, and an empty note under a real title is worse than a visible failure. Non-reasoning calls (sonar-pro, used by Phase 2, Phase 3 and /research) were checked and do not have this behaviour: they truncate at the cap in the ordinary way. Covered by tests/test_perplexity_empty_completion.py, including the reasoning-only and whitespace-only shapes; verified to fail when the guard is reverted.

  • Every Python-backed Hermes skill named a path that only resolves if you start the agent in the skill directory, which this build's own install doc tells you not to do (#191, reported by @konsone). The report was about one line: the obsidian-health-check blueprint invoked uv run -m scripts.vault_health, which needs the skill root as the working directory, while a cron job is armed with --workdir <vault>. That is real and is fixed. It is also the smaller half. adapters/hermes/adapter.sh rewrote the SKILL_ROOT placeholder to . for all 15 Python-backed commands, and . is the same assumption written differently - so the interactive commands failed too, on an install doc that ends with "point Hermes at your vault as the working directory". Verified rather than reasoned about, by copying the built tree to a fake install root and running each emitted form from a vault: uv run -m scripts.vault_health gives No module named 'scripts', uv run --directory "." scripts/vault_health.py gives Failed to spawn: scripts/vault_health.py, and naming the root outright returns the report. Both forms now resolve through one constant, HERMES_INSTALL_ROOT, set to $HOME/.hermes/skills/obsidian-second-brain - the path INSTALL.md already tells you to copy into. $HOME and not ~, because commands write the placeholder inside double quotes (--directory "SKILL_ROOT") where a tilde does not expand and uv would receive a literal directory named ~; that failure was reproduced too, before choosing. scripts/conformance_report.py had to learn that a citation rooted at an absolute install path cannot be checked against a tree in dist/, so it verifies the tail from references/ onward. Covered in tests/test_smoke.py::test_hermes_build_generates_native_skills: the health blueprint must run --directory, and no markdown in the build may ship --directory "." or --directory "~. Reporter's own deployment carried a sed patch hardcoding their skill path to work around this; it should no longer be needed.

  • The scheduled Hermes blueprints skipped the folder-map sweep, so the nightly agent scanned wiki/ folders on vaults that have none (#190, reported by @konsone). Every interactive command resolves its target folder through references/folder-map.md; the four blueprints are hand-written inside adapters/hermes/adapter.sh rather than derived from commands/, which is how they missed it. On an Obsidian-style vault (Knowledge/, Ideas/, People/) the nightly pass therefore opened with three guaranteed tool failures on wiki/entities, wiki/concepts and wiki/decisions, every night, with no interactive user present to notice - the reporter's logs show Hermes's own curator then trying to patch the skill in place. Phase 2 now resolves all three folders through the folder map (vault _CLAUDE.md first, wiki-style default, Obsidian-style alias) and treats a folder that does not exist as a skip rather than an error, and Phase 3 writes its synthesis into the folder resolved in Phase 2. Fixing it surfaced two further drifts from SKILL.md, which the adapter comment names as the canonical source for these prompts: the blueprint told the agent to "auto-resolve clear winners" among contradictions, which SKILL.md had already changed to flag-only precisely because resolving one rewrites a note unattended, and which the blueprint's own closing "do not fix anything destructive" contradicted; and Phase 5 lacked the Logs/YYYY-MM-DD.md branch. Both re-synced. SKILL.md's copy of the nightly prompt had the same hardcoded paths and is fixed with it, so the Claude Code /schedule route was affected and is covered by the same change. Covered in tests/test_smoke.py::test_hermes_build_generates_native_skills, asserting on the bare imperative (Scan \wiki/entities/`) rather than on the path, since naming wiki/entities/` as the wiki-style default beside its Obsidian-style alias is correct and expected.

  • Every MCP install broke at once when the mcp SDK shipped 2.0.0 (#183, reported by @jackiepan99). The plugin manifest launched the server with uv run --with mcp, which resolves to whatever is newest on PyPI at launch time - so the release that restructured the SDK and dropped mcp.server.fastmcp (FastMCP now lives at mcp.server.mcpserver.MCPServer) turned server.py's import into ModuleNotFoundError on every machine, without anything in this repo changing. The failure surfaced in Claude Code only as Failed to reconnect ... -32000, which points at the plugin rather than at a dependency the plugin never pinned; two of the installs where this was diagnosed had been silently disconnected for days. The launch command is now pinned to mcp<2 (last working release: 1.29.0) everywhere it appears - the plugin manifest, scripts/setup.sh, SKILL.md, README.md, and the integration's own README, server.py and live_test.py docstrings. Pinning is the whole fix; porting to the 2.x API is a separate change that should not be forced by an unattended resolution. Covered by tests/test_plugin_manifest.py::test_mcp_launch_pins_the_mcp_dependency (the manifest arg must carry a constraint) and ::test_no_documented_command_reinstalls_the_unpinned_mcp (no copy of the command anywhere in the tree may drop it again).

  • Three builds shipped purely reactive skills because they never read trigger-mode (#181, reported by @konsone). 7 of the 8 commands that declare the field are proactive - /obsidian-save, /obsidian-task, /obsidian-person, /obsidian-daily, /obsidian-capture, /obsidian-log, /obsidian-decide - which is what lets an agent offer to save a conversation without being asked. The policy was encoded in agent-skills only. The report named hermes; the real scope is every adapter that writes its own frontmatter, so codex-cli and pi had the same gap and lost the signal on all 7. claude-code, gemini-cli and opencode copy the command body verbatim, frontmatter included, so the field travels there and they were never affected - checked per build rather than inferred from a grep. The policy now has one definition, with_trigger_policy in adapters/lib.sh, called by all four generating adapters instead of living in one and being absent from three. Nothing failed while this was broken: the builds compiled, the skills loaded, and they simply never volunteered. Covered by tests/test_dispatcher_prose.py::test_generated_descriptions_carry_the_trigger_policy, which reads the declared mode out of commands/ and asserts the matching wording reaches every generated build; verified to fail when the fix is reverted.

  • validate-ai-first.sh was never wired, so the rule this repo calls non-negotiable enforced nothing on a plugin install (#171, found by @born-in-autumn). The script has shipped in hooks/ since it was written, its own header says it "fires as a Claude Code PostToolUse hook after Write/Edit", and CLAUDE.md cites it as what enforces the substitution-Unicode ban on vault writes. hooks/hooks.json declared only SessionStart and PostCompact. Nothing ran it. It also had a second silent no-op behind the first: vault resolution read OBSIDIAN_VAULT_PATH from the environment only and exit 0'd when unset, so even a hand-wired hook did nothing on a plugin-marketplace install, which configures the vault in ~/.config/obsidian-second-brain/.env and never exports it. That is the same root cause as #160 (MCP server) and #124 (research toolkit), in a third code path that was never swept because the hook was not running to fail. Now wired as PostToolUse on Write|Edit|MultiEdit|NotebookEdit, with the documented .env fallback (environment still wins, OBSIDIAN_ENV_FILE overrides). Covered by tests/test_plugin_manifest.py::test_plugin_hooks_reference_shipped_executable_scripts, which now asserts the event set includes PostToolUse and that it references the script by name.

  • Every build cited the AI-first spec by a path that resolves from exactly one directory, and failed silently everywhere else (#171, reported by @Palo-Alto-AI-Research-Lab). Skill and command bodies pointed at the spec with an install-root-relative path (.codex/references/ai-first-rules.md and its per-platform equivalents). Start the agent in any subdirectory and the read fails - and nothing surfaces, because an unreachable advisory reference does not stop the skill from running, so a note written without the spec is indistinguishable from one written with it. scripts/conformance_report.py could not catch this: it asserts the cited file exists inside the build, which was true. It has no concept of where the agent stands when it reads. Every citation now carries a recovery path (search upward) and an instruction to say so before writing if the spec is still unreachable, with the rule summary itself inline as the floor. agent-skills was already immune, since it embeds the full spec in every SKILL.md; embedding it in the other six would have cost ~6.7MB of context for the same guarantee. The rule summary also gained a step-number parameter so agent-skills calls the one definition in adapters/lib.sh instead of carrying a seventh copy. Covered by tests/test_smoke.py::test_relative_reference_citations_are_not_silent (sweeps all 273 pointer-only files across every build) and an extended assertion in tests/test_dispatcher_prose.py.

  • Redacted vault note titles and a verbatim query from a tracked eval doc, and added a CI guard. scripts/eval/BASELINE.md is public, is written while looking at real search output, and had picked up two note filenames and a quoted query from the maintainer's own vault - the second time this has happened in that file. The case files are gitignored, which makes the area feel safe and is exactly why the prose keeps leaking. tests/test_no_vault_content_in_eval_docs.py now fails when an eval doc backticks a .md filename that does not exist in this repository, or quotes a long string with no <placeholder> in it. The rule needs no list of real names, since keeping such a list would be its own leak.

  • The semantic index went stale silently, and nothing anywhere said so. The index is built on demand and never invalidates itself; the README told users to build it "once". So it drifts behind the vault, and on a real 1,828-note vault it had drifted to covering 1,303 - 29% of notes had no vector at all. That reads as a minor staleness issue and is not: an unindexed note is still reachable by literal word match, so on English queries the lexical arm papers over the gap and the drift is invisible, but on a query written in another language the lexical arm contributes nothing (measured on the multilingual eval set: every hit came from the semantic arm, and the target note's lexical rank was absent or in the hundreds). For those queries an unindexed note is not ranked low, it is unretrievable. Search now warns once per index version when coverage falls more than 5% behind (OBSIDIAN_INDEX_STALE_WARN_PCT), reusing the scan it already performs so the check costs a set difference; /obsidian-health reports coverage as a semantic_index finding, reading note keys out of the (66MB) index as a stream rather than parsing every float; and the README no longer says "once". Covered by tests/test_index_staleness.py.

  • Vault health reported accented notes as both wanted AND orphan on macOS (#161, by @kontaktgift). A wikilink and a filename that differ only in Unicode composition (NFC vs NFD) name the same note, but a plain string compare rejected them - macOS stores filenames decomposed (NFD) while text typed or pasted is usually composed (NFC), so any accented title (German umlauts, Spanish/Portuguese/French diacritics) could be flagged twice, as a wanted note (link "goes nowhere") and an orphan (nothing "links to" it), while the note sat right there. vault_health.py now normalizes to NFC at the comparison boundary only (stem, alias, asset and link-source keys), NFC not NFKC so distinct titles stay distinct. Companion fix in the same release: scripts/link_graph.py (which imports the same file index and promises identical link rules) now applies the same NFC normalization in its _norm, dangling-link, and directory keys, so /obsidian-visualize resolves accented titles exactly as /obsidian-health does instead of showing the phantom orphan the health check stopped showing. Covered by tests/test_vault_health_unicode.py (by @kontaktgift) and tests/test_smoke.py::test_link_graph_resolves_unicode_composition.

  • MCP search silently dropped most CJK queries (#159, reported by @SylvesterTee). vault_ops.search() split query terms on \W+ and then discarded anything len <= 2 - a filter calibrated for English noise words (a, is, of). But Python's \w is Unicode-aware, so it never splits a Chinese/Japanese/Korean phrase, and two characters is the single most common CJK word length (系統, 資料, 会議). The result: a word appearing thousands of times in a vault was invisible to search, while the fallback repeated the same filter and returned nothing. Search now uses a CJK-aware tokenizer that indexes CJK runs as overlapping character bigrams (a lone char stays a unigram) while keeping the English len > 2 + stopword rule for Latin tokens, so 系統 is findable and a reordered phrase still overlaps. Latin ranking behavior is unchanged. Covered by tests/test_smoke.py::test_mcp_vault_ops_search_finds_cjk_words.

  • MCP server ignored OBSIDIAN_VAULT_PATH from the config .env (#160, reported by @SylvesterTee). architecture.md promises the vault path is read from "the environment or ~/.config/obsidian-second-brain/.env", but resolve_vault() checked only os.environ - so a plugin-marketplace install that configured the vault in .env (as the docs instruct) got a non-functional MCP server whose error message contradicted the user's own config. This was #124 (PERPLEXITY_API_KEY ignored from .env) in a different code path; that fix landed in the research toolkit but the MCP server was never swept. resolve_vault() now falls back to the config .env (parsed with a tiny stdlib reader, since the server runs under uv run --with mcp without python-dotenv; path overridable via OBSIDIAN_ENV_FILE), with the environment still taking precedence. Covered by tests/test_smoke.py::test_mcp_vault_ops_resolves_vault_from_env_file.

Added

  • Opt-in tag taxonomy audit (#221, taxonomy half - the digit-only/tag-syntax half of the issue is a separate write-time check). A vault can now declare a canonical tag vocabulary at _meta/taxonomy.md - one ## heading per canonical tag, its synonyms as a - list underneath (format and rationale in references/taxonomy-format.md). scripts/vault_health.py gains load_taxonomy (parses the file, empty dict if it does not exist) and check_taxonomy, which reports two disjoint findings: tag_synonym (warning - a note's tag is a known synonym, fold it to the canonical form) and tag_not_in_taxonomy (info - the tag matches nothing in the vocabulary, which is not necessarily wrong). Absence of the file is a true no-op: zero findings, nothing changes for the vaults that have not opted in. vault_health.py stays pure-report as it already was for every other check; /obsidian-health's new Taxonomy agent offers the synonym fold per note with confirmation and never touches the taxonomy file or a "not in taxonomy" tag on its own. _meta/ is now skipped by vault_health.py's note scan, since it holds tool config, not AI-first vault content. Covered by tests/test_taxonomy_audit.py.

  • Simplified Chinese trigger phrases for all 46 commands. Each command now carries natural triggers_zh requests written for how a Chinese-speaking user would actually ask, rather than literal translations of the English phrases. Dispatcher builds label the language as 简体中文, and the generated command reference publishes the same phrases alongside English, Spanish and Portuguese. A docs-generation test requires every published language on every command so future commands cannot silently ship without Chinese routing coverage.

  • /obsidian-reindex turns semantic-index maintenance into a first-class command. It reports coverage before and after the existing incremental build, names how many notes were newly embedded or refreshed, and surfaces cached, excluded, degraded, and dropped notes instead of hiding gaps behind a success message. If Ollama or another configured embedding backend is unavailable, the command stops on the builder's nonzero exit and relays its actionable setup error. The flow updates only .obsidian-semantic-index.json; Markdown notes are untouched.

  • AI-First Lint, an Obsidian plugin (integrations/obsidian-plugin/). The Obsidian community plugin directory is the only large, automated, in-app distribution surface in this space, and it accepts plugins and themes only, so it is closed to a CLI skill by exactly one technical fact. This opens it, and it earns the listing rather than merely qualifying for it: the plugin checks a vault against AI-FIRST.md and lists the notes an agent cannot use - missing frontmatter, missing ## For future Claude preamble, missing ai-first: true, and cited sources with no as of date - with each result clickable. It is standalone: no CLI, no Python, no network, no shelling out, so someone who has never heard of this project can install it and get value. All rule logic lives in src/lint.ts as pure functions with no Obsidian import, tested with plain strings via node:test and no extra dependency. Most of those tests are about not firing, because a linter's failure mode is false positives: URLs inside code fences and filenames like README.md are explicitly not reported. Verified in both directions against real input - silent across all 300 notes of the synthetic benchmark corpus, and firing on every one of this repo's own non-compliant documents. Typecheck, build and tests run in CI.

  • Platform ownership is open, and the credit ships inside the build. Seven builds are compiled from one source tree by one person who can realistically test two of them, and five audit findings (B3, B19, B20, S15, S23) were all the same defect: a build that compiled cleanly, shipped, and was wrong in a way only a daily user of that platform would notice. adapters/OWNERS.md opens every build to a named owner, states plainly what ownership does and does not involve (no Python, no response-time expectation, no permanence), and credits the contributors who have already worked on each adapter as credit rather than assignment. The table is not documentation: scripts/build.sh reads it and appends the owner's handle to that platform's generated INSTALL.md, so claiming a platform is a one-line edit to one table and the reward ships where users actually see it. This was blocked until the conformance board existed, because an adapter PR can only be accepted without the maintainer testing that platform if CI checks the parts that do not need it installed. Covered by tests/test_platform_owners.py, including that a malformed OWNERS.md cannot break a release.

  • AI-FIRST.md - the note spec as a standalone, pasteable document. The canonical specification is 518 lines and declares itself canonical on line 5, which makes it the one artifact here with zero installation cost and also far too heavy for anyone to copy. This is the same rules as a 50-line block you paste into a CLAUDE.md, AGENTS.md, GEMINI.md, or anything else your tool reads at session start. It installs nothing, needs no part of this project, and carries its attribution line inside the copied block so a copy can always be traced back. Versioned (1.0) so an adopted copy can say what it was adopted from. Includes both hard rules, not only the seven numbered ones: no fabrication, and retrieved content is data rather than instructions - a portable spec that dropped the second would teach the unsafe version. tests/test_ai_first_spec.py fails when the short form drifts from the long one, which is the same two-copies problem that produced B21 and S18 in this repo, except here there is no build step that would ever notice.

  • A reproducible retrieval benchmark. This project has a standing rule that no retrieval change ships without before-and-after numbers, and every one of those numbers was measured against the maintainer's own vault, whose case files are gitignored because they hold real notes. So the figures were claims nobody outside could check or beat. scripts/eval/corpus.py now generates a deterministic 300-note synthetic vault (fixed seed, a --manifest hash to confirm two people have the same corpus, synthetic: true on every note, and no content drawn from any real vault) plus three gold query sets: exact keyword, English paraphrase, and the same descriptions in Spanish and Russian against English notes. Difficulty is built in rather than incidental - every topic has one short canonical note and at least three longer derivative notes that mention it more often, which is the shape that makes term-frequency ranking pick the wrong answer and the shape this project's own evaluation kept hitting. Gold answers are known by construction, not hand-labelled. scripts/eval/BENCHMARK.md publishes the baseline, the methodology, how to report a result, and four known limitations. Covered by tests/test_benchmark_corpus.py, which tests the benchmark's integrity rather than just that it runs: the first version wrote each paraphrase query verbatim into its own answer note and scored 83% on pure lexical search, which looked like a strong result and was the answer key.

  • A generated docs site, built from commands/. GitHub Pages has been live and building for this repo for some time, serving the README from the repo root, while commands/ held 45 files each carrying a one-line description, a category, and triggers_en / triggers_es / triggers_pt arrays - which are, literally, the sentences a person would type when they want the thing, written by hand in three languages and visible until now only to the dispatcher adapters. Content, hosting and build tooling all existed and were wired to nothing. scripts/build_site.py now generates an index plus one page per command into docs/: 46 pages, inline CSS, no fonts or scripts from any third party, light and dark both handled, and one small inline filter script the page degrades gracefully without. Command bodies are deliberately not published - they are instructions addressed to an agent, not prose a reader wants. Generated rather than hand-written for the same reason the adapters are, and --check fails CI when the committed tree falls behind the generator, so a page cannot end up contradicting the command it documents. Covered by tests/test_build_site.py.

  • A one-time star prompt, shown after the tool has actually done something for you. The README asks every stranger who lands on the repo the same way. This asks in the terminal, once per machine ever, and only after a moment the reader can see: a bootstrap that just created their vault, or a health check that came back with zero issues. It quotes the number they are already looking at rather than making a generic pitch. Suppressed by OBSIDIAN_NO_STAR_PROMPT=1 and by CI, never printed on a --json path, and never shown when the health check found problems, because asking for a favour in the same breath as reporting someone's mess has the tone backwards. The marker file lives in the config directory, and if it cannot be written the prompt is skipped rather than shown, since a prompt that cannot record itself would repeat forever. Covered by tests/test_star_prompt.py, which tests both directions - every suppression path, and that it still fires. The first implementation gated on stdout.isatty(), which in a project whose scripts are normally run by an agent into a pipe would have meant it never appeared for a single user; there is a regression test for exactly that.

  • scripts/eval/diagnose.py - why a retrieval case missed, not just that it did. retrieval_eval.py reports recall and MRR; it cannot say whether a miss is a coverage failure (the note has no vector), a pool failure (ranked below the fusion depth in both arms), or an ordering failure (in the pool, ranked out) - and only the last is reachable by any weighting change. --gap additionally reports how much cosine a fix would have to supply to lift the note past the rank-10 cutoff, which is the number that decides whether a weight could ever work: on the multilingual set four of six misses need more lift than the entire rank-1-to-rank-10 band is wide. Four separate weighting experiments had already been run against those cases before this was measured. Output is rank numbers only unless --show-paths is passed, so a run against a private vault is safe to share. Covered by tests/test_eval_diagnose.py.

  • Typed edges + graph linting - the graph-engineering layer. A plain [[wikilink]] says two notes are related but not how; notes can now record typed relationships in a relations: frontmatter block with a controlled vocabulary (supersedes/superseded_by, depends_on/required_by, caused/caused_by, decided_by/decides, relates_to, contradicts), turning a pile of links into a traversable, interpretable graph. scripts/link_graph.py parses the block (inline-list and block-list forms, plus the legacy top-level supersedes: scalar as an equivalent alias) and exposes it as a typed_edges overlay in its JSON - kept separate from degree, since the underlying frontmatter link is already counted, so orphan/hub math is unchanged. A new --lint mode validates the layer and returns severity-ranked findings: contradiction cycles (A and B claim the same asymmetric type about each other) as critical; unknown types, dangling targets, and self-edges as warnings; missing inverse edges as info. /obsidian-health gains a typed-edge lint agent that folds these into its severity groups; /obsidian-visualize labels canvas edges with their relation type and summarizes the overlay (counts by type, longest reasoning chains). Documented as Rule 6 § Typed edges in references/ai-first-rules.md (with an ADR-schema cross-reference). Covered by tests/test_smoke.py::test_link_graph_typed_edges_and_lint. Implements the techniques from the "graph engineering" writeup (typed edges, entity resolution via wikilinks, graph linting) while staying plain-markdown, no-database, no-lock-in - the overlay is reconstructed on demand from frontmatter, never stored.