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
-
/podcastsummarizes 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_KEYset 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/podcastused to fall back silently to the most recent episode; a second iTunes lookup (entity=podcastEpisode) now resolves the id to its title and_pick_entrymatches on that. Covered bytests/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 wrotetags: [33]or[2.0]never learned it had.hooks/validate-ai-first.shcheck 7 andvault_health.py's newcheck_tag_syntax(issue typeinvalid_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- itemblock forms;vault_health.pyreads inline and block through the sameparse_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. -
/podcastgained a free Groq-hosted Whisper transcription fallback (#233). The transcript chain wasrss-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 withoutOPENAI_API_KEYat all there was no audio path.scripts/research/lib/groq.pyinserts the free Groq tier (GROQ_API_KEY,whisper-large-v3-turbo) as the middle step, provenancegroq-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/-tchunks 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.mdandreferences/ai-first-rules.mddocument the new step and thegroq-whisper-apitranscript-source value. Covered by 27 offline tests intests/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-healthfinds 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.pydoes the mechanical half: union the two notes' frontmatter (canonical's value wins a conflict, the loser is recorded under a newmerged_from:block), fold the retired note's title into the canonical note'saliases:, and replace the retired note with a shorttype: redirectstub (schema added toreferences/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 onecompute_merge()so the preview can never drift from what gets written.--from-healthresolves a pair fromvault_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.mdcomposes it (one## For future agentpreamble, both notes' provenance trails kept, contradictions between them listed rather than silently resolved, perreferences/ai-first-rules.md) and hands it to the script via--merged-body-file. Covered bytests/test_merge_notes.py: dry run writes nothing,--applywrites the redirect stub and folds the alias, frontmatter conflicts resolve canonical-wins, no conflict means nomerged_fromnoise,--from-healthresolves a real pair (and refuses a 3-file group), and a missing--merged-body-fileerrors 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.mdvsArchive/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.pyruns each question from a newbehaviorcase 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 knownanswer_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 incorpus.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 fromresearch.lib.grok, judging from the newresearch.lib.gpt(OPENAI_API_KEY, defaultgpt-4o-miniviaGPT_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 carriesretrieval_empty. The corpus gained one contradiction line per daily note, so the published corpus hash inscripts/eval/BENCHMARK.mdmoved to5773dc7f39b0c3b0; 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). TheUSERPROFILE-on-Windows block from #242 had been pasted into seven shell scripts. It now lives inscripts/platform-home.sh(osb_platform_homesetsOSB_WINandOSB_HOME), sourced byinstall.sh,update.sh,scripts/setup.sh,scripts/run-command.shand the Telegramsetup.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) andscripts/quick-install.sh(curl | bash, before any checkout exists).tests/test_platform_home.pyfails 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 totests/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 agentheading, andhooks/validate-ai-first.shcheck 4 and the MCPvalidate_notematched 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.mdrule 2 says so, and the hook,validate_note, andvault_health's duplicate-similarity stripper recognize both spellings (any callout type, folded or not; the legacy labelsAI,Claude,Codexincluded). The heading stays the default every command writes. A bold line or a paragraph without the title is still not a preamble. Covered bytests/test_smoke.py::test_validate_hook_accepts_the_callout_preambleand::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_LIMITandGROQ_API_KEYare in.env.example; a cap that is not a whole number (480k,1e6) exits naming the variable via the newconfig.get_optional_int()instead of anint()traceback; and the## For future agentpreamble of/youtubeand/podcastnotes names the model that actually wrote the summary (Gemini or Grok) instead of always saying Grok.commands/podcast.mdnow 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-ingestchecks for a previous ingest before writing a raw note (#218, raised by @konsone). The raw-source schema already carriedsource_urlandcontent_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 definescontent_hash(first 16 hex of SHA-256 over the verbatim text) and searchesraw/for it and for the normalizedsource_urlfirst: 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 carriessupersedes: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.mdgains a "Batch writes" section with the git-branch recipe and the LiveSync snapshot equivalent, and/obsidian-ingestpoints 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 usefuture agent. No vault migration needed:validate-ai-first.shcheck 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
mcpServerskey in.claude-plugin/plugin.jsonwas alsoobsidian-second-brain, and Claude Code composes tool names asmcp__plugin_<plugin>_<server>__<tool>- so every tool arrived asmcp__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 nowvault, givingmcp__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 too2b, which was declined because the name is the install identity of the repository and the commands are alreadyobsidian-*, soo2b:obsidian-dailywould save little. Upgrade note: if you allowlisted these tools by their full name insettings.json, update the server segment fromobsidian-second-braintovault.
Security
-
The Telegram journal bot accepted messages from any sender (discussion #215, raised by @robertcamero).
telegram_journal.pypolledgetUpdatesand 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 requiredTELEGRAM_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.shprompts for the id, the env template and README document it. Upgrade note: existing installs stop saving untilTELEGRAM_ALLOWED_CHAT_IDSis set; message your bot once and it tells you the id to add. Covered bytests/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 asfolder / f"{name}.md"wherenameis whatever sat inside a[[wikilink]]the model wrote, so a link like[[../../somewhere/else]]resolved to a write outside the vault. Names now pass throughsafe_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 bytests/test_telegram_ingest.py(traversal, absolute path, dot-names refused; plain and Unicode names accepted). -
Lockfile carried two dependencies with published advisories (discussion #215).
cryptography47.0.0 -> 50.0.1 (GHSA-537c-gmf6-5ccf) andurllib32.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 viagoogle-api-python-client;uv.lockonly, nopyproject.tomlchange.
Fixed
-
/obsidian-ingestrewrote existing notes without confirmation, andcontent_hashkeyed on the raw capture (#239, raised by @konsorsiumai). Two spec gaps.references/ai-first-rules.mdhas 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 reachedcommands/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 andlog.mdwith 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_hashwas 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 asupersedes:raw note. Both are prompt-level contracts;tests/test_untrusted_source_handling.py::test_ingest_treats_rewrites_of_existing_notes_as_proposalspins the wording so it cannot drift out again. -
validate-ai-first.shexited 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 readfile_pathandfilePath(andargs.*); 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 readsnotebook_path/notebookPathas well (theNotebookEditshape its own matcher names; an.ipynbthen drops at the.mdgate as before), and when a payload names atool_namebut 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 notool_namestays silent. Covered bytests/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'scp1252_defaultfixture substituted cp1252 only whenPath.open()receivedencoding=None, butread_text()/write_text()resolveNoneto the string"locale"before callingopen()(Python 3.10+), so the fixture intercepted only the directopen("a")inappend_to_logand 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_splittingbuilt its Windows cases only when the runner's ownos.namewas"nt", so the non-POSIX branch of_split_external_cmdhad zero CI executions; the test is now parametrized over both branches and setsos.namein 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/.envto two different folders, so a user who followed the README configured one half only. Python'sPath.home()readsUSERPROFILEand ignoresHOME(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 setsHOMEtoUSERPROFILE, but a machine whose Windows environment definesHOME(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~/.claudethat Claude Code (which keys onUSERPROFILEtoo) never reads. On Windows shells the bash scripts now resolve the home asUSERPROFILE(viacygpath), matching Python and Claude Code; on macOS and LinuxHOMEis still the home and nothing changes. The research loaders and the retrieval eval also honorOBSIDIAN_ENV_FILEnow, the override the MCP server and the hook already accepted, andinstall.shandscripts/setup.shwrite 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.claudepaths instead of~/..., which bash expands fromHOME. README documents both, with theUSERPROFILE-based commands for a corporate-HOMEmachine and the caveat that a Cygwin- or MSYS-built Python followsHOME. Covered bytests/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.pycrashed 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 withensure_ascii=Falseto a pipe that Windows encodes as cp1252, so a decomposed title (u + U+0308, the macOS filename form its own test exists for) raisedUnicodeEncodeError; stdout is now forced to UTF-8 the waybootstrap_vault.pyalready does, andretrieval_eval.py's JSON report gets the same guard.retrieval_eval.py --mode externalalso lost the backslashes of a Windows engine path to POSIX-modeshlex.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 bytests/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 sochmod 600cannot be verified throughst_modethere (their other assertions still run), three home-redirection tests also setUSERPROFILE(whatPath.home()reads on Windows;HOMEis 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.shwas a silent no-op on Windows. Claude Code passes the written file's path with backslashes (C:\Users\...) whileOBSIDIAN_VAULT_PATHis 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 -mthen 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.envfallback 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 inOBSIDIAN_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 bytests/test_windows_compat.py::test_validate_hook_matches_windows_backslash_paths(runs on Windows, skipped elsewhere). -
merge_notes.pycarried the retired note'sdateandstatusinto the canonical note, and reported no conflicts, when the canonical note started with a UTF-8 BOM.note_io.read_exactreturns the text byte-exact, BOM included (the rule from the BOM fix: files keep their BOM, readers stop being blind), butexport_okf.parse_noteanchored 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.pyitself was never affected, because it reads withutf-8-sig.parse_notenow skips a leading BOM the wayvault_scan.split_frontmatteralready 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 bytests/test_merge_notes.py::test_bom_notes_keep_their_frontmatter_through_a_merge,::test_a_bom_on_the_retired_note_stays_on_its_redirect_stubandtests/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, sotests/test_research_sources.py::test_cache_roundtripwas flaky on Windows. The check wasage > 0, with the age computed astime.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 andRESEARCH_CACHE_TTL_HOURS=0already meant; positive TTLs are unchanged, andput()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_clockmakes 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
/notebooklmsynthesis 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 withUnicodeEncodeError; one carrying only characters it can encode, an accented letter say, was written as code-page bytes that Obsidian reads as mojibake; andappend_to_daily, which rewrites the whole daily note, left it empty on the first kind, becausewrite_text()truncates before it encodes.notebooklm.py's save andlib/vault.py'swrite_note,append_to_logandappend_to_dailynamed no encoding, which is UTF-8 on macOS and Linux and the code page on Windows. They now write (andappend_to_dailyreads) UTF-8, the encoding Obsidian expects; a daily note or alog.mdthat 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/notebooklmsave moves intosave_note()so it can be exercised without Gemini. Raised in review as the missing half of the read-side entry below; covered bytests/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_aloneand::test_append_to_log_leaves_a_log_it_cannot_decode_alone. -
The
/research-deepand/notebooklmvault scans found nothing for a CJK topic on Windows, and every note excerpt/research-deepsent onward was mojibake for any non-ASCII character.vault_scan()inresearch_deep.pyandnotebooklm.py, and the two excerpt reads inresearch_deep.py, calledread_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 nameencoding="utf-8", the encoding Obsidian writes, so Windows reads exactly what the other platforms already did. Found bytests/test_research_cjk_and_models.py::test_vault_scan_finds_cjk_topicon Windows;::test_vault_scan_and_excerpts_read_utf8_under_a_cp1252_defaultemulates that default, so the case runs on the ubuntu CI runner too. -
freshness_lint.pynamed files with backslashes on Windows (Boards\Work.md), so a finding'sfilefield differed by platform. The field is the contract of the--jsonoutput and of the tests, and the vault's own link form is forward-slash, so the relative path is now rendered withas_posix(), the wayvault_healthalready keys its notes (B40 intests/test_frontmatter_parity.py); output on macOS and Linux is unchanged. Found bytests/test_freshness_lint.py::test_obsidian_comments_are_invisible_not_contenton Windows;::test_findings_name_files_with_forward_slashespins the rendering with aPureWindowsPath, so the case runs on the ubuntu CI runner too. -
merge_notes.pylost 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 handsexport_okf.parse_notethe byte-exact textnote_ioreturns, and the parser's fence pattern accepted spaces and tabs after---but not a carriage return, so---\r\nnever matched and the note read as having no frontmatter at all.export_okf.pyitself was not affected, because its own read is in text mode and normalizes the newlines first;vault_healthandvault_statsread the byte-exact text correctly, because their patterns use a whitespace class that absorbs the\r, which is the S9 disagreementtests/test_frontmatter_parity.pyexists to pin, on line endings this time.export_okf.FM_REand its twinvault_scan.FRONTMATTER_REnow accept\r\non both fences (asbuild_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 bytests/test_merge_notes.pyon Windows, wherewrite_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_mergepins each side and both on every platform. -
/notebooklmproduced an empty filename for Cyrillic and any non-Latin topic (#227, reported and fixed by @konsone in #228).notebooklm.pykept a privateslugify()that dropped everything outside[a-z0-9\s-], so a Russian topic becameYYYY-MM-DD - .mdand an empty Gemini File Searchdisplay_name. It now uses the sharedlib.vault.slugify()(Unicode word characters, the one/youtubealready used) plus theor "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 now2026-08-26 - smart home automation.mdrather than2026-08-26 - smart-home-automation.md, which is the form every other research command already writes. -
The MCP server registered zero tools on older
mcp1.x releases (#229, reported and fixed by @mpuglin).server.pyopened withfrom __future__ import annotations, which turns every annotation into a string; fastmcp inmcp1.9.x callsissubclass(param.annotation, Context)while registering tools, so the first@mcp.tool()raisedissubclass() arg 1 must be a classand 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 indexmcp<2resolves 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.lockinto their repository and syncing their dependencies into a.venvbeside it. The manifest launched the server withuv run --with 'mcp<2' python ..., and Claude Code starts an MCP server with the user's working directory as cwd.uv rundiscovers a project from cwd upward, so in any repository carrying apyproject.tomluv treated that repository as the project it had been asked to run in. Measured on Windows with uv 0.11.2 against a repo whosepyproject.tomlcarries only pytest configuration, with no[project]and no[tool.uv]table: that was still enough for uv to create a.venvon Python 3.14 (the repo targets 3.12) and write a 52-byteuv.lockat the root on every session start, and sinceuv.lockwas 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 ismcp, which--withalready supplies, andvault_opssits 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.pyandvault_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 bytests/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_lintflagged 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 bytests/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'svault_scankept 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 inresearch_deep.py. The whitespace split +len(w) > 2filter 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. Bothvault_scans now tokenize via the newlib/vault_terms.topic_terms(), which delegates tovault_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 underscripts/research/; thescripts/eval/copies keep their own semantics deliberately (gold matching) and are out of its scope. Covered bytests/test_research_cjk_and_models.py, including an end-to-end scan where朝ラボの習慣must find the note that contains it. -
/youtubecould never fetch a non-English transcript (#210, reported by @hamidasiblog).get_transcript()calledapi.fetch(video_id)bare, and youtube-transcript-api defaults to('en',)- so a Japanese video with retrievable auto-captions failed withNoTranscriptFound, which reads as "this video has no captions" when the transcript was one argument away. Preferences now come fromTRANSCRIPT_LANGUAGES(comma-separated, defaulten, 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 bytests/test_research_cjk_and_models.py(configured preference passed through, fallback to available language, true-absence still returns None). -
/youtubeand/podcastsilently truncated transcripts at 24k chars, discarding 90%+ of long-form content (#233). BothTX_LIMITconstants 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 readYOUTUBE_TX_LIMIT/PODCAST_TX_LIMITviaconfig.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-latestaliases - andGET /modelsreturns 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.pynow walks a fallback ladder (gemini-2.5-flash, thengemini-flash-lite-latestbeforegemini-flash-latest- the reporter's quota table shows the Lite tier carries ~25x the free-tier daily quota, and these are summarization workloads - thengemini-3.1-flash-lite) on 404 and remembers the first model that generates;/notebooklmreuses the same ladder at its File Search call. An explicitGEMINI_SUMMARY_MODEL/NOTEBOOKLM_MODELis never laddered - a configured name that 404s fails loud, and every terminal error now names the env var and config path to fix. Covered bytests/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.shor the Claude Code plugin),OBSIDIAN_VAULT_PATHis set in the process environment (scripts/setup.shwrites it intosettings.json; the hook does not read the.envfallback), and the session starts inside the vault. A user who runsbootstrap_vault.pyfrom 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.mdbefore 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 aJobs/<job>.mdrow per--jobsentry (Jobs/Work.mdby 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; theJobs/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'sSide Biz/Deals/*tree), so real folders were invisible to the map. Bothclaude_md_personalandclaude_md_assistantnow build the map from the folders actually created. (3) The Kanban plugin footer%% kanban:settingsthatrender_kanbanwrites on every seeded board parses as a typed pointer, and no.freshness.jsonmaps it, sofreshness_lint.pyreported 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 bytests/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), andtests/test_freshness_lint.py::test_obsidian_comments_are_invisible_not_contentplus 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_healthreported fenced syntax demos as wanted notes (#203). #82 taught the CLIvault_health.pyto 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'svault_ops.pykept 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-initwrites carries its entry template in one, and syntax demos like`[[wikilinks]]`rang as unresolved links inobsidian_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 bytests/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-codeonly (#171, measured by the codex-cli platform owner).SKILL.mdsaid the hook script lands indist/<platform>/hooks/"for all platform builds", and the AI-first rules table said substitution Unicode is "caught byvalidate-ai-first.shcheck 5" - both read as enforced on builds that contain no such file and no host wiring.SKILL.mdnow states the hook is claude-code-only and points other platforms at the source repo'shooks/validate-ai-first.hook.yaml+.shfor manual wiring; the rules table scopes check 5 to Claude Code; the codex-cliINSTALL.mdgains a "Write-time validation hook (not included)" section. The stale "warning on stderr" behavior description inSKILL.mdwas also updated to the JSON (systemMessage/additionalContext) contract introduced by #202. -
validate-ai-firststill enforced nothing useful in the VS Code Claude Code extension after it was wired intohooks.json(follow-up to #171, found by @born-in-autumn). Stacked gaps: (1) the extension writes withtool_name=create_fileandtool_input.filePath(camelCase) while the stock matcher only listedWrite|Edit|MultiEdit|NotebookEditand the script only readfile_path, so the hook exited 0 with no warning; (2) after matcher/filePathwere 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, andadditionalContextalone does not surface to the user. Matcher now includescreate_file; path extraction acceptsfilePath; warnings exit 0 with JSON that carriessystemMessage(user-visible per Claude Code hooks docs), plusdecision/reasonandhookSpecificOutput.additionalContextfor the model path (stderr still mirrored for logs). Covered bytests/test_smoke.py::test_validate_hook_accepts_vscode_extension_payloadand a matcher assertion intests/test_plugin_manifest.py. -
/podcastdownloaded a full transcript, could not read it, and quietly summarized the show notes instead._parse_json_transcriptaccepted only the Podcast Index spelling of the per-segment string,segments[].body. Whisper-derived exports spell the same fieldsegments[].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 namedtext. The parser returnedNone, the caller fell through past Whisper (noOPENAI_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, andtranscript-sourcein the saved note recordedshow-notestruthfully. Both spellings are now tried in turn,bodyfirst so existing feeds are untouched. The two stderr messages no longer claimsegments[].bodyis the only supported shape, since that is no longer true. Covered bytests/test_podcast_transcript_schema.py, including the empty-string case (asegments[].textarray of blanks must still fall through rather than return an empty transcript); verified to fail when the fix is reverted. -
/research-deeplabelled a bounded excerpt as "full-page text" and truncated it silently (part of #194, reported by @feariangod).web_reader.read()cuts each extracted page atMAX_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 andcommands/research-deep.mdall stop calling it full text and name the cap. Covered bytests/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 proposedcapture_scope/content_hashraw-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.pykept 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\wis 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 satisfyMIN_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 tovault_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_termsdrops stopwords, so an overlap of "there"/"would"/"which" no longer counts as a meaningful match the way a barelen(t) > 3did. Covered bytests/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-deepsaved an empty synthesis under a real title, and reported success. Phase 4 calledsonar-reasoning-prowithmax_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 incompletion_tokens- so a Phase 4 prompt around 4k tokens (it carries the findings plus any Tavily full-text) came back withcompletion_tokens: 0, no content, andfinish_reason: "stop". A success by every field a caller inspects.lib/perplexity.pythen returned"",research_deep.pywrote it into the note body, and theexceptbranch 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 hitfinish_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.pynow 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 bytests/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-checkblueprint invokeduv 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.shrewrote theSKILL_ROOTplaceholder 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_healthgivesNo module named 'scripts',uv run --directory "." scripts/vault_health.pygivesFailed 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.$HOMEand 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.pyhad to learn that a citation rooted at an absolute install path cannot be checked against a tree indist/, so it verifies the tail fromreferences/onward. Covered intests/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 asedpatch 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 throughreferences/folder-map.md; the four blueprints are hand-written insideadapters/hermes/adapter.shrather than derived fromcommands/, which is how they missed it. On an Obsidian-style vault (Knowledge/,Ideas/,People/) the nightly pass therefore opened with three guaranteed tool failures onwiki/entities,wiki/conceptsandwiki/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.mdfirst, 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 fromSKILL.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, whichSKILL.mdhad 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 theLogs/YYYY-MM-DD.mdbranch. 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/scheduleroute was affected and is covered by the same change. Covered intests/test_smoke.py::test_hermes_build_generates_native_skills, asserting on the bare imperative (Scan \wiki/entities/`) rather than on the path, since namingwiki/entities/` as the wiki-style default beside its Obsidian-style alias is correct and expected. -
Every MCP install broke at once when the
mcpSDK shipped 2.0.0 (#183, reported by @jackiepan99). The plugin manifest launched the server withuv run --with mcp, which resolves to whatever is newest on PyPI at launch time - so the release that restructured the SDK and droppedmcp.server.fastmcp(FastMCPnow lives atmcp.server.mcpserver.MCPServer) turnedserver.py's import intoModuleNotFoundErroron every machine, without anything in this repo changing. The failure surfaced in Claude Code only asFailed 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 tomcp<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.pyandlive_test.pydocstrings. 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 bytests/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 areproactive-/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 inagent-skillsonly. The report namedhermes; the real scope is every adapter that writes its own frontmatter, socodex-cliandpihad the same gap and lost the signal on all 7.claude-code,gemini-cliandopencodecopy 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_policyinadapters/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 bytests/test_dispatcher_prose.py::test_generated_descriptions_carry_the_trigger_policy, which reads the declared mode out ofcommands/and asserts the matching wording reaches every generated build; verified to fail when the fix is reverted. -
validate-ai-first.shwas 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 inhooks/since it was written, its own header says it "fires as a Claude Code PostToolUse hook after Write/Edit", andCLAUDE.mdcites it as what enforces the substitution-Unicode ban on vault writes.hooks/hooks.jsondeclared onlySessionStartandPostCompact. Nothing ran it. It also had a second silent no-op behind the first: vault resolution readOBSIDIAN_VAULT_PATHfrom the environment only andexit 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/.envand 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 asPostToolUseonWrite|Edit|MultiEdit|NotebookEdit, with the documented.envfallback (environment still wins,OBSIDIAN_ENV_FILEoverrides). Covered bytests/test_plugin_manifest.py::test_plugin_hooks_reference_shipped_executable_scripts, which now asserts the event set includesPostToolUseand 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.mdand 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.pycould 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-skillswas already immune, since it embeds the full spec in everySKILL.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 soagent-skillscalls the one definition inadapters/lib.shinstead of carrying a seventh copy. Covered bytests/test_smoke.py::test_relative_reference_citations_are_not_silent(sweeps all 273 pointer-only files across every build) and an extended assertion intests/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.mdis 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.pynow fails when an eval doc backticks a.mdfilename 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-healthreports coverage as asemantic_indexfinding, reading note keys out of the (66MB) index as a stream rather than parsing every float; and the README no longer says "once". Covered bytests/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.pynow 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-visualizeresolves accented titles exactly as/obsidian-healthdoes instead of showing the phantom orphan the health check stopped showing. Covered bytests/test_vault_health_unicode.py(by @kontaktgift) andtests/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 anythinglen <= 2- a filter calibrated for English noise words (a,is,of). But Python's\wis 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 Englishlen > 2+ stopword rule for Latin tokens, so 系統 is findable and a reordered phrase still overlaps. Latin ranking behavior is unchanged. Covered bytests/test_smoke.py::test_mcp_vault_ops_search_finds_cjk_words. -
MCP server ignored
OBSIDIAN_VAULT_PATHfrom the config.env(#160, reported by @SylvesterTee).architecture.mdpromises the vault path is read from "the environment or~/.config/obsidian-second-brain/.env", butresolve_vault()checked onlyos.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_KEYignored 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 underuv run --with mcpwithout python-dotenv; path overridable viaOBSIDIAN_ENV_FILE), with the environment still taking precedence. Covered bytests/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 inreferences/taxonomy-format.md).scripts/vault_health.pygainsload_taxonomy(parses the file, empty dict if it does not exist) andcheck_taxonomy, which reports two disjoint findings:tag_synonym(warning - a note's tag is a known synonym, fold it to the canonical form) andtag_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.pystays 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 byvault_health.py's note scan, since it holds tool config, not AI-first vault content. Covered bytests/test_taxonomy_audit.py. -
Simplified Chinese trigger phrases for all 46 commands. Each command now carries natural
triggers_zhrequests 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-reindexturns 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 Claudepreamble, missingai-first: true, and cited sources with noas ofdate - 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 insrc/lint.tsas pure functions with no Obsidian import, tested with plain strings vianode:testand 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 likeREADME.mdare 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.mdopens 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.shreads it and appends the owner's handle to that platform's generatedINSTALL.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 bytests/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 aCLAUDE.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.pyfails 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.pynow generates a deterministic 300-note synthetic vault (fixed seed, a--manifesthash to confirm two people have the same corpus,synthetic: trueon 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.mdpublishes the baseline, the methodology, how to report a result, and four known limitations. Covered bytests/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, whilecommands/held 45 files each carrying a one-linedescription, acategory, andtriggers_en/triggers_es/triggers_ptarrays - 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.pynow generates an index plus one page per command intodocs/: 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--checkfails CI when the committed tree falls behind the generator, so a page cannot end up contradicting the command it documents. Covered bytests/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=1and byCI, never printed on a--jsonpath, 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 bytests/test_star_prompt.py, which tests both directions - every suppression path, and that it still fires. The first implementation gated onstdout.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.pyreports 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.--gapadditionally 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-pathsis passed, so a run against a private vault is safe to share. Covered bytests/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 arelations: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.pyparses the block (inline-list and block-list forms, plus the legacy top-levelsupersedes:scalar as an equivalent alias) and exposes it as atyped_edgesoverlay in its JSON - kept separate from degree, since the underlying frontmatter link is already counted, so orphan/hub math is unchanged. A new--lintmode 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-healthgains a typed-edge lint agent that folds these into its severity groups;/obsidian-visualizelabels canvas edges with their relation type and summarizes the overlay (counts by type, longest reasoning chains). Documented as Rule 6 § Typed edges inreferences/ai-first-rules.md(with an ADR-schema cross-reference). Covered bytests/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.