Skip to content

Latest commit

 

History

History
1118 lines (817 loc) · 228 KB

File metadata and controls

1118 lines (817 loc) · 228 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • Markdown embedded-image analysis (Issue #608). Opt-in local vault image embeds (![[...]] and ![...](...)) are resolved through Obsidian and processed in 20 MiB visual-evidence packages, so a source has no image-count limit. Each image remains capped at 10 MiB; GIFs send a static first frame; remote URLs are never downloaded. The visual request includes each image's nearest Markdown paragraphs. An additional default-off setting saves per-image context, evidence, and skip reasons in a replaceable collapsible source-page audit section.

[1.27.2] - 2026-09-15

39 merge commits since v1.27.1 (2026-09-06 → 09-15, 128 files, +4243/−1802 LOC, 3993 → 4144 tests). PATCH — a rewrite cut off at the token limit no longer overwrites a page (#704), one shape for updated_pages so link repointing sees every page (#713), provenance footnote brackets repaired (#702), corporate-gateway structured-output demotion (#711), strict structured-output as a negotiated tier (#658), ingest lifecycle released on skip (#688), cancel reaches the running model call (#646), plus the wave-C correctness wave (#644-#684) and a main-is-red regression fixed at the source (#722).

Fixed

  • A body rewrite cut off at the token limit is no longer adopted — and the guard now returns the page path, not a boolean (Issue #704, PR #705; regression fixed in PR #722). A rewrite that stops because the model hit max_tokens was written over the page as if complete: measured on a rebuilt vault, 13 body rewrites grew past 8 KB, 10 of them within 4 minutes of a "finish_reason": "length", one page going 32 KB → 127 KB with 300–760 provenance footnotes and 2463 of 2703 prose links dead. The truncation is knowable after the fact, so it is checked there: new captureFinish() in llm-sdk/finish-reason.ts returns an onFinish sink plus a truncated getter (only 'length' counts; a client that reports nothing leaves the reason 'unknown', so legacy and mock clients keep their exact behaviour). The guard sits at the three call sites that write over an existing body — merge-body and reviewed-append in page-factory/merge-page.ts, related-page in page-factory/related-page.ts — and on truncation keeps the frontmatter write, restores the existing body, logs a warning, and adopts nothing. PR #722 fixes a regression the two halves introduced: #705 wrote return true while the function still returned boolean, and #714 changed that return type to string | null; each passed Gate 1 on its own branch, and merged together they left main red for five consecutive commits (error TS2322). The runtime half is the worse one — updatedPath became true, so updated_pages gained a boolean that the p.endsWith('.md') filter drops silently, which is the #713 shape reappearing on the path #705 had just repaired. return page.path is the correct value rather than merely the correct type: the guard runs after createOrUpdateFile, so the frontmatter landed and the page genuinely is updated.
  • analysis.updated_pages now carries one shape, so the run-end link repoint sees every page (Issue #713, PR #714). Two producers disagreed: wiki-engine.ts pushed vault paths for created and updated pages, but the related-page branch pushed the bare task.name. repointLinksAfterRun filters p.endsWith('.md'), so every bare name was skipped in silence — the pass documented as reading "every page the run created or updated" was reading a subset. Fixed at the fill site, not at the readers: new recordUpdatedPage() in engine-internals/dedup-pages.ts de-duplicates on insert, which also keeps the cancelled and failed paths correct (they hand updated_pages straight to onDone without passing a reader, so a reader-side dedup would have left the count too high exactly where it is hardest to notice). Second fix in the same change: log-writer.ts gained pageLinks(), and updated pages now strip the wikiFolder prefix like created pages always did — [[wiki/concepts/X.md]] does not resolve where [[concepts/X.md]] does, so updated pages were rendering as dead links in the ingest log.
  • Provenance footnote brackets are repaired on the single write gate (PR #702). A page's inline provenance marker (^[<label>: [[Name]]]) is what paragraph-provenance.ts reads to decide which paragraph a rewrite may drop, and the model gets the brackets wrong often enough to matter: measured over a rebuilt vault about a third of markers came back with two closing brackets instead of three, and one in five with the opening bracket doubled (155 of 791 on one run). Every malformed form is invisible to the guard, so the paragraph it belongs to loses its owner and becomes droppable — and Obsidian reads the doubled form as a link to a page named <label>: [[Name, one ghost node per source. New core/provenance-marker.ts (normalizeProvenanceMarkers) repairs the four written forms (^[, ^[[, [^, and the caret lost with the nested [[ or a trailing ^) to the canonical shape, called from wiki-engine.ts beside normalizeHeadingSpacing inside the content-folder write gate. The semantic half — which paragraph, which source — is the model's judgement and is left alone; only the syntax is repaired, the label is carried through verbatim, and the function is idempotent (returning the same reference when the content holds no [[, so callers detect a no-op cheaply). False positives are guarded deliberately: NESTED requires the nested [[ and TRAILING_CARET requires the trailing caret, which is what keeps an ordinary wikilink whose name contains a colon ([[Study: Berberin 2021]]) out of the rewrite.
  • Corporate-gateway response_format rejections now engage the demotion chain (Issue #711, PR #712). A gateway that rejects the whole response_format envelope without naming json_schema — "This response_format type is unavailable now" — matched no classifier, so the rejection surfaced raw and the provider was unusable behind such a proxy. OutputModeProber.isJsonSchemaFieldError now recognises that phrasing while leaving the #658 strict-dialect body ("Invalid schema for response_format …") to isStrictSchemaRejection, which owns it; negative cases ("This feature is unavailable now", "max_tokens is invalid") keep the match from widening into a generic 400 catch-all.
  • Strict structured output is a negotiated tier at the schema boundary, not a per-provider guess (Issue #658, PR #686). New llm-sdk/strict-schema.ts with LLMClient gaining an outputMode of 'json_schema' | 'json_schema_strict' | 'json_object' | 'text_prompt', so a provider that rejects the strict dialect demotes one tier instead of failing the call.
  • Delete Empty Stubs collects the stubs it is named for, and leaves the gate stubs alone (Issue #678, PR #691). Two writers set stub: true: fix-dead-link.ts writes a bare placeholder, while stub-page.ts's buildDissentStubContent writes a placeholder plus a summary, a quote block, or a fifth-gate form carrying neither — a dead-link shape produced on purpose and therefore not empty. The composite predicate is isStubPage(fm) && isEmptyStub(content), where isEmptyStub (one heading, one quoted line, no prose) is deliberately fail-safe: a stub it cannot prove empty is kept. Test names were corrected to match their assertions in the deletion path, and the predicate is mutation-verified — sparing the gate stubs fails exactly the test that guards them.
  • Two contradiction paths only the model could feed are gone (Issues #604, #666, PR #684). updateContradictionStatus only ever passes 'resolved' or 'pending_fix', never 'review_ok', and clampPageSections is reached only from resolveContradiction — the cluster was unreachable end to end.
  • The source page's head comes from the code, not the model's copy (Issues #679, #670, #661, PR #681). Title, note path and date are stamped deterministically; SourceAnalysis.contradictions is removed as a field only the model ever fed.
  • A skipped file releases the ingest lifecycle (Issue #688, PR #690). The status bar stayed stuck after a skip because the teardown path was not reached; extracted as endIngestion().
  • One label table for the ingest log, read by writer and parser alike (Issue #667, PR #685). Three supported languages fell back to English.
  • One-token JSON defects are repaired before spending a model call (Issue #682, PR #683). The structural repair runs first; the JSON-repair model call remains for defects that need reasoning, since disabling its thinking budget produces structurally-valid-but-wrong content.
  • The related-sibling cap holds at three and no longer forms a clique per note (Issue #644, PR #645). Siblings rescue an orphan; they do not become a complete graph.
  • Case is folded when checking the page's own aliases (Issue #674, PR #675), the create path's alias floor applies on the source path too (Issue #671), a two-character name is created rather than sent to semantic dedup (Issue #661, PR #663), the Related sections render after a complementary append (Issue #659, PR #660), model-written sources: entries are dropped before the true one is stamped (Issue #650, PR #651), a bare sources: line no longer emits a stray [[]] (Issue #648, PR #649), and a cancel reaches the running model call and stops at the next page write (Issue #646, PR #647).
  • Frontmatter seams no longer grow a blank line on every call (PR #649). frontmatter.ts normalised the seam by pasting fmBlock and the body with one \n; since fmBlock already ends on --- and the slice begins with whatever followed the old delimiter, each call added a blank line. The seam is now normalised to the one-blank-line shape the constraint enforcer already produces, which also makes the function idempotent.
  • The schema-suggestion wording reads as a proposal, and the schema diff modal no longer crashes on first render (Issues #594, #593, PRs #655, #654).

CI

  • The Five-Gate now runs on pushes to main, not only on PRs (PR #698). Merged commits were previously unverified; the push trigger is what surfaced the #722 regression, whose five failing runs had no PR page to show a red mark on.
  • package-lock.json is gated against package.json (PR #693), and the lockfile is regenerated for the vitest 5 bump (PR #692). Dependency bumps: actions/checkout 4 → 7 (#710), pnpm/action-setup 4 → 6 (#709), actions/attest-build-provenance 1 → 4 (#639), actions/setup-node 4 → 7 (#638), vitest 4.1.10 → 5.0.0 (#652), yaml 2.8.3 → 2.9.0 (#642).

Docs

  • Where a document is processed, per PDF path (Issue #657, PR #694). Both the English and Chinese guides, plus the changelog line, now state that residency is US by default for Anthropic, OpenAI and Google, the configured region for Bedrock, and that OpenAI offers EU residency — replacing an earlier claim that all four are US-only. README.md's cross-file link is absolute, as the #375 guard requires.
  • Four maintainer-lesson entries (PRs #689, #695, #696, #700). ROADMAP wave E and the PATCH ROI board; a close keyword in prose closes a PR, not just an issue; a declined PR is closed rather than left open; and AGENTS.md corrected require_last_push_approval to false.

[1.27.1] - 2026-09-06

46 merge commits since v1.27.0 (2026-08-27 → 09-06, 194 files, +10487/−3257 LOC, 3677 → 3993 tests). PATCH — deterministic related lists (#636), sourced-paragraph rewrite guard (#631), vault-wide folder-link repoint (#626), stream-path thinking policy (#629), local-calendar date stamps (#612), two-gate contradiction records (#610), stop-word/lex-PPR query fix (#625), 3-phase repo audit cleanup (#632/#633/#634), plus the wave-A/B correctness wave (#557-#622).

  • Related lists are now deterministic: siblings link each other, a name the vault answers keeps its own title, and the two Related sections are rendered from the lists, not transcribed by the model (Issue #635, PR #636). The extraction named anything from the note in related_entities/related_concepts with nothing downstream checking whether a page exists or will — measured on a 413-note German vault rebuild: 676 dead related entries on 870 pages (24 %), 103 pages with no live outgoing link (101 of which had a sibling born from the same note), and 265 dead links were spelling variants of pages the vault already held (Interleukin 6 for Interleukin-6, H₂O₂ re-encoded). Three deterministic layers, no model call, no new setting: core/related-shaping.ts (shapeRelatedLists — after the candidate gate, before planning) adds sibling edges by kind and re-routes vault-answering names to their own title+kind, drops tag values (prefixed Thema/Therapie, Th.Therapie in any Unicode script, or the bare leaf only the vocabulary knows) against the active tag vocabulary, and keeps names nothing answers as written while counting them in the log; core/related-sections.ts (renderRelatedSections) writes the two sections from the typed lists — vault path when known, planned path otherwise, name as display text, rewrite-kept entries re-resolved in front, one target once across both sections, everything else byte-identical; page-factory/related-links.ts (applyRelatedLinks) collapses the create/merge/related-page call sites to one line each. Headings match with the section-header-canonicalizer tolerance. Measured same vault: 0 pages without a live link (was 103), sibling lists complete 246/246, dead related entries 1 % (was 24 %). Unicode-safe end to end (\p{L} prefix + NFC slugKeys — verified 主题/糖尿病-style tags drop like Thema/Therapie); the one edge (space-containing tag groups like Health Sciences/Neurology falling through to the unanswered-kept path) is the same non-loss behaviour as before, filed as a backlog note not a follow-up commit. Deliberately out of scope (follow-ups): the generation/merge prompts still ask for the two sections (drop them to save output tokens), buildVaultResolver could return {path, title, kind}, the section-scoped half of correctRelatedLinkPrefixes is now redundant. Default behaviour change without a switch, argued and five-line opt-in available.

Fixed

  • A concept page reaches the answer prompt as its Description, not its one-line Definition (Issue #628, PR #630). The merge template writes a ## Description (where the substance accumulates) above the one-sentence ## Definition on every multi-source concept page, but extractSummaryFromPage preferred the Definition for concept pages — measured 183 of 241 dual-section concept pages handing the answer prompt one sentence instead of the substance. Description is now primary for both page types, Definition the fallback; pageType stays in the options for callers (no signature change). The old pinned rule ("concept with both uses Definition") is inverted inside the test with the reason recorded; a single-Definition page still returns it.
  • The per-step thinking policy now applies on the stream path too (Issue #627, PR #629). taskPolicies (e.g. *=default:off) reached both createMessage paths but never createMessageStream — the path the user waits for: measured 2026-09-04, the keyword call sent reasoning_effort: none while the streamed answer sent nothing, so the model thought for 43 of its 55 seconds. createMessageStream now spreads enableThinking from applyTaskPolicy(params.task) after the advanced-settings overlay (same precedence as the other two paths); without a policy the request is byte-identical (pinned by test). This is the root cause behind the "streamed answer thinks" report, not commit b302aab (verified: reasoning never enters onChunk). reasoningEffort stays off the stream — its interface carries no effort field.
  • Stop-word substrings and the lex/PPR merge no longer hide the page the user named (Issue #623, PR #625). Measured on a 3,025-page German vault: "Kann man bei eingeschränkter Nierenfunktion Creatin nehmen?" loaded Kahneman, Karpman, Salbei and Pekannüsse while the page titled "Creatin" was absent. Three mechanisms fixed in ppr-cascade.ts: tokenizeQuery word runs are now \p{L}\p{N}\p{M} (RegExp constructor, the form candidate-gate.ts already uses under the ES6 target — "über" no longer splits into "über"+"ber", NFD combining marks stay inside the word, Cyrillic/Greek/Arabic keep their words, edge punctuation stripped); new exported needleHits requires a word start for space-delimited scripts ("man" ↛ Kahneman, "creatin" ↛ Phosphocreatin — a different substance; Han/kana/Hangul/Thai keep substring semantics); mergeWithPPR replaces max(lexRank, ppr) with ppr + hint × 0.1 × maxPpr (unreached pages hint × minPpr / 2) so PPR ranks and the lex hint only orders within a tenth of the top mass. PageMatch.score scale unchanged (read only in a debug line). One existing assertion changed honestly: ppr-cascade.test.ts toBe('P0')toContain(P0/P1/P2) — the old pin held only through the max() defect. Tail compounds ("insuffizienz" in Niereninsuffizienz) stay with aliases/keyword/graph by design.
  • 314 folder-wrong links re-pointed vault-wide, on the source page, and once more when the run is over (Issue #624, PR #626). The extraction's folder is decided at dedup (#589), so a page written early in a run links to a sibling that does not exist yet — three quarters of the 314 measured folder-wrong links were invisible to the write-time corrector. Two commits: correctRelatedLinkPrefixes now re-points folder-typed links in prose and on source-page sections (title-then-alias against the engine's cached page index; bare [[X]] untouched, sources/ never re-typed, unknown names left alone, case-only differences not rewritten); new Stage 4.5 repointLinksAfterRun reads every page the run created/updated once, resolves folder-typed links against the run's own pages (title from filename, aliases from frontmatter), and writes only changed pages through the single write gate (no model; reviewed: true pages lend titles but are never touched; per-page failure isolation). [[concepts/X#heading]] anchors not resolved (no template emits them); the write-time pass stays (a deferred whole-run correction would move the corrector out of three writers — too large for this fix).
  • A paragraph another source footnoted no longer vanishes from a rewrite (PR #631). Replay over 571 rewrite pairs (720 footnoted paragraphs): 15% came back with less than half their words anywhere on the page — 102 of those 111 dropped by a rewrite a different source triggered; of surviving paragraphs 23% lost their footnote. New deterministic layer preserveSourcedParagraphs in src/core/paragraph-provenance.ts: every paragraph/list item ending in an inline footnote carrying a wikilink is matched into the same section of the rewrite by word overlap (PARAGRAPH_KEEP_OVERLAP 0.5); found-without-footnote → footnote re-attached; not found → restored after the nearest surviving preceding paragraph (unless every footnote on it names the source being merged — own-source exemption, folder/case/NFC-aware). guardBodyRewrite composes the three layers (sections #618, sourced paragraphs, H1 #419); both rewrite paths (merge-page.ts, related-page.ts) call it. sectionIdentityKey extracted in section-header-canonicalizer.ts. 13 tests + replay: 103 rewrites touched, 120 paragraphs restored, 164 footnotes re-attached. Deliberately out of scope: the footnote convention itself (no-op without it), mergeDuplicatePages/resolveContradiction full-body rewrites (separate note), the 0.5 threshold as regulator (a 45%-overlap rewording comes back next to its rewrite — known trade-off).

Refactor

  • Three-phase repo audit cleanup — 8 no-op changes + SDK wrap unification + T1/T3 lossless pass (PRs #632/#633/#634). Phase 1 (#632): stub-page.ts UTC-day site missed by #612 → localDateStamp(); LLMWikiSettings.language + settings onChange re-typed as keyof typeof TEXTS (auto-follows new locales; the hand union was missing ru); LANGUAGE_NAMES gains ru; dead appendGranularityToPrompt (0 callers since v1.16.2) + byte-identical private buildKnownTargets (folded onto the scanners.ts export) removed; three makeCtx bodies → createMergeCtx in __support__/link-vault.ts; flat batch-limits twin folded into the inline suite; graph-cache.test.ts renamed to wiki-engine-graph-warmup.test.ts. Phase 2 (#633): openai-sdk-client drops its private wrapReasoningContent copy (missing </think escape; stale "circular dep risk" comment) onto the canonical markdown.ts encoder, which gains the idempotence guard — all four SDK clients share one wrap contract. Phase 3 (#634): escapeRegExp 4th copy → canonical lint/utils escapeRegex (re-export alias keeps the API); 7 inline src/core + 15 llm-sdk tests relocated into the __tests__ mirrors (zero test loss); 12 dead exports privatized; controller.ts report round-trip (−85L: extractProgReport + manual summary/alias re-assembly were vestiges of the #473-removed LLM prompt) collapsed into one buildLintReport call; zero-runtime-consumer OUTPUT_MODES const deleted. 3932 → 3932 tests across the three phases (relocations are moves). Deliberately deferred as needs-design: makeCtx unification (semantically-customized fixtures), onFixAll runner (i18n plan), indexLabels/logLabels schema, dedup-retry extraction (perf-sensitive).
  • Vault dates are now the local calendar date, not UTC (Issue #611, PR #612). Every YYYY-MM-DD the plugin writes — created:/updated: on generated pages, the ingest log header, contradiction records, schema-file dates, the welcome note — was built from new Date().toISOString(), the UTC calendar date, so east of UTC the first hours of each day were stamped with yesterday (a UTC+8 user loses the first 8 hours of every working day). All 17 write sites now route through one helper, localDateStamp() in src/core/format.ts (local getFullYear/getMonth/getDate). Full-ISO instants (convertedAt, apply-suggestion, source-analyzer) are deliberately untouched — UTC is correct for them. The three tests that computed expected dates with the same UTC expression were updated with the helper; boundary tests pin 00:30 local.
  • Related-page rewrites no longer land on the source page that shares the entity's name (Issue #613, PR #615). updateRelatedPage resolved its target by bare title over the whole wiki folder; when sources/Zytokine and entities/Zytokine coexisted, find returned whichever the vault listed first — measured 573 related-page body rewrites landing on source pages in a 413-note rebuild, 168 of which lost content. The lookup is now scoped to entities/ and concepts/; a name that exists only as a source page returns "not found" (a source page is written by its own note's ingest and nothing else). Twin tests verify the entity is rewritten and the source stays byte-identical.
  • Re-emitting accumulated Mentions no longer caps the block at the fresh-page 500 characters (Issue #614, PR #616). The #267 "non-lossy" union of accumulated mentions was re-injected through the formatter's fresh-page budget, so every rewrite cut a page's Mentions block back to 500 (measured: 180 of 180 audited losses on 180 pages, blocks of 374–2029 chars coming back at or under 500). assembleFinalContent now injects with maxChars: existingLength + DEFAULT_MENTIONS_MAX_CHARS — what the page already holds is the floor, the default budget is what the current source may add. Fresh pages (existing length 0) are unchanged.
  • A rewrite that kept a section's header but collapsed its content is now treated as a dropped section (Issue #617, PR #618). preserveExistingSections (#419) restored sections the rewrite dropped outright, but a section the rewrite kept and emptied passed as "kept" — measured 9 canonical ## Beschreibung sections of ≥800 chars coming back under half their size (34,454 chars lost; worst case concepts/Stress 5046 → 335). A canonical section whose content falls below SECTION_SHRINK_FLOOR (0.5) of its previous length, once it held at least SECTION_SHRINK_MIN_CHARS (400), is now treated as dropped and the previous block is restored in place, with a warning naming the section and both sizes.
  • The candidate gate no longer prunes links to pages the vault already has (Issue #620, PR #621). With skipMentionOnlyCandidates on, dropping a candidate also removed its name from every survivor's related_* list ("no gate ever manufactures a dead link", #514) — but the prune was vault-blind: it never asked whether a page for the name already exists, and a link to an existing page is not a dead link. Measured: 782 of 2612 entity/concept pages with no outgoing link (vs 95 of 2386 pre-gate). The gate now takes an isKnownPage predicate backed by the same title-then-alias resolver the related-link corrector uses (buildVaultResolver, extracted — code moved, not changed); a dropped name that resolves keeps its edge and is reported in linkedAnyway, a genuinely unknown name is pruned as before. Without the predicate, behaviour is unchanged.
  • The ingest log and report now count the merge triage's contradictions too (Issue #605, PR #606). Both counted only analysis.contradictions (the extraction lane); the merge triage records contradictions of its own — item-level kind: 'contradictory' and page-level strategy: 'contradictory' stamp the marker and write the record file — and neither reached the log or report. Measured: one night of 163 ingests logged "no contradictions" while four record files were written by the triage lane. mergePage now reports each recorded contradiction through an optional onContradiction callback (same shape as onFileWrite); the engine collects them for one ingestSource run and builds log entry and report from both lanes. Nothing about what is written changes.

Changed

  • Two gates before a contradiction record — the page sentence must exist, and the source must hold the claim (Issue #609, PR #610). The item-level contradiction lane recorded on the model's word alone; measured 7 of 9 records were false — 3 reported positions the note argues against (a critic's view), 4 conflicts with nothing on the page (the model compared the source with its own knowledge). A prompt rule made the model blind (0/3 on a planted true positive), a stance field inside the triage prompt went unanswered. What works, measured: Gate 1 requires existing_statement in the triage output, checked deterministically against the page (statementOnPage: the #244 quote fold + wiki-link/footnote markup removed + NFC; exact match or 6 consecutive words) — the model quoted an exact page sentence 13/13 when one existed, and could not when the conflict was with its own knowledge. Gate 2 asks the stance question as its own small call (task: 'source-stance', 600 tokens) over the note excerpt + claim — 72/72 across the real, reported, and planted cases. A no acts only when the evidence is a sentence (≥4 words) actually in the excerpt; anything else is unverified and stays. Demotions are never silent: MergeTriageResult.demoted carries gate + evidence, mergePage logs one line per demotion, and the demoted item still lands on the page as a plain fact — only the record file and the contradictions: marker are withheld.
  • Cross-folder dedup routes same-designator pages through the semantic call — the folder never decides (Issue #588, PR #589). The opposite folder was never consulted in either direction; a cross-folder slug/alias hit now routes the resolution through the semantic dedup call with that page in the candidate list (measured: 18 twin pairs in one 83-note batch, every one the same referent). Conservative by construction: no-decision keeps the same-type target (a failure never crosses the folder on its own), and a create-with-cross-claim falls back to its own folder's slug where the miss is visible as a twin. ConflictResolution gains crossFolderCandidates; a direct file probe backstops the page index for pages born moments earlier in the same run.
  • The file picker shows what the vault knows, not what the session remembers (Issue #598, PR #600). Rows were resolved from the session's queue membership, which silently discarded selections made while a batch ran. New core/ingest-state.ts (pure, no IO) answers disk state per candidate — none/ingested/drifted — reusing the same page-ownership code as isAlreadyIngested (pageBelongsToNote); drift (noteHasDrifted) compares the page's stored contentHash against the note body, answered false when undecidable (no stored hash, or more than one recorded origin) rather than guessed. Selection lives in a Set that survives the search filter; a scan token drops results from scans that outlive their modal.
  • Ingest ownership resolves from source_file, not sources: (Issue #595, PR #596). The sources: frontmatter holds only [[sources/X]] links by contract — it can never contain the note's own path — so every multi-source page read as "not ingested" and was re-processed. New originNoteRefs() in core/frontmatter.ts reads the scalar source_file: first and falls back to sources:; three readers (ingest skip, drift scanner, picker) use the same helper. Origin-less pre-#164 pages fall back to the existence check (conservative).
  • Contradiction source_page is resolved against the page index, never trusted from the model (Issue #601, PR #602). The write path did string surgery on the model's source_page (String.replace with no match returning the input unchanged), so a bracket-less value — the plugin's own record-file prose prompt encouraged it — wrote the conflict block into a user note: 3 user notes hit on 07-16/17 and 08-31, violating the "never write user notes" hard rule. New resolveContradictionTarget() resolves model output against the real page index (rel path → title → alias, case-insensitive), refuses ambiguity (>1 match → null), and discards + warns on no resolution (zero writes). The call site passes sourceNotePath explicitly; the record file's frontmatter carries source_note: and the prose block that stripUnknownSections would delete is removed.

Added

  • Fix Dead Links' dev instrument can retarget body links (Issue #590, PR #591). tools/dev-instrument/src/vault-fs.ts gains linkCacheOf (body wiki-links + embeds, parsed on demand, scan starts after the closing frontmatter ---, mirroring frontmatterOf) and getFirstLinkpathDest, so retargetLinksToPage in the instrument is no longer a silent no-op.
  • Co-maintainer credit (PR #619). manifest.json/package.json author becomes "Greener-Dalii, DocTpoint"; README maintainer line lists both with roles; NOTICE entry gains the role.

Docs

  • README Marp URL + LICENSE relative links fixed (PR #622, community first PR by NotAFlightRisk). The Ecosystem bullet pointed at samuele-cozzi/obsidian-marp (404; the plugin repo is obsidian-marp-slides), and the docs/ READMEs linked LICENSE/NOTICE in the wrong direction (they live at the repo root, so ../LICENSE is correct there, plain LICENSE in the root README).
  • 8 fast-uri Dependabot alerts closed at the root — not by another pin (PR #637). The recurring vulnerable-transitive-dep cycle (fast-uri 3.1.5 → 3.1.6 → 3.1.7, four GHSA advisories 2026-09-03) exposed a mechanism defect, not a version lag: a manual overrides pin guaranteed the next advisory would need the same manual pass. fast-uri moves to ^3.1.7 (3.x latest) and its redundant override is dropped (every consumer declares ^3.0.1, which natively permits the fix — verified; the brace-expansion override is kept because legacy minimatch@8/9/3 peers genuinely need pinning against vulnerable ^2/^1 instances). CI now runs pnpm audit --audit-level high per PR so a vulnerable dep fails the build the day it lands; .github/dependabot.yml (new) opens weekly auto-PRs for npm + github-actions, so the fix path no longer depends on a human noticing an alert. DevDependency-only — zero production code touched.

[1.27.0] - 2026-08-27

36 merge commits (181 files, +11197/-3158 LOC, 3434 → 3677 tests). MINOR — Bedrock SSO/IAM, MinerU multi-format ingest, source-page verbatim quotes, Fix Dead Links leave-it, ingest candidate gate, per-step taskPolicies UI, plus a long tail of frontmatter / alias / dedup correctness fixes from the community wave (Issues #467/#468/#469/#485/#491/#496/#501/#506/#507/#510/#511/#513/#514/#515/#516/#517/#518/#519/#520/#521/#522/#523/#525/#527/#528/#530/#531/#532/#534/#535/#536/#537/#538/#540/#542/#544/#545/#546/#547/#549/#550/#551/#552/#553). Closed-by-PR totals include 21 community contributions.

Added

  • AWS Bedrock auth: SSO and IAM modes join API key (Issue #425, PR #540). Settings → Provider → Bedrock now picks one of three auth modes; the provider row asks only for the inputs that mode needs. API-key mode is byte-for-byte identical to v1.26.4 (Stage 1). SSO runs the IAM Identity Center device flow (RegisterClient / StartDeviceAuthorization / polling CreateToken) — every step uses platform-native crypto.subtle and stays platform-neutral. The plugin stores SSO and IAM secrets only in Obsidian SecretStorage (karpathywiki-bedrock-sso / karpathywiki-bedrock-iam), never in data.json, logs, or docs. The data-plane call signs every request with a hand-rolled SigV4 (canonical request → string-to-sign → HMAC-SHA256 chained for the AWS scope), zero AWS SDK, ~+10–15 KB on the bundle. Three isolated constants (BEDROCK_MANTLE_SIGNING_SERVICE='bedrock', the content-sha256 switch, the portal-host bearer scheme) are the only dials a real-AWS E2E would need to flip. Stage-1 fix rides along: the sync factory previously ignored the user's bedrockRegion and fell through to us-east-1 for every call; it now forwards the region every code path reads. CI-side #540 ran with the three constant values unverified against a real AWS account — those constants are the only thing an account-holding user would have to flip. Sign-out clears the SSO/IAM secret and the in-memory cache, same discipline as Codex OAuth.

  • Markdown conversion backend: MinerU (PDF + images + Office) joins native (Issue #404, PR #511 CLI follow-up). Settings → Wiki Configuration → Markdown Conversion Backend picks Native (Anthropic Vision / OpenAI Vision / Bedrock direct) or MinerU. The MinerU backend accepts PDF + images (PNG/JPG/JPEG/JP2/WebP/GIF/BMP) + Office documents (DOC/DOCX/PPT/PPTX/XLS/XLSX) through MinerU's Precise parser — best path for scientific papers, scanned documents, and Office files where layout preservation matters. The API token lives only in SecretStorage (karpathywiki-mineru-api-token); server caps are 200 MB / 200 pages per PDF, 256 MB / 10,000 files per archive. The native backend is still PDF-only by design (provider PDF input surface); multi-format routing under native is not in scope — switch backend to MinerU when needed.

  • Source pages now carry verbatim quotes, routed from data already captured (Issue #496). A measured vault showed the Mentions in Source section on 96% of concept pages but 0 of 1,045 source pages — the page representing the underlying document was the only one with no verbatim path. Three causes fixed together. First, the extraction's quotes existed only per item and were never aggregated onto the analysis, so there was nothing to route; buildSourceAnalysis now collects every entity/concept's provenance-or-legacy quotes into a deduplicated pool. Second, createSummaryPage injects that pool through the same programmatic route entity pages use; on a source page the quotes are the payload, so its section budget is raised to 2000 chars (the default 500 would ellipsize exactly what this route preserves). The summary model still sees only the first 500 characters of the source, so its prose cannot fabricate beyond them, and when nothing was captured any Mentions section the model wrote itself is stripped rather than kept. One [MENTIONS-CAPTURE] debug line per ingest reports how many verbatim quotes were captured across how many extracted items, making "the model skipped the field" visible without making it required (a hard-fail risk on weak models). Third, the issue's quote-grounding finding verified true and now load-bearing: lint built its grounding map from generated sources/ pages only, so quotes cited to PRIMARY notes (#244 style) were checked against nothing and flagged ungrounded — which after this change would have hit every fresh capture on re-lint. The programmatic lint phase now reads each cited primary note once and grounds against the underlying document (unreadable notes keep their flags), and passes the localized Mentions label so non-English wikis stop silently no-oping the check. Also: the Minimal extraction-granularity description now states that it caps items per source, not detail per item, in all 11 locales. Refs #496.

  • Fix Dead Links gains a leave-it outcome: stub creation is now optional (Issue #485). An unresolvable link always became an empty placeholder page — in both branches (the model's create_stub answer and the deterministic fallback), with no way to decline — and since the #484 title-resolution index such a page absorbs same-name references vault-wide while erasing the dead-link signal that produced it. createStubsForUnresolvableLinks (default ON, so every existing vault keeps its current outcome; toggle in Settings → Advanced settings) routes both creation sites through a single greppable sibling of #197's never-LLM-expand gate — the two gates answer different questions ("may an LLM fill the stub?" vs "is the stub page written at all?"). When off, the link stays as it is and keeps surfacing in every lint report until a real source defines it; ingest creates pages through normal channels and never needed the pre-existing stub. Correction outranks the gate either way: the deterministic pre-check and the post-model alias safety net keep retargeting links regardless of the setting. Also in this change: the per-step policy control's description now names common step labels (extract, merge-triage, dedup, …) — previously a user had to know internal pipeline label strings to configure it at all, and a misspelled label silently matched nothing. Closes #485.

  • The Query Wiki stream path now lands in the per-step LLM accounting table under its own label (Issue #469). createMessageStream predated per-step accounting and carried no task field, so every streamed Query answer — a known step — recorded under the single merged untagged row, indistinguishable from genuinely unlabelled calls, and any future streaming call site was type-forbidden from passing a label. The interface now carries task? (same contract as createMessage), the wrapper's stream block drops its hardcoded undefined in favour of the caller's label, and the only stream caller (QueryView-class.ts) passes task: 'query-wiki', distinct from the separate query-view-evaluate save-judgement call. SDK clients forward the field for interface conformance (the wrapper owns accounting, not the client). Unlabelled stream calls still record as untagged — no fabricated suffix. Closes #469.

  • Ingest: opt-in candidate gate skips candidates the source only mentions (Issue #514, PR #521 DocTpoint). On a measured German vault 28.7% of extracted candidates were named but never treated by their source (9.6% name absent from the body, 19.1% present only inside parentheses/enumerations/short list items) — each still cost a page plus dedup and generation calls. skipMentionOnlyCandidates (default off, Settings → Advanced) runs a deterministic gate between analysis and page planning: prose candidates keep their pages, and gated names are pruned from other candidates' related_* lists so the gate never manufactures a dead link. Keyed on wikiLanguage: de is measured; en/fr/es/pt/nl/ko are estimated with pinned edge cases; zh/ja character-script thresholds are unmeasured (first thing to measure on a Chinese vault); stem-changing languages deliberately get no profile rather than a bad guess. Cross-language notes are not gated (their names are translations). A wiki language without a profile is reported once per ingest, never silently skipped. Off by default: fewer pages is a behaviour change, so it is the user's choice. Closes #514.

Changed

  • Prompts: one ranked candidate window for the dedup and Fix Dead Links prompts (Issue #519, PR #520 DocTpoint). Both prompts showed the model an "existing pages" list built ad hoc: semantic dedup fell back to the FULL same-type list whenever the candidate's name shared no title token (~40K prompt tokens per call, firing for 61% of entity candidates on a 2,800-page vault), and Fix Dead Links took every page in vault order cut at 3,000 rendered characters. New core/candidate-window.ts ranks K=30 pages by the existing lexical matcher plus one point per context keyword found in the page's own prose (a document-frequency cap replaces a language-specific stop list; pool order among equals preserves the ctime ordering the KV-prefix cache relies on). Measured over 3,309 hidden-alias trials: target-in-window 25% → 43% (entities) / 24.5% → 41% (concepts). The decisive cell: at a local 26B the model found a synonym's target 0 of 18 times when it sat in the full list, and 9 of 9 in a 30-entry window that contained it — the fallback protected nominal recall at real cost. Gate 4 note: getExistingWikiPages now retains ~2KB of page text (~5.6MB peak at 2.8K pages), cheaper than re-reading files to rank. Closes #519.

  • Tools: the in-tree CLI is replaced by tools/dev-instrument/, an UPSTREAM DEV-ONLY INSTRUMENT (Issue #507, PR #511). The production CLI has been the sibling repo (npx karpathywiki-cli) since the v1.26.x migration; what only the in-tree copy could do — run the real WikiEngine headless against real LLM spend — survives as a measurement instrument for engine contributors (tools/dev-instrument/run-instrument.mjs <vault> <source>), with per-step token/latency accounting preserved (the 979s → 365s → 151s evidence chain) and environment-driven measurement arms (WIKI_THINKING_MODE / WIKI_TEMP / WIKI_TOP_P, fail-fast validated, echoed in the [cli] header next to the effective data.json task-policy map). Eliminates 49 of ~52 Obsidian Bot findings on tools/. package.json drops bin.llm-wiki + scripts.llm-wiki; users mid-transition can check out the frozen legacy/cli-v1.26.4-snapshot branch. Deprecation notice ships in the v1.27.0 release notes. A bundle smoke test in Gate 1 pins the esbuild obsidian→shim alias so a missing shim export can never ship green again. Closes #507.

  • Dev instrument: the process exit code follows the ingest report (Issue #417 secondary, split out of PR #418 as its own decision). tools/dev-instrument/run-instrument.mjs exits 0 when the engine's report says success: true, 1 when it says success: false, when no report was emitted, or when the run throws before the engine, and 2 when <vault> <source> is missing (usage now goes to stderr, nothing to stdout). Before, the code was whatever the last throw decided — a failed ingest exited 1 only because ingestSource rethrows after reporting, a run that ended without a report exited 0, and a run without arguments printed usage on stdout and exited 0. exitCodeForReport (tools/dev-instrument/src/exit-code.ts) is the single seam; a process-level test drives the real launcher and pins no positionals → 2 and a report with success false1 against a vault whose local provider points at a closed port.

Fixed

  • Complementary appends no longer leak the model's visible reasoning into wiki bodies (E2E 2026-08-25). The per-section append — the path that splices new facts into an existing page's section on re-ingest — was the one raw-prose writer without thinking-block stripping: every other LLM-text consumer goes through cleanMarkdownResponse or parseJsonResponse, both of which strip <think> blocks, but this one only trimmed. A reasoning model emitting visible reasoning in its content channel had its full planning prose appended verbatim into the target section, and a think-wrapped NO_NEW_CONTENT was spliced as if it were content instead of short-circuiting. The strip now runs at the fragment via a single exported home in core/markdown (shared with cleanMarkdownResponse, which now calls it internally), and the append call pins enableThinking: false unconditionally — appending a formatted fragment is mechanical text work; per-call thinking only added latency and was the leak's source. Found during v1.27.0 E2E on a page-update flow with a visible-thinking model.

  • Five default-schema sections now reach the tasks that need them (Issue #491). schema/config.md writes eleven sections and promises "edit it freely", but the per-task delivery whitelist named only six — Source Page Template, Date Fields, Mentions Format, Content Rules, and Multi-Source Merge Rules reached just the two full callers (contradiction resolution, fill-empty-page) and never ingest, generation, or merge. A user writing merge policy into the section named Multi-Source Merge Rules was editing the one task that would not see it, and the entity template's "see Mentions Format" pointer dangled. Ruled an oversight (consistent with the #328 Phase 1 contract that schema edits must take effect) after a full call-site audit; the map now delivers Content Rules to analyze/entity/concept, Mentions Format to entity/concept/summary, Source Page Template to summary (audited as the task whose call writes sources/<slug>.md), and Multi-Source Merge Rules + Date Fields to merge. Measured prompt growth from the default body: +73 tokens on analyze, +193 on entity/concept, +336 on summary, +204 on merge — under 10% everywhere against ≥5K-token prompts. The unchanged tasks are pinned by a snapshot matrix so they cannot silently grow. Closes #491.

  • Reasoning-model empty responses: the typed path now returns what it already recovered, and Welcome translation stops inviting the failure (Issue #506). On reasoning-capable openai-compatible backends the model can spend its whole output budget in the reasoning channel and leave content empty; when nothing parseable results the AI SDK throws NoOutputGeneratedError, and the typed-output client returned { text: '' } unconditionally — even when it had just composed the reasoning-channel payload into a local variable for exactly this purpose. The catch now returns that composite so caller-side parseJsonResponse can recover the embedded JSON (Layer 3), keeping the quiet path only for the origin where generateText rejected outright (no result exists in scope, and the error class carries no .response to mine — which is why the recovery reads the in-scope composite rather than the wire body). Both quiet-path debug lines gain an actionable hint naming the per-step policy escape hatch. Root-cause half: welcome-translate now pins enableThinking: false — translation is a text task, and reasoning budget spent there was the first-run onboarding failure's cause; a full-pipeline test drives the request through a real client and asserts reasoning_effort: "none" reaches the body. Closes #506.

  • Follow-ups from the PR #525 review: the text-mode pin now reaches the Codex provider, a source-borne repetition no longer burns the retry, and a duplicated policy entry is an error. Six items the review filed as non-blocking, fixed together because they are all consequences of the same PR. (1) openai-codex-sdk-client.ts never read outputModeOverride, so the built-in extracttext_prompt pin was honoured by every provider except this one, which kept Output.json() — the SDK's structured-output path, which also routes the reply around the plugin's own JSON repair. It now drops the format when the mode is pinned and carries the JSON instruction in the system prompt instead; forcedTextPromptSystem moved from openai-compat-sdk-client.ts to json-prompt-prefix.ts so both clients apply the identical rule rather than one of them having a private copy. (2) A note that repeats a phrase itself — a refrain, a quoted chorus, a tabulated column — produces a faithful echo the loop detector cannot tell from degeneracy; halving changes how many items are asked for and never the note, so the retry was spent on a certainty and the same batch merged afterwards anyway. isSourceBorneLoop checks the note before the retry is spent. It requires the note to repeat the unit at least four times, not merely to contain it once: skipping the retry on a genuinely damaged batch costs items silently while spending it on an echo costs one call, so the test has to be the conservative one — Vitamin D, occurs in half a vault and would otherwise suppress the retry for every real loop built from it. This also needed RepetitionLoop.rawUnit: unit is shortened to 40 characters for log lines, so a lookup using it would silently never match a longer refrain. (3) The exhaustion arm — halve budget spent, damaged batch parsed and merged rather than dropped — had no coverage; one test now pins it. (4) The taskPolicies placeholder was hardcoded English in an otherwise localized control; it is a text key in all 11 locales. (5) parseTaskPolicySpec accepted a task named twice and let the last entry win silently, which is the manifest lie the parser refuses everywhere else — it now throws. The task NAME stays unvalidated on purpose: every label is a string literal at its own call site, so a list in the parser would be a second copy that drifts, and a stale list rejecting a policy for a step that does exist is worse than the typo it would catch; the behaviour (an unknown name is inert) is now documented where the parser explains itself. 13 regression tests.

    Upgrade note. Switching LLM Advanced back to Default clears taskPolicies along with the other fields in that block — the control is rendered only inside it, so leaving a value behind would strip its only affordance. If you hand-edited a policy string, copy it before switching modes.

  • Constraints pass turned a block-form unknown field into a YAML null (Issue #522, PR #523 DocTpoint). enforceFrontmatterConstraints collected its passthrough lines inside its own line walk, skipping every - item — a block-form list under a user-owned key came back as its header alone. It now takes its lines from the shared extractPassthroughLines, same semantics as every sibling writer. One disclosed side difference: a literal reviewed: false line is omitted rather than carried verbatim, matching mergeFrontmatter's long-standing behaviour. With #513 this closes the #356 parity chain end to end. Closes #522.

  • Duplicate merge dropped every user-owned frontmatter field of the surviving page (Issue #512, PR #513 DocTpoint). mergeDuplicatePages was the one frontmatter writer whose serializeFrontmatter call carried no passthroughLines, so a lint duplicate merge silently stripped fields like redirect_to: or parent_org: from the survivor — the #356 invariant ("re-touching a page never strips fields the plugin does not own") had exactly one writer missing, and it is the path a user is least likely to re-check afterwards. Same helper, same semantics as mergeFrontmatter; the absorbed page's unknown fields stay out on purpose (it is deleted, and merging two differently-authored metadata sets is not this path's decision). Red/green test plus byte-identical control. Closes #512.

  • Merge stored whichever source's type arrived first (Issue #509, PR #510 DocTpoint). mergeFrontmatter unioned sources: two lines above but had no parameter that could carry the incoming source's extracted type — on a measured vault 34.3% of multi-source pages had a classification computed and dropped at every merge, and the stored tag was a function of arrival order. An optional third parameter unions the incoming term (first occurrence wins, idempotent, order-invariant by test); incomingTypeTag withholds types the active vocabulary does not admit, so custom-vocabulary mode behaves exactly as before. Decision recorded on the PR: union kept over first-wins — order-invariance outweighs single-term purity, and the retag pass remains the normalizer of last resort. Closes #509.

  • Test fixture documented OpenRouter's Anthropic baseURL as a path that 404s (Issue #515, PR #516 anavalo). https://openrouter.ai/api/v1/anthropic in the pass-through table was invisible to CI because the assertion only checks pass-through. Verified live through the repo's own @ai-sdk/anthropic with a tracing fetch: /api/v1/anthropic/messages → 404; the /api value from OpenRouter's docs (for clients appending /v1/messages themselves) → HTML site page; /api/v1 → 200 valid Anthropic-format body, which is also what url-fallback.ts:buildModelsPaths already assumes. Test-only; PREDEFINED_PROVIDERS.openrouter was already correct. Fixes #515.

  • OpenRouter model variants containing : are visible in Fetch Models (Issue #534). The provider-specific model filter treated : as a disallowed separator for OpenRouter, hiding every :free and :batch entry returned by its catalog. OpenRouter now keeps both / and : model IDs while the existing Ollama and LM Studio separator rules stay unchanged. A three-provider regression matrix covers the boundary.

  • Create path persisted an alias equal to the page's own filename (Issue #536). The model routinely lists the page name among the aliases it writes; appendAliases refuses exactly that via filterRedundantAliases, but enforceFrontmatterConstraints — the other writer of aliases: on createNewPage, fillEmptyPage and mergeDuplicatePages — did not know the page path and kept it (20 % of the pages in a 2.4 K-page build, 8 of 11 under 1.26.x). The function now takes an optional pagePath and applies the same gate; callers without a path are unchanged. Space/hyphen variants stay, as before. 5 regression tests.

  • Test Connection reported a model that was never selected as Invalid URL: (Issue #517). DEFAULT_SETTINGS.model is '' and testLLMConnection built its probe plan straight from it, so the fresh-install path — pick a provider, paste the key, press the button — put "model": "" on the wire. OpenRouter answers that with HTTP 502 {"error":{"message":"Invalid URL: "}}, 502 is retryable, so the AI SDK spent three attempts and the catch reported Failed after 3 attempts. Last error: Invalid URL: verbatim: a message that points at the Base URL, which was correct, and never mentions the model. isUrlError only rescues 404, so nothing intercepted it on the way out. A blank or whitespace-only probe model now short-circuits before the network with a new errorNoModel text (all 11 locales), in the same shape as the existing errorNoApiKey / codexAuthRequired early returns. Per-task mode is covered by the same guard: a task whose model resolves to '' aborts before any probe is issued, not after the valid ones. 5 regression tests. Closes #517.

  • Custom tag vocabulary: extracted types outside the vocabulary are resolved at intake, not born as lint violations (Issue #527). The extraction prompt lists the active vocabulary and the wire schema enforces nothing (type is z.string()), so under a custom vocabulary about one item in ten arrives with the model's built-in taxonomy (person, theory, method — measured 10.0 % of 10 669 items, plus 1.6 % near-miss spellings) and became tags: [person] on the new page: enforceFrontmatterConstraints kept it with a console.debug, scanTagViolations reported it, and the hand-triggered retag later decided the tag from 400 characters of the page's own prose — the source's summary never reached that decision. SourceAnalyzer.repairTypesAgainstVocabulary now runs after accumulation: a deterministic fold onto the vocabulary (case, diacritics; foldToVocabulary in core/tag-vocab.ts), then for what the fold cannot place one short type-repair call with the item's own summary and the allowed terms (TypeRepairLLMSchema, TOKENS_TYPE_REPAIR). Every doubt — parse failure, answer outside the vocabulary, call error — keeps the extracted value, i.e. the previous behaviour; the repairs are logged under [Type repair]. Under the default vocabulary the step is a no-op for every in-vocabulary item and repairs only free-form types. Tests: fold (5), schema (2), analyzer wiring (fold / model answer / unusable answer keeps the value / default vocabulary makes no call).

  • Extraction runs in text mode again by default, and a damaged batch is no longer accepted as a short one (Issue #524). Since 266d641 (#443, v1.26.3) the extract call carried response_format: json_schema. Replaying the real pipeline request on LM Studio / gemma-4-26b-a4b-qat, the extraction under that schema degraded in 3 of 3 draws — twice silently, as schema-valid JSON with 4–5 items where text mode returned 17–30 — and the server logs of one vault show 14 repetition loops in 125 schema-mode extraction calls against 9 in 2,368 text-mode ones. Nothing in the plugin could see the silent form: checkEmptyBatch stops on zero items, halving fires on length, repair on unparsable JSON. Three changes: (1) BUILTIN_TASK_POLICIES in core/task-policy.ts pins extract / extract-retry to text_prompt below any user policy — the wire shape every user had before 1.26.3 for the one long-output step; the short judgement calls keep the prober's default and a user entry or * wildcard still wins. (2) core/repetition-loop.ts detects a unit of 3–120 characters repeated at least four times in a row over ≥ 200 characters (calibrated on 2,319 clean text-mode responses: zero flagged); source-analyzer.ts treats a flagged batch like truncation — halve and retry, else parse what arrived and say so — and logs finish, reasoning_tokens and the first batch's item counts. (3) The taskPolicies field from #490 gets a control in the LLM Advanced section (spec step=mode:thinking, parsed on change, an unreadable spec is reported and not saved) — without it no user could move a step off the baseline. Left out on purpose: item-level required fields in the Zod schemas (the #463 "no scope creep" stance stands) and an item-count plausibility check (a short note gives the same signal as a degraded batch).

  • cacheBreakpoint wire contract is now pinned at the wire, and two names in the v1.26.4 entry corrected (Issue #493 partial). Every existing cacheBreakpoint test asserts the shape handed to generateText through a module-level mock, so it cannot see what the SDK core does with that shape — the distinction that produced the original Issue #449 no-op. anthropic-cache-control-wire.test.ts drives the real @ai-sdk/anthropic adapter through a stub fetch and asserts cache_control in the request body: present on the first of two blocks for a non-zero offset, absent for offset 0 and for no breakpoint, and positioned by UTF-16 code unit (42 'Ü' = 42 units / 84 bytes). buildMessagesWithCacheControl now declines the split when the clamped offset is 0 instead of building a prefix part the SDK core drops — measured as no wire change on the official adapter (the suite passes identically with and without the guard); it removes the empty text block from the shape rather than relying on the adapter to filter it, and gives the condition a documented place. The caller-side half of Issue #493 (markerIdx === -1 truncating the template and zeroing the offset) is untouched and stays with that issue. Two corrections to the v1.26.4 entry below: cacheBreakpoint is a UTF-16 code-unit offset, not a byte offset, and Anthropic's minimum cacheable size is model-dependent (512-4096 tokens, not monotonic across generations) rather than a fixed 1024.

  • Vault root as an ingest or watch target now follows the picker's own exclusion rule (Issues #502 / #505, PR #504). path.startsWith('/') never matched a real vault path, so a / entry in watchedFolders was a silent no-op; choosing the vault root for "Ingest from folder" reinstated wiki/ as source material that the file picker already excludes. isIngestableSource composes the two rules in the same IO-free module as isExcludedFromSourcePicker and isInFolderScope, so all three entry points agree. Upgrade note: a / entry that previously did nothing now watches the whole vault except wiki/. If you set / as a placeholder, remove it or narrow it to the folders you actually want ingested before the next run.

Release chore

  • npm audit HIGH→0 on main (Issue #501). The pnpm.overrides block introduced by the v1.26.x Pin (164b06a) is not visible to npm — npm reads only the top-level overrides key — so package-lock.json (which Obsidian's release.yml installs from) kept three vulnerable transitive copies of brace-expansion (1.1.16, 2.1.2, 5.0.7) across five paths even after the pin was merged. package.json now declares both keys (overrides for npm, pnpm.overrides for pnpm) with the same flat values, and package-lock.json resolves brace-expansion to a single 5.0.9 line. npm audit --audit-level=high reports 0 vulnerabilities; the four affected paths (eslint-plugin-import, eslint-plugin-n, eslint-plugin-react, eslint-plugin-json-schema-validator) drop the HIGH GHSA-mh99-v99m-4gvg and GHSA-rgw5-rvv9-x895 advisories. pnpm side is unchanged. Note for future maintainers: the same flat-form values are intentional — pnpm does not honour the top-level overrides key, and npm does not honour pnpm.overrides. The two fields must stay in sync; the lockfile-regen step in obsidian-plugin-release Step 3b (npm install --legacy-peer-deps --package-lock-only in the project directory) is the one place this is enforced.

[1.26.4] - 2026-08-19

20 merge commits (74 files, +3786/-1816 LOC, 3290 → 3434 tests). Six-axis fix batch: silent-bug cluster, wire-shape accuracy, lint report UX, extraction prompt bloat, contradiction data-loss, per-step measurement scaffolding.

Fixed

  • Settings: per-task model fields wiped on every commit (Issue #456, PR #462). commitTempSettings ran a v1.24.1 PATCH Phase 5.5.0 belt-and-suspenders cascade that fired a commit-time model change event on every commit, overwriting per-task modelOverride/temperature/topP/seed/enableThinking on commitTempSettings. The 3 direct-write sites that bypass setFieldValue (provider change / Bedrock region change / post-test sync) all hit it. Fix removes the cascade entirely; per-task fields are owned by setFieldValue and commitTempSettings is a pure pass-through. 3 unit + 2 integration regression tests pin the contract. Closes #456.

  • Wrapper: createMessageStream dropped every advanced setting (Issue #451, PR #465). wrapWithAdvancedSettings used Object.create(client) to inherit createMessageStream, which sidestepped the settings-injection seam entirely — temperature / top_p / seed / repetitionPenalty / enableThinking were silently dropped on the Query Wiki streaming path (the only user-facing stream surface). Fix mirrors createMessageWithOutput's wrapping shape: hook the stream method, build sampling args + provider options identically, apply at the seam. 4 wire-body regression tests pin injection reaches the call. Closes #451.

  • LLM semantic dedup: parseJsonResponse parse failure read as "no match" (Issue #407 Stage 1, PR #444). path-resolution.ts:220 collapsed {ok: false, reason} from parseJsonResult (the union added in PR #436) into the same match: false branch as a legitimate negative answer. Fix routes {ok: false} through a new parse-failure log + the slug-path fallback (named failure rather than silent answer); the match: false branch is reached only when the reply parsed. Counter-test pins the new branch: a well-formed {match: false} is not reported as a parse failure. 2996 → 3000 tests. Stage 2 of #407 follows in v1.27.0.

  • Output schemas: extraction wire schema had no top-level required array (Issue #463, PR #476). SourceAnalysisLLMSchema carried .passthrough() AND marked every top-level field .optional(), so the wire additionalProperties: true had no required to enforce and the model's reply (source_title_, summary=, phantom ", ": "") was formally valid. Drop .optional() from entities + concepts only; .passthrough() stays (per the user requirement "针对一些格式内容多变的属性,必须留好冗余空间"). source_title / summary / key_points / related_pages / contradictions stay .optional() because normalizeBatchResponse does not consult them for batch validity (only entities + concepts do) and the runtime has explicit fallbacks. The "accepts {}" test is inverted: {} is now REJECTED (the constraint that was missing); {entities: [], concepts: []} is the empty-batch signal. 3320 → 3328 tests. Closes #463.

  • Lint prompt: fillEmptyPage hardcoded default tag taxonomy contradicted runtime injection (Issue #459/#460, PR #460 DocTpoint). The fix line at src/wiki/prompts/fixes.ts:47 enumerated ≥3 default-taxonomy values, silently breaking disjoint custom vocabularies (biochemistry, legal domain, etc.). Defer to system-layer Active Tag Vocabulary (the same path runtime uses). 68 regression tests pin the runtime vocabulary reaches the lint task + no FIX_PROMPTS line enumerates default-taxonomy values. Closes #459/#460.

  • Path resolution: drop the alias latch on ambiguous fallback (Issue #446 follow-on, PR #478). path-resolution.ts:174 previously latched an extracted name as a wiki alias whenever a name-collision fallback was taken, polluting the alias index with single-extraction noise. A name claimed by two pages resolved through the latch to whichever one extracted first; re-extraction then hardened the wrong answer. Fix: drop the latch entirely. The ambiguous case now reports "neither" (per #446's lesson) and the typed-list fallback takes over. 3000 → 3004 tests. Closes #446 follow-on.

  • Extraction: freeze the slug catalog per run so the prompt cache survives (Issue #452, PR #483). The catalog is the first block of analyzeSource and ~91% of its characters — the span every prefix cache (Anthropic / OpenAI / llama.cpp KV reuse) would otherwise re-prefill every note. buildCompactSlugList re-sorted per call, so pages the previous note created sorted into the middle and the reusable span ended there. Measured on LM Studio (gemma-4-26b-a4b-qat, 2844 slugs, 24.5 K prompt tokens, max_tokens=1): 23.5 s prefill/note against 4.39 s when new slugs are appended instead. Fix: a folder/batch ingest carries a RunSlugCatalog on its BatchRequirementsContext — sorted snapshot at run start, pages that appear during the run appended in first-seen order so an earlier append never shifts a later one. Single-file ingests pass no catalog and get the freshly sorted list as before. Two consequences, both deliberate: deleted-mid-run pages stay in the catalog until the run ends (dropping them reintroduces mid-list divergence; stale targets resolved downstream by PageFactory.resolvePagePath), and re-ingesting a source inside the wiki folder still excludes its own slug per call (one-line shift; sources outside the wiki folder, the normal case, were never in the catalog). This makes the block prefix-stable within a run — Direction 1 of #449; Direction 2 (cross-run caching) is the cacheBreakpoint wire-up below. Closes #452.

  • AI-SDK migration: reasoning-only guard dropped (Issue #470, PR #488). The v1.26.0 Batch 6 4-layer force-disable thinking lost Layer 1 (reasoningEffort: 'none') when the AI-SDK v6 client migration reshuffled the buildProviderOptions destructure: reasoningEffort was no longer threaded through to the wire on any openai-compat provider. Fix: thread reasoningEffort through buildProviderOptions for every openai-compat provider that does not have a Zod-declared equivalent, with the reasoning-strip-probe cache unchanged. The 400-strip retry path also accepts reasoningEffort so a backend that rejects the field gets a clean retry rather than a second 400. 2 regression tests pin the wire-body assertion (reasoning_effort: 'none' IS on the body). Closes #470.

  • Three-layer repair for DeepSeek reasoning-model ingest (Issue #474, PR #486). Three failure modes collapsed into "Failed to connect to deepseek API". Layer 1 — prose reasoning pollution: prependReasoningForParse always prepended reasoning_content before visible text; when reasoning is prose (deepseek-v4-flash narrative thinking) and visible text is JSON, the parse target became prose + JSON and every parseJsonResult layer walked into the prose first. Fix: drop reasoning when it has no <think> wrapper AND visible text is non-empty; the Qwen3.5 JSON-in-reasoning case (text='' + reasoning=JSON) is preserved (still prepends); the R1 / o-series <think>-wrapped case is preserved (still wraps). Layer 2 — NoOutputGeneratedError misclassification: AI SDK's step-retry exhaustion path throws NoOutputGeneratedError (sibling of NoObjectGeneratedError; both extend AISDKError); the catch only checked NoObjectGeneratedError.isInstance(err), so the sibling slipped through and mapAiSdkError rewrote it as "Failed to connect to deepseek API" — a budget problem misreported as a connectivity error. Fix: catch NoOutputGeneratedError in both createMessage and createMessageWithOutput; return '' / empty shape so the caller's parseJsonResponse empty-input path handles it; finishReason: 'stop' is the right semantic. Layer 3 — output mode reporting honesty: OutputModeProber defaulted to json_schema for every provider, but OpenAICompatSdkClient is constructed with supportsStructuredOutputs: false for 5 cloud openai-compat providers (deepseek / kimi / glm / minimax / openrouter); the SDK encodes json_object on the wire regardless, silently dropping caller-supplied schemas. New getCurrentOutputMode(model) pre-seeds the cache to json_object on the first call when !supportsStructuredOutputs — initialization, not demotion. outputMode reports the wire shape the SDK actually emits; side benefit is the wasted json_schema → json_object demotion cycle on first 400 is skipped (1 HTTP call saved per first failure on these providers). 3328 → 3391 tests. Closes #474.

  • Page-batch-runner test: relax timing assertion slop (PR #489). A 30 ms timing assertion on the page-batch-runner internals flaked under CI's variable load. Relaxed to 25 ms slop (10/10 runs clean on a 6-vCPU runner). 3391 tests. No behaviour change.

  • CI: Gate 1 PR-time status check (PR #487). .github/workflows/pr-ci.yml runs the full Five-Gate (lint + tsc + build + test + css-lint) on every PR to main; status check Gate 1 / Five-Gate is now a branch-protection requirement. Order is non-negotiable: build before testopenai-codex-loopback-flow.test.ts:39 reads main.js to verify esbuild bundle shape, so a test-before-build run on a fresh clone fails ENOENT. CLAUDE.md §"Gate 1" was corrected to match. Local pnpm lint remains src/-only (the Obsidian Bot scans the whole repo .ts tree, not just src/, and pnpm lint:tools-bot is the local pre-check). Lockfile-pinned install (pnpm install --frozen-lockfile) prevents eslint-plugin-obsidianmd drift between local Gate 1 and CI. CI is defense-in-depth — explicit "merge it" still required per CLAUDE.md §"Git Safety Protocol".

  • Final lint analysis sent an uncapped full-wiki context, 400-ing on 49K-context local models (Issue #473, PR #494). runAnalysisPhase (src/wiki/lint/llm-phases/analysis-phase.ts) read the entire wiki/index.md and injected it into the LLM prompt. On a 1,690-page vault that is ~152K input tokens against an LM Studio 49K context window → HTTP 400. Decision (established with the maintainer from Karpathy first principles): instead of capping the prompt or adding a token-budget estimator, remove the LLM analysis section entirely. The prompt asks the LLM to judge a whole wiki it only sees 8 pages of; schema-suggest keeps the LLM-advice path on the "Analyze Schema" button. This deletes the section, its 5 accumulated regressions (duplicate ## LLM 分析 heading — root cause: report-builder.ts's cleanedLLM.startsWith('##') guard fires on empty string — leaked chain-of-thought, nested <ul><ul>, repeated headings, JSON parse failures), and one LLM call per lint. Side-effect fix in same PR: #474's prependReasoningForParse change dropped Query reasoning — query stream paths (openai-compat 4 sites, openai-codex added) now use wrapReasoningContent; anthropic stream uses the shared helper. Net -563 LOC; 3391 → 3401 tests. Closes #473.

  • Startup quick-fix completion Notice was 6 lines of "everything is fine" on a healthy vault. The three per-check detail lines (structure / sources / incomplete) are now emitted only when that check actually needed fixing; a routine morning startup shows just title + page-count summary + disable hint.

  • Wire-up: cacheBreakpointcache_control on the user-message prefix, not the system block (Issue #449, PR #464). cacheBreakpoint is a UTF-16 code-unit offset (String.length, not bytes) into the FIRST user message's text content (set by source-analyzer.ts:404 as staticPrefix.length). Anthropic prompt caching is a prefix match on render order tools → system → messages; a marker on the system block caches tools + system (a few KB, below Anthropic's minimum cacheable size on every current model — the floor is model-dependent and not monotonic across generations, 512 to 4096 tokens, so it is not a constant) and leaves the 75K-char user-message prefix uncached — the silent no-op class of regression that pre-fix had been shipping. Fix: replace buildSystemWithCacheControl (emits SystemModelMessage[] with cacheControl on the system block) with buildMessagesWithCacheControl (emits the first user message as two TextParts cut at the offset, with cacheControl on the prefix part). AI SDK v6's Anthropic adapter (@ai-sdk/anthropic/dist/index.mjs:2316-2340) reads cacheControl from TextPart.providerOptions and emits cache_control: { type: 'ephemeral' } on the matching wire block. system stays a plain string. Branch D non-blocking fix (DocTpoint): call-site spread is now ...(system ? { system } : {}) (truthy-check), dropping system: '' from the wire. 5 tests rewritten to inspect call.messages[0].content as TextPart[] (the previous 5 pinned the wrong wire shape — call.system as SystemModelMessage[]); 1 new test covers Branch D. Wire-shape net: pre-fix had cache_control on system block (below cache floor → no cache hit); post-fix on user prefix → cache_creation_input_tokens on first call, cached_input_tokens on subsequent calls (all-prefix reuse). On a 2,838-page vault (75,876-char prefix) the saving is ~22-25K input tokens per post-first batch. 6 tests. Closes #449.

  • Contradictions: clamp the page in sections and restore what was withheld (#287 follow-on, PR #492). ContradictionManager.resolveContradiction sent the affected page as existingContent.substring(0, 6000). Three facts on that path composed badly: the prompt asks the model to preserve every existing fact and output the complete repaired page; the answer is written back over the file with createOrUpdateFile; nothing preserves what the model was not shown. For pages above the budget the model never saw the tail, answered with what it believed was the complete page, and the write replaced the original — silent data loss on 55 of 2,416 knowledge pages (2.3%) on a typical vault, with the largest page dropping four-fifths. Same family as #292 and #287, one path further along. Fix: src/core/clamp-page-sections.ts clamps in whole ## sections (drops from the end, never mid-sentence), names the omitted sections in the prompt text, returns the withheld blocks verbatim so restoreWithheldSections can put them back after the rewrite. Below the budget the return is byte-identical (97.7% of pages). A page over budget with no ## boundary (preamble alone busts the budget, or no heading at all) is refused with hardCut: true — the rewrite is refused rather than written back incomplete. The contradiction record is clamped through the same helper (read-only path). The merge-triage path is left alone (median 1,424 / p90 2,695 chars; the payload does not grow with the vault there). 11 + 3 tests cover byte-identity below budget, whole-section dropping, the marker, verbatim withheld blocks in document order, no-boundary hard cut, and the integration path: the prompt admits the omission and excludes the tail, the file keeps the tail anyway, and the unclamped case reaches the model unchanged. Closes #287 follow-on.

Changed

  • Per-task policy: choose output mode and thinking per pipeline step (Issue #481, PR #490). taskPolicies?: TaskPolicyMap on LLMWikiSettings maps a task label to {outputMode, thinking}, resolved specific → wildcard → default. The client wrapper applies it at the one seam every call already passes through, so no call site changes. An unset policy spreads {} and the path stays byte-identical to today. The spec format is extract=text:on,merge-triage=text:on,page-generate=-:off; parseTaskPolicySpec throws on anything it cannot read (a silently-ignored entry would mean an arm that did not run what its own manifest says it ran). Two mechanics the wire forced: a pinned text_prompt puts no response_format on the wire, so the JSON shape has to come from the prompt — forcedTextPromptSystem adds JSON_ENFORCEMENT_SYSTEM_PREFIX up front (the 400-driven demotion adds it at retry time; a pinned mode has no retry path); low / medium / high send reasoning_effort and the reasoning-strip retry deliberately drops the field (the backend just rejected it, so re-sending it would only earn a second 400). low/medium/high are indistinguishable on LM Studio / gemma-4-26b-a4b-qat (byte-identical output at 417 reasoning tokens); reasoning_effort: none does switch reasoning off. The levels are the standard openai-compatible field and are carried for the backends that honour it. The stream path is untouched — it carries no task label (#469), so there is nothing to key a per-step decision on. The setting is deliberately not exposed in the UI — the settings worth offering are the ones a measurement has picked out, and this field is what makes that measurement possible. 3430 → 3434 tests. Unblocks #481.

  • Extraction payload stops growing with the vault (Issue #482 stages 1+2, PR #484). Stage 1 removes the slug catalog that was the first block of analyzeSource and ~91% of its characters (2,843 lines / 69,355 chars on a mature vault). The prompt is now instructions plus the note, so its prefix is identical for every note — the best case for prefix caching — and per-note cost is a function of the note instead of the vault. Requirement 7 (related_pages) went with it: source-analyzer.ts overwrites accumulation.relatedPages unconditionally with the programmatic match whenever anything was extracted, so the LLM output was paid for and discarded. Stage 2 stops showing the candidate list in generation/merge prompts and resolves every related link after generation against an index of every page: title first, then curated aliases — so [[E433]] lands on entities/Polysorbate (a connection no candidate window could contain, because the page's title is not the name in the prose). An alias claimed by two pages resolves to neither (#446 lesson); bare [[Name]] links are now in scope, since a prompt without a path list produces them. A name the vault does not know keeps the previous behaviour (folder from typed related lists, slug from the name) so the dead-link/stub path that requirement 3 of the generation prompt relies on is unchanged. buildPagesListForPrompt had no remaining caller and is removed with its facade and its tests; the #234 invariant it carried — sources/ is never a body-link target — now sits on the resolver, which cannot select a sources/ page at all. The closing commit removes the run-scoped catalog plumbing #483 added: stage 1 makes the block it stabilises unnecessary. Acceptance measurement (2,838-page vault): Stage 1 — 4 usable draws each, catalog arm runs round 1 then loses round 2 to the output ceiling every draw (398-421 s); stage 1 arm runs both rounds (80-110 s). Round 1 differs (mergeBatchResults is strictly additive; a name in only one arm cannot have been dropped by a later round). Stage 2 — 8,800 related links: 7,962 → 8,191 resolving (90.48% → 93.08%); +235 newly resolving, −6 (one class: links that resolved only because a sources/ page carried the name — the #234 invariant arriving in its new home; the resulting 6 dead links convert to stubs via Fix Dead Links, tracked separately as #485). Net -288 LOC; 3434 tests. Closes #482 stages 1+2.

[1.26.2] - 2026-08-09

A surgical PATCH that closes the pre-submission blind spot exposed by the v1.26.1 Obsidian Bot pre-review. The bot scans the whole repo .ts tree while local pnpm lint only scans src/ — and v1.26.1 shipped a blocking no-unsafe-call Error in tools/llm-wiki-cli/src/obsidian.ts that local lint never saw. No behaviour change, no new settings, no migration. The headline: the CLI's obsidian.ts:117 await import() chain is now type-safe AND exempt from obsidianmd/no-nodejs-modules, and a pnpm lint:tools-bot script closes the local blind spot so the next release doesn't need a Bot trip to surface what local lint should have caught.

Fixed

  • CLI obsidian.ts:117 blocking unsafe-call Error (#442). await import(<dynamic-arg>) left request as any, cascading 6 unsafe-* warnings and triggering no-unsafe-call → Error. Split into two literal await import('node:https') / await import('node:http') branches with explicit typeof import('node:http').request annotation — Error + the entire unsafe-* cascade disappear in one stroke.
  • obsidian.ts:158 requestUrl().json contract (#442). JSON.parse(text) as unknown + try/catch that re-throws with the status context, matching Obsidian host's behaviour on bad JSON.
  • main.ts:443 loadSettings JSON.parse argument type (#442). Now typed as Partial<LLMWikiSettings> | null to match applySettingsMigrations' declared parameter; eliminates no-unsafe-argument.
  • main.ts:647 globalThis.crypto.subtlecrypto.subtle (#442). Node 18+ exposes crypto as a global; the explicit globalThis. prefix was tripping obsidianmd/no-global-this (no-disable rule).
  • vault.ts:291 redundant as Record<string, unknown> | null removed (#442). parseFrontmatter already returns a FrontmatterData | null whose index signature is compatible — Bot flagged as no-op assertion.
  • node-globals.ts:28 (...args: any[])(...args: unknown[]) (#442). Console constructor accepts unknown[]; eliminates a Bot-flagged bare any and the Unexpected any warning.

Added

  • Local tools/ blind-spot closure: pnpm lint:tools-bot (#442). New eslint.tools-bot.config.mjs (obsidianmd recommended ruleset scoped to tools/**, type context from tools/llm-wiki-cli/tsconfig.json, Node globals declared) and a matching package.json script (|| true, informational — never gates CI). Developers now see the Bot's view of the CLI tree locally instead of discovering it post-submission.
  • Platform.isDesktop AST guards on the three runtime-loaded node:* imports (#442). obsidian.ts:requestUrl() and node-globals.ts:plainConsole() each carry a function-start if (!Platform.isDesktop) throw new Error(...). The CLI's own Platform shim hardcodes isDesktop: true, so the guards never throw at runtime — they declare the desktop-only invariant the obsidianmd/no-nodejs-modules rule requires (verified against the rule source; bare dynamic imports are not exempt, contrary to the assumption baked into PR #418/#433's patterns). Mirrors src/llm-sdk/openai-codex/loopback-flow.ts:156-160.

Notes for release engineering

  • Release skill v1.7.0 now mandates an Obsidian Bot pre-review (Step 6b.5, HARD STOP ②) between tag and publish. This is the gate that should have caught v1.26.1's pre-publish — making it explicit instead of relying on the maintainer remembering to submit. See [[feedback_obsidianmd_no_nodejs_guard_detection]] for the rule-detection mechanism.
  • All 7 remaining tools/ warnings are accepted-structural (static node:fs/path/fs-promises/util imports, .obsidian literal, console.log output interface, globalThis shim). Dynamic form would break the 14 parser-contract tests that pin parseCliOptions as sync. The honest long-term fix is the CLI split into a separate repo ([[project_v1_27_0_cli_split_planning]]).

[1.26.3] - 2026-08-12

A surgical PATCH that closes five UX blind spots the maintainer discovered while validating the v1.26.1 / v1.26.2 release on the production vault. None of these touch the LLM pipeline (PR #447's v1.26.3 PATCH owns that work — Phase A 3-tier state machine, Path 2 fix, Phase B 11 caller migrations, per-model placeholder demotion, pushed 2026-08-12, awaiting DocTpoint re-review); this entry covers the settings/lint/UI bugs the maintainer surfaced during the same E2E round and shipped on a separate branch (fix/ux-b1-b2-b3-provider-statusbar-dedup): Fetch-Models error classification (B1), status-bar cancel affordance (B2), lint dedup cross-type filter (B3), full status-bar progress i18n (B2.5), and the remaining hardcoded-English Toasts.

Fixed

  • Settings: Fetch Models misclassified auth/endpoint/server failures as "Network" (B1). fetchOneUrl in model-section.ts silently returned [] on non-2xx and the outer catch rewrote every error to the status-less All URL candidates failedclassifyFetchError's regexes (\b(401|403)\b / \b(404|405|...)\b / \b5\d\d\b) had nothing to match, so every auth/wrong-URL/server failure surfaced as fetchErrorNetwork. Verified across all 7 cloud providers (OpenAI / Anthropic / DeepSeek / Kimi / MiniMax / GLM all return 401 for invalid keys; LM Studio / Ollama have no auth). fetchOneUrl now throws HTTP {status}: {body-snippet} on non-2xx; fetchModelsWithFallback tracks lastHttpError across all candidates and re-throws it; the outer catch passes the original error through unchanged. The classifier's existing regexes now hit on the first try. DocTpoint CR follow-up (2026-08-12): two unknown-case regressions the original fix introduced were closed — a 2xx with an empty/absent data array is now a valid [] return (the HTTP 200 throw had no classifier branch → misreported Network), a true network failure is tracked as lastNetworkError and re-thrown as Network (previously it collapsed into []empty model list → the misleading fetchErrorEmpty for a disconnected user), and classifyFetchError now matches the leading ^HTTP (\d+) BEFORE the keyword regexes so a 5xx whose body contains unauthorized is Server, not Auth.

  • Settings: every locale's fetchErrorNetwork now mentions the API Key as a fallback hint (B1). True network failures (DNS / connection refused / timeout) still fall through to fetchErrorNetwork and cannot be disambiguated from auth by status alone; the fallback message now suggests checking the Key alongside network settings. 10 locales updated (en, zh, zh-Hant, ja, ko, de, fr, es, pt, it, ru) with an i18n-parity test that asserts every locale's message matches a Key-mention regex.

  • UI: status-bar update path dropped the "click to cancel" label (B2). setStatusBarUpdateCallback in command-registry.ts called setText(text) directly with the raw progress text, dropping the always-visible base label for the entire duration of long ingest/lint batches. Users saw Analyzing batch 2/3 (0 entities, 5 concepts so far)... with no indication the bar was clickable to abort. The click handler itself was already wired (line 156) — only the affordance text was missing. New composeStatusBarUpdate helper in src/core/status-bar.ts selects the active label (ingest > lint, mutex in practice) and appends it as a stage segment via buildIngestStatusBarText, restoring the docblock contract. The callback now hides the bar (returns null) when neither is running, instead of leaving stale text on screen. DocTpoint CR follow-up (2026-08-12): the PDF emitter (ingestPdfSource's setPdfStage) passed an already-composed buildIngestStatusBarText string — which already ended in the base label — into updateStatusBar, so composeStatusBarUpdate appended the label a second time on every PDF stage (My Note.pdf · Reading PDF… · Ingesting… · Ingesting…). setPdfStage now emits raw segments ([filename, stage].join(' · ')) so label composition happens in exactly one place.

  • Lint dedup: cross-type pair filter (B3). The dedup candidate generator emitted entity↔source and concept↔source pairs that shared a wiki subfolder bucket (tp: / ic: / lh:), polluting the LLM verify batch with nonsense questions. Per the #358 complementary memory model, a source mentioning an entity by name is NOT a duplicate of the entity — they live in different cognitive registers. Per the maintainer's 2026-08-12 direction, the dedup now considers only: entity↔entity, concept↔concept, entity↔concept, source↔source. Forbidden: entity↔source and concept↔source (in any order). New pageTypeOf(path) helper infers page type from WIKI_SUBFOLDERS segments; new isCrossTypePairAllowed guard rejects forbidden pairs at addCandidate injection time using canonicalized smaller|larger string keys. DocTpoint CR follow-up (2026-08-12): two B3 refinements — the anti-regression comment now states the true root cause (the canonical key concept|entity MUST be present in ALLOWED_PAIR_KEYS; the a < b comparison was never the miss — it produces an identical key either way, so a future editor must not 'simplify' it), and the rejected-pair count is surfaced via hooks.onCrossTypeRejected in the dedup-phase candidate debug line so the filter's effect is measurable.

  • Status-bar progress text fully localized (B2.5). 16 hardcoded English status-bar strings in wiki-engine.ts / conversation-ingest.ts / source-analyzer.ts (e.g. Analyzing batch 1/3..., [7/10] Concept: <name>) produced mixed-language bars on non-English vaults. 18 new i18n keys (status-bar stage + Entity/Concept type labels) across 10 locales; every onProgress string now flows through getText(). 22 i18n-parity tests pin the placeholder contract ({current} {total} {filename} etc.) so a translator dropping a placeholder fails loudly.

  • Remaining hardcoded English Toasts localized (B2.5 follow-up). User E2E found Ingesting: <file> Toasts still English while the status bar was fully localized. Full sweep of every new Notice() / showProgressFor() call site found exactly three hardcoded strings — Ingesting: {filename} (single-file manual ingest), Checking for already-ingested files... (batch pre-scan), and <N> findings (auto-lint completion). 3 new i18n keys + 10 locales; lintFindingsSummary is a full phrase ({total} findings) so each locale can order/pluralize freely. 22 i18n-parity tests.

  • repetitionPenalty user setting was a silent no-op on every shipped provider (Issue #414, PR #453). Since the v1.23.0 AI SDK migration dropped the pre-AI-SDK unsupportedFields blocklist, the setting flowed through to wire on no path: LM Studio / Ollama / llama.cpp received the wrong spelling (repetition_penalty with -ion; llama.cpp recognizes repeat_penalty per DocTpoint #414 type-error test on gemma-4-12b); Kimi / OpenRouter / vLLM saw the field placed under providerOptions.openaiCompatible while the AI SDK's openai-compat passthrough at @ai-sdk/openai-compatible@2.0.62/dist/index.mjs:525-540 reads providerOptions[this.providerOptionsName] (the provider id) — the key mismatch meant the lookup missed for every provider; Anthropic received the field but its Messages API has no repetition_penalty (only temperature / top_p / top_k); DeepSeek / OpenAI / OpenAI Codex / Ollama (OpenAI-compat) / Gemini / MiniMax / GLM / Bedrock-OpenAI do not list the field at all. Per-provider dialect dispatch in OpenAICompatSdkClient.buildProviderOptions: lmstudio / ollama → wire repeat_penalty (no -ion); kimi / openrouter / custom → wire repetition_penalty (snake_case, OpenAI-spec); deepseek / gemini / minimax / glm / bedrock-openai / unknown → field dropped silently. The return key flips from { openaiCompatible: openaiOpts } to { [this.provider]: openaiOpts } so the SDK's per-id-key passthrough delivers the field. The Anthropic client drops the field entirely instead of placing an unrecognized key on the wire — matches the 10-locale i18n text "cloud providers will silently ignore it". OpenAI / Codex unchanged. The dialect table + repetitionPenaltyWireField(provider) helper lives in src/core/repetition-penalty-dialect.ts (re-exported from openai-compat-sdk-client.ts for test parity; placed in core to avoid a circular core↔llm-sdk import — see [[project_v1_26_3_pr454]]). No 400-strip retry for repetitionPenalty: the setting is user-opt-in (not a plugin default), so a backend rejection should surface to the user rather than be silently swallowed (dead-code-as-docs policy + half-life rule). One-line console.debug ([REPETITION-PENALTY-EMIT], mirrors [REASONING-STRIP-DEBUG]) so users with developer-mode debugging can verify the wire contract on their backend. Known limitation: wrapWithAdvancedSettings (src/llm-client-wrapper.ts) uses Object.create(client) to inherit createMessageStream without settings injection — repetitionPenalty (and all other settings) is silently dropped on the stream path (Query Wiki, streaming UI). Tracked as #451 for v1.27.0; this fix lands only on the non-stream path. Closes #414. 3212 tests / 230 files (+3 are the new dialect dispatch tests).

  • Frontmatter writer silently emptied the sources: field on re-ingest (Issue #438, PR #450). enforceFrontmatterConstraints ran AFTER preserveExistingSources was already merged, so the final write dropped any pre-existing sources value that didn't satisfy the new constraint — silently. DocTpoint Finding 1 fix (commit 2560ab4): filter empty-string entries from preservedSources before merging, so a bare sources: header (no values) no longer re-emits as sources:\n - "". Finding 2 (whole-class passthrough via extractPassthroughLines) tracked as a follow-up PR — same PR series, separate commit. Per-vault impact: 321 affected pages in the maintainer's vault. CHANGELOG-side note: previously this regression was hidden by the v1.25.11 frontmatter-writer audit (which validated YAML shape, not value preservation); the fix lives at the writer-merge layer.

  • Placeholder detector missed {"": {}} / {"": []} empty-object/array variants (Issue #443 follow-up, PR #454). The grammar-constrained JSON-repair gate (isPlaceholderObject in src/core/json.ts) only caught {"": ""} (empty string under the empty key). User E2E on LM Studio / qwen3.5-9b (2026-08-13) showed the model emits an empty OBJECT or ARRAY under the empty key when the grammar token is pluralised — both bypassed the existing gate and returned the broken JSON to the caller, triggering the source-analyzer's empty-result path. The detector now uses a single-pass Object.entries(...).every(isEmptyJsonValue) over all keys (not just the first), so empty object/array/string/number/null variants are caught uniformly. The throw-wiring test in wiki-engine-repetition-penalty-hint.test.ts confirms the placeholder gate still routes failures to the "Source analysis failed" path with the localized repetitionPenalty hint attached (gated by core/repetition-penalty-dialect.ts so providers that never put repetitionPenalty on the wire don't get a misleading hint — see the #414 entry above).

  • RepetitionPenalty UX hint on Source analysis failed (PR #454). User feedback 2026-08-13 (LM Studio / gemma-4-12b / qwen3.5-9b): when a custom repetitionPenalty value silently broke grammar-constrained extraction (qwen3.5-9b), the user-facing error was a generic Source analysis failed with no mention of the setting. New buildRepetitionPenaltyHint(language, value, provider) helper appends a localized hint to the throw site (wiki-engine.ts:905-908) ONLY when (a) the user opted into a custom value AND (b) the active provider actually puts the field on the wire (repetitionPenaltyWireField(provider) !== null). Hint suppressed on anthropic/deepseek/gemini/minimax/glm — never put the field on the wire, so a "reduce or clear" hint would be misinformation. Settings description extended in 10 locales (the existing repetitionPenaltyDesc i18n text already warned about silent-drop on cloud providers — the new failure-path hint is a complementary UX surface for the wire-supporting subset).

Notes for release engineering

  • Five PRs land in this PATCH. #447 (LLM pipeline, 39 commits, Phase A 3-tier + Path 2 + Phase B 11 caller migrations + per-model placeholder demotion) + #448 (UX fixes, 10 commits, B1-B3 + B2.5 + Toast i18n) + #453 (Issue #414 dialect, 4 commits: client + wire-shape tests + Anthropic drop + 400-strip documentation) + #450 (Issue #438 sources-loss, 1 commit for Finding 1; Finding 2 follows separately) + #454 (placeholder detector widening + repetitionPenalty UX, 4 commits: detector + UX + simplify/code-review cleanup + provider gate). #450 + #454 still awaiting DocTpoint re-review at tag time; release is held until both clear.
  • No settings-schema change, no migration, no key rename. 21 new i18n keys (18 B2.5 status-bar + 3 Toast) added across all 10 locales + the fetchErrorNetwork value change + 1 repetitionPenaltyErrorHint key + the existing repetitionPenaltyDesc extension. i18n-parity guards (bidirectional + placeholder-drift + non-empty) pin all new content; existing fetch-flow regression guards pin the HTTP-status re-throw behaviour; new dialect-dispatch tests pin the per-id-key passthrough at the wire boundary; new wiki-engine-repetition-penalty-hint.test.ts pins the throw-site wiring.
  • Test count growth. v1.26.2 → v1.26.3: 2992 → 3305 tests (+313 / +12 net files after 5 PRs land). Per-PR deltas: #447 +135 / #448 +58 / #453 +3 / #450 +4 / #454 +5. Composition and full regression-guard list in ROADMAP v1.26.3 PATCH track.
  • CLI repo split is now live in v1.27.0 scope (see ROADMAP §v1.27.0). The in-tree tools/llm-wiki-cli/ remains the canonical CLI source until v1.27.0 ships; the README §Headless CLI was rewritten to point at the published karpathywiki-cli npm package + the standalone sibling repo green-dalii/obsidian-llm-wiki-cli. No code change; only user-facing docs.

[1.26.1] - 2026-08-08

Added

  • parseJsonResult discriminated union for LLM JSON parse outcomes (Issue #407 Stage 0, PR #436, commit ad02b0e). No behaviour change; the union gives the failure a name: {ok: true, value} / {ok: false, reason: 'empty' | 'malformed' | 'exception'}, so a parse failure can no longer be read as a negative answer, and under the union parsed?.field || fallback stops compiling at a call site that ignores the distinction. Every current call site keeps the old parseJsonResponse and is untouched — identity shown over 776 input combinations across return value, thrown error, and the full console call sequence including debug. Call-site migration follows in Stages 1+2 as one PR per site, starting with the two highest-blast sites, path-resolution.ts:220 and conversation-ingest.ts:337.

  • Per-step LLM timing ledger (PR #409, eucher). Each createMessage call now carries a task label naming the pipeline step; a process-global ledger (src/core/llm-task-usage.ts) accumulates call count + wall-millis per label at the single seam every call passes through (wrapWithAdvancedSettings). Callers snapshot before the work and diff after — deliberate, since a reset would be wrong the moment two ingests overlap. A phase as large as page generation (one interval covering path resolution, dedup call, page writes, merge routing) now decomposes into per-step timings, so a slow ingest says which step to look at. The ledger is cumulative for the process; an unlabelled call lands in 'untagged' rather than being dropped, so the table never under-reports the run it exists to explain.

Fixed

  • Duplicate sources: frontmatter key on stub-created concept pages (Issue #399, PR #405, commit 4c43cdfb). v1.25.11 regression in appendSourceSlugToFrontmatter; produced two top-level sources: keys (invalid YAML) on post-stub ingests, breaking Obsidian Properties render. Two-sided fix in buildStubContent + appendSourceSlugToFrontmatter. Corpus: 321 affected pages in @borthwick's vault. 5 new unit tests + 3 parser-shape guards (real yaml package assertion that sources is string[]).
  • CLI: per-run bundle isolation prevents concurrent-run corruption (PR #408, commit 7f864f1). Two concurrent ingest --help runs raced esbuild's in-place bundle write (14 of 60 failed with SyntaxError: Unexpected end of input on main 2a42241). Fix: per-process bundle name + process.kill(pid, 0) liveness sweep at startup + post-import rm (Node keeps loaded module + inline sourcemap). 20 of 20 clean after fix. No config / API / env-var change.
  • Dedup-phase in-scan concurrency halving was inert (CR-1, post-v1.26.0 code review). consecutiveThrottleChunks + HALVE_AFTER_CONSECUTIVE_CHUNKS were declared inside the chunk-iteration for-loop body in src/wiki/lint/llm-phases/dedup-phase.ts, so the counter reset to 0 at every chunk and could never reach the halving threshold of 2. Result: the v1.26.0 Batch 2 attribution "979s→365s e2e gain on the 2141-page vault came from force-disable + halving + 500ms backoff" was wrong on the halving factor — only the retry/backoff mechanism delivered the gain; halving was dead code in practice. Fix: hoist the two declarations above the loop (alongside currentConcurrency) so the counter accumulates across chunks. New regression-guard test in src/__tests__/wiki/lint/llm-phases/dedup-phase.test.ts ("runDedupPhase — in-scan concurrency halving (CR-1 regression guard)") with 4-pair caseVariant fixture + systemPrompt.length = 7000+ to force chunkSize=1 + pageGenerationConcurrency = 2 + per-prompt mock (1st call → '', 2nd → valid JSON). Asserts the "temporarily reducing in-scan concurrency 2 → 1" warn line fires. Attribution correction: see [[feedback_dedup_phase_halving_dead_code]] + [[feedback_force_disable_thinking_openai_compat_noop]] for the full 979s→365s→151s chain.
  • Six reasoning-budget-sensitive TOKENS_* caps raised to 3000 (Issue #403, PR #429). Short-JSON output call sites ({strategy, path}, {keywords: []}, {kind: "entity"}) were sized for non-reasoning models; on reasoning-capable models the deliberation is billed against the same max_tokens budget as the answer, so the cap was burned before content. DocTpoint measurement on gemma-4-12b / LM Studio / 2.4 KB source × 45 calls: 14 truncated, 13 of those empty; complementaryAppend was 3/3 = 100% miss at its 600 cap. Bumped to 3000 uniformly: TOKENS_DEDUP_RESOLUTION 1000 → 3000, TOKENS_MERGE_TRIAGE 2000 → 3000, TOKENS_COMPLEMENTARY_APPEND 600 → 3000 (the three #403 primary sites), plus three same-pattern sites surfaced by the post-#403 audit pass: TOKENS_LINT_ALIAS_BATCH 500 → 3000, TOKENS_LINT_ORPHAN_FIX 800 → 3000, TOKENS_QUERY_KEYWORDS 1000 → 3000. ~50–80% reasoning headroom while keeping the cap well below the call's context window. Per-call reasoning-aware multiplier is deferred to v1.27.0's per-call thinkingPolicy enum (scope item 6).
  • CHANGELOG v1.26.0 entry: thinking / chat_template_kwargs never reached the wire correction (Issue #420, PR #420). Replaced the "SDK's filter() silently drops them" framing with the actual mechanism verified by DocTpoint: the SDK's filter() at @ai-sdk/openai-compatible@2.0.62/dist/index.mjs:531-540 is a passthrough for undeclared keys (copies them verbatim), but it reads from providerOptions[<provider id>] (e.g. lmstudio / deepseek) while buildProviderOptions returns them under the hardcoded openaiCompatible key, which no shipped provider id matches — the fields were misaddressed, not filtered. reasoningEffort: 'none' is the only verified-working disable (it IS declared in the schema and emits as reasoning_effort: 'none' on the wire at :541).
  • CHANGELOG + ROADMAP: Bedrock Stage 2 (SSO/Profile auth) planning entry recorded (Issue #425, PR #426). Cancels the prior "≥3 user requests" gate. Implementation window v1.26.x PATCH / v1.27.0 via a zero-AWS-SDK path: hand-rolled IAM Identity Center OIDC (reusing the Codex OAuth skeleton) → GetRoleCredentials → temp IAM creds → hand-written SigV4 signer → existing bedrock-mantle endpoint. ~+10 KB bundle, zero new npm deps (vs the rejected PR #263's +1.2 MB). Issue #425 milestone: v1.27.0+ research. PR #263 author notified with the new decision (comment 5218259440).
  • --seed is no longer documented as honoured by every local server (Issue #423, PR #434, commit 8826710). Docs-only. Three sites promised strict seed honouring: the flag table in tools/llm-wiki-cli/README.md, the --help text in tools/llm-wiki-cli/src/main.ts, and the samplingSeed doc comment in src/types.ts. Measured on LM Studio / google/gemma-4-12b (MLX, 4bit) the field is accepted and type-validated (seed: "abc" answers HTTP 400 naming llm.prediction.seed) and then ignored: five requests at seed: 42, temperature: 1.0 returned five distinct outputs, as did five requests with no seed at all — only temperature: 0 returned a single output. Nothing in the exchange tells the caller the run is not reproducible, which is what made the sentence costly rather than merely optimistic. The openai / Anthropic / Codex exceptions are accurate and stay verbatim.
  • A page keeps its own H1 through an LLM body rewrite (Issue #419, PR #422, commit cddd460; hardened for Issue #435, PR #437, commit 8eb3948). reassertH1 restored the title with rewrite.replace(current, previous), wrong twice over: the replacement string processes $ escapes, so a title the function exists to keep verbatim was mutated (# Kosten $$500 und $& im Titel came back as # Kosten $500 und # Kosten im Titel), and replace substitutes the first occurrence anywhere in the body rather than the matched line, so a preceding line quoting the title took the restore while the real H1 kept the model's version. Both are repaired by splicing at exec().index. #435 then removed the remaining assumption behind that match — H1 re-assertion hardened against frontmatter and code-fence comment lines: findH1 walks the lines once and skips a --- block in frontmatter position (further down it is a thematic break) and any fenced block, closed by its own opening marker. The read side is the mass-mutation case the file-name approach was rejected for in #419 — a # shell comment inside a bash example could be adopted as the page's previous title and mint a title for a page that never had one. The same repair applies to mergeDuplicatePages, which adopts parsed.body as the merged body. 2 + 6 tests; the thematic-break test pins the boundary rather than fixing it.
  • yaml declared in devDependencies (Issue #424, PR #431, commit 64601ee). Clean-install regression: yaml@2.8.3 was only transitive via vite peer in vitest → eslint-plugin-obsidianmd → yaml-eslint-parser, so pnpm's strict isolation kept it nested under vite/node_modules/yaml. The append-source-slug.test.ts import (import { parse as parseYaml } from 'yaml') failed to collect on a fresh pnpm install from main. Two things had hidden the regression: release.yml uses npm install --legacy-peer-deps (flat layout hoists), and the release workflow runs npm run build only — no test step, so a file that fails to collect never turns CI red. Fix: declare ^2.4.2 in devDependencies; lockfile regenerated per pre-release-gate §2f.2.
  • Query wiki: silent-success defect on Save (Issue #398, PR #432, commit e2071af). User clicks "Save to Wiki" on a conversation; the notice said "Conversation saved to Wiki!" but no file was written. Three notices flashed: "Checking for existing knowledge" → "Konversation im Wiki gespeichert!" → "0 entities, 0 concepts, 0 pages" (or "0实体, 0概念, 0页" in zh-CN locale). Root cause: src/wiki/conversation-ingest.ts:78-93 returns early with success: true, createdPages: [], entitiesCreated: 0, conceptsCreated: 0, errorMessage: 'Knowledge already exists in Wiki' when checkDedup returns status: 'fully_redundant' — but QueryView.saveToWiki displayed an unconditional "saved!" notice without surfacing report.errorMessage. Silent-success defect (UI lied about what actually happened). Two-layer fix: (a) UX layer — noticeTail conditional append when report.errorMessage is set; i18n key querySaveAlreadyExists added to all 10 locales. (b) Diagnostic layer — console.debug for the dedup verdict + console.warn when save is skipped, so the user can inspect DevTools to see the LLM's actual verdict. 3 regression tests pin the silent-success contract.

Issue state (administrative)

Issue tracker had drifted from the v1.26.0 merge history (no Closes #N in PRs #401 / #406 / #410 / #411 commit messages, so auto-close-on-merge never fired). Closed administratively on 2026-08-07:

  • #382 [v1.26.0 hardening] — all 5 P0+P1 batches shipped in v1.26.0 via PRs #401 / #406 / #410 / #411.
  • #328 [schema layer rethink] — Phase 1 closed by PR #331 (2026-07-22). Phase 2/3 deferred to v1.27.0+.
  • #402 [providerOptions stripped] — response_format closed by ca4a24d (2026-07-29); repetitionPenalty split to #414.
  • #399 — see Fixed section above.

Security

  • 24 Dependabot alerts closed via transitive devDep upgrade (Dependabot batch 2026-08-08). All 24 alerts were in transitive devDependencies only — production runtime (@ai-sdk/*, openai, anthropic, etc.) was untouched. Bumped 4 root devDeps so the 4 vulnerable transitives resolve to safe versions:

    • fast-uri 3.1.43.1.5 (added as direct devDep so pnpm hoists it; override updated) — closes 3 alerts (#1, #30, #31)
    • undici 7.27.28.10.0 via jsdom@^30.0.1 — closes 16 alerts (#5-#11, #18-#27)
    • postcss 8.5.158.5.26 via vite@^8.2.1 (vitest peer re-resolved) — closes 3 alerts (#12, #34, #35)
    • vite 8.0.138.2.1 via direct devDep — closes 2 alerts (#3, #4)
    • Plus ajv@^8.20.0 added to devDeps (pulls the safe fast-uri)
    • Plus eslint-plugin-obsidianmd@^0.4.1 + vitest@^4.1.10 range-widened to latest

    All 24 alerts now have first_patched_version ≤ installed version per Dependabot's metadata; GitHub auto-closes on next lockfile re-scan post-merge. Zero runtime impact (fast-uri/undici/postcss/vite never appear in main.js — verified by grep -c against the built bundle, 0 hits each). Gate 1 green on the new lockfile: lint 0/0, tsc 0, 2980 tests passing (217 files), build clean, css-lint 0 violations.

    Known CI-only carry-over: npm-audit (registry-local advisory DB) flags an additional brace-expansion@1.1.16 / 2.1.2 transitive reachability through eslint-plugin-import / eslint-plugin-n / eslint-plugin-react / eslint-plugin-json-schema-validator. GitHub Dependabot does NOT flag these (no open alert for brace-expansion). The npm overrides field syntax to cascade (eslint-plugin-import > brace-expansion: 5.0.9) is incompatible with pnpm's parseCatalogProtocol (bareSpecifier.startsWith is not a function) — flat overrides work in both, but flat overrides do not cascade into grand-children transitive deps on npm's side. pnpm's hoisting deduplicates everything to a single 5.0.9 via flat override. Resolving the npm-side carry-over requires either (a) migrating CI to pnpm, (b) using pnpm-only overrides via pnpm.overrides (subtly different field), or (c) replacing eslint-plugin-import / eslint-plugin-n / eslint-plugin-react with non-vulnerable alternatives. Out of scope for v1.26.1 — the runtime bundle is unaffected, Dependabot considers it resolved.

Tracked in v1.26.x PATCH (no fix yet)

  • #407parseJsonResponse parse failures indistinguishable from negative answers at 7-12 sites (high-blast: path-resolution.ts:220 + conversation-ingest.ts:337). Stage 0 shipped as PR #436 (see Added above); the call sites still read a failure as a negative answer until Stages 1+2 port them, one PR per site.
  • #414repetitionPenalty setting inert (split from #402). DocTpoint's per-backend measurement 2026-08-07 on LM Studio / gemma-4-12b confirmed: repetition_penalty is silently discarded on this backend; the correct spelling is repeat_penalty (llama.cpp style) for LM Studio / llama.cpp and repetition_penalty for vLLM / OpenRouter. Path = per-backend spelling transform. Gap: DeepSeek / Kimi / GLM / Ollama / vLLM unmeasured.

Planned — Bedrock Stage 2 (SSO/Profile auth, 2026-08-07 decision)

Scope. Adds AWS SSO / Profile login to the existing bedrock-anthropic / bedrock-openai providers via a zero-AWS-SDK path (cancels the prior "≥3 user requests" gate). Mechanism: hand-rolled IAM Identity Center OIDC device-code flow (reusing the Codex OAuth skeleton at src/llm-sdk/openai-codex/) → GetRoleCredentials → temp IAM creds → hand-written SigV4 signer → existing bedrock-mantle endpoint (bedrockMantleMessagesUrl / bedrockMantleChatCompletionsUrl). ~+10 KB bundle, zero new npm deps.

Why this replaces the rejected PR #263 approach. #263 shipped @ai-sdk/amazon-bedrock + @aws-sdk/credential-providers for the same feature at +1.2 MB (bearer users pay it too — esbuild single-file CJS cannot lazy-load). The bedrock-mantle endpoint accepts AWS credentials (SigV4) per AWS docs and speaks standard OpenAI/Anthropic protocols over plain SSE — so SSO needs only the OIDC login flow + a hand-writable SigV4 signer (~300 LOC, crypto.subtle, AWS test vectors). No AWS SDK required.

Design record: ~/.claude/.../memory/project_bedrock_stage2_codex_style_sigv4.md (implementation checklist). Target window: v1.26.x PATCH or v1.27.0.

[1.26.0] - 2026-08-05

MINOR. Anchored at #358 (complementary memory model). User-visible surface from this release: the headless ingest CLI, #383 boundary follow-up, dual-key bucketed dedup, cross-type dedup candidate expansion, dedup threshold advanced tunables, real wire-level force-disable thinking (4-layer fallback), parse-failure routing into dedupFailures, dead-code-as-docs governance, Russian i18n, and three DocTpoint PRs (#357 source-lemma, #386 vault-wide link retarget, #388 created: provenance). The complementary-memory design items (per-type registration, typed edges, bidirectional frontmatter, identity ambiguity, Preview-Confirm, stable mutation interface) are scoped but not implemented in this release — they remain v1.26.x follow-on work and are tracked in docs/v1.26.0-design.md plus the issues listed there.

Composition. 115 commits on top of v1.25.11, 110 files changed, +10,604 / −994 LOC, 2928 tests / 213 files passing. The Batches 1-4 P0+P1 hardening was added on top of the originally-shipped CLI surface; v1.26.0 is the first MINOR in the project that carries P0+P1 hardening into the same tag.

Added

  • Headless ingest CLI is now discoverable (PR #372 + #387). The engine under tools/llm-wiki-cli/ previously shipped with no bin, no pnpm script, and no mention in any README — a fresh clone could not find it. Exposes it as llm-wiki (bin) plus pnpm llm-wiki (script) and sets the executable bit on run-llm-wiki.mjs so npm install produces node_modules/.bin/llm-wiki. The tool is named llm-wiki rather than wiki-ingest so it can grow beyond ingest into a general wiki-management CLI (lint, query, mutation) without a later rename. Note: pnpm 10 does not link the root package's bin to node_modules/.bin/, so the pnpm-user entry point is pnpm llm-wiki (not pnpm exec llm-wiki); npm users see ./node_modules/.bin/llm-wiki.
  • 🛠️ Tools H2 section in all 10 READMEs. One paragraph pointing at the CLI's flag reference, environment requirements, and shim caveats — links out to tools/llm-wiki-cli/README.md (absolute GitHub URL so the readme-links test stays green).
  • Source-slug deterministic merge (PR #357, DocTpoint). Replaces the LLM-only merge judgement with a deterministic "source-slug = page-lemma" path so a re-ingest of the source that produced a page cannot fail to merge that page into its own subject on the second pass.
  • Real wire-level force-disable thinking, 4-layer fallback (PR #411, Batch 6). Prior versions shipped thinking.type = 'disabled' as a wire disable, but they never reached the request body. The AI SDK's openaiCompatibleLanguageModelChatOptions zod schema (@ai-sdk/openai-compatible@2.0.62/dist/index.mjs:322-344) does not declare thinking / chat_template_kwargs — on its own that would not have stopped them, because the filter() at :531-540 is the passthrough for undeclared keys: it copies them into the body verbatim and skips only the keys the schema already handles. It reads them from providerOptions[<provider id>] (lmstudio / deepseek / …), while buildProviderOptions returns them under the hardcoded openaiCompatible key, which no shipped provider id matches — so the fields were misaddressed, not filtered. Layer 1: reasoningEffort: 'none' is declared in the schema and the SDK emits it as reasoning_effort: 'none' on the wire (:541) — the only verified-working disable. Layer 2: co-emit thinking: { type: 'disabled' } + chat_template_kwargs.enable_thinking = false for the Anthropic path (uses a different field that the Anthropic SDK accepts). Layer 3: on HTTP 400 mentioning reasoning_effort / thinking / chat_template, retry once with reasoningEffort stripped. Per-baseURL cache prevents infinite loops. Layer 4: "Do not reason step by step" line in the dedup prompt. User-facing impact on a 2141-page vault: wall-time 979s → 365s (Batch 2 retry only) → 151s after Layers 1-3 went live (−85% vs baseline, −59% vs Batch 2). Per-call thinkingPolicy (the JSON-repair path at source-analyzer.ts:417 always allows reasoning — DocTpoint measurement showed disabling it produces structurally valid JSON with wrong content) is deferred to v1.26.x PATCH.
  • Dual-key bucketed dedup, Batch 1 rev 2 (PR #401). partitionPagesMultiBucket partitions pages into tp-prefix (title-prefix) + lh-link-hash (link-hash) buckets before the O(n²) generateDuplicateCandidates scan. Bucket boundary emits checkCancelled() so the user can interrupt mid-scan. Pure refactor on legacy vaults (≥95% recall on synthetic N=200 pages vs 80-90% on the previous single-bucket baseline); memory peak O(N² candidates) → O(B² per bucket).
  • Cross-type dedup candidate expansion, Batch 2 (PR #410). generateDuplicateCandidates now surfaces candidates across entity / concept / file types when the shared-link signal crosses type boundaries, and the dedup phase ships an inline empty-response retry + backoff + concurrency halving on transient burst load (LLMs that return 200 + 0-byte body under burst). User-facing impact: 2141-page vault dedup 979s → 365s after retry/backoff live. Threshold inputs (see Changed) are user-tunable. The companion lintDedupIncludeSources toggle (per-source-file dedup scope filter) relocates to the bottom Advanced settings panel — it was originally misplaced in the LLM Advanced section.
  • Russian i18n, full UI + wiki-output + README (PR #397). 667 new keys in src/texts/ru.ts; full docs/README_RU.md translation; 11-way language switcher across all READMEs; system-prompt section labels for wiki output. 10→11 locales. i18n-parity test enforces bidirectional coverage.
  • Vault-wide link retarget for mergeDuplicates (#386, PR #392, DocTpoint). merge-duplicates.ts previously retargeted links only inside the surviving page's body; links from sibling pages pointing at either the surviving page or the deleted page now also get rewritten vault-wide and across every alias form before the delete commits. Closes #386.
  • Frontmatter created: provenance (#388, PR #396, DocTpoint). create-page.ts and fill-empty-page.ts now take created: from the caller (new Date().toISOString()); never read from the LLM-generated content. Closes #388 — the previous shape could echo an LLM-hallucinated old date into freshly-created pages.
  • Dead-code-as-docs governance (Batch 4, policy only — no functional change). CLAUDE.md §"Dead-code-as-docs policy" + pre-release-gate Phase 2g audit. Exported helpers with zero production importers now have a one-release half-life: either wire into production before the next MINOR or delete before the next MINOR. Two prior instances on record (v1.25.10 PATCH lint-analysis-cache.ts + lint-smart-skip.ts shipped as dead code and survived until v1.26.0 Batch 3 deletion; v1.25.0 PDF cache-only helpers followed the same pattern). Two is a pattern; three would be a culture. P1-1/P1-2 from v1.25.10 PATCH #367 deleted as part of this governance (PR #406).

Changed

  • --thinking on|off--thinking-mode data-json | plugin-off | server-default. The old flag was a two-state surface that hid the three outcomes the plugin can actually produce: leave data.json alone, force-disable reasoning, or defer to the server's preset. on looked like "enable reasoning" but actually meant "defer to server default" — a footgun. The new flag makes all three states explicit. The legacy --thinking throws a deprecation error pointing at the new flag and the v1.27.0 removal target; it does NOT silently translate, so a --thinking on typo can't keep working long after the deprecation is forgotten.
  • --max-rounds--round-base. The old name was actively misleading: it set the granularity's maxBatchesBase field, not a ceiling. The actual ceiling is min(base * 3, ceil(source_chars / 2000) + 2), so --round-base 6 allows up to 18 rounds, and on a short source the length term wins regardless. Renaming to --round-base describes what the flag actually sets. The internal GRANULARITY_CONFIG.maxBatchesBase field is unchanged (engine contract, not CLI surface). Legacy --max-rounds throws a deprecation error.
  • Picker exclusion rule centralised (PR #389 follow-up to #383). FileSuggestModal, FolderSuggestModal, and MultiFileSuggestModal previously each open-coded the wiki + configDir filter. They now share a single isExcludedFromSourcePicker primitive in src/core/folder-scope.ts. The original FolderSuggestModal also held the same unanchored-prefix leak class folder-scope.ts was created to eliminate (.obsidian-backup/ next to .obsidian/) — closed as part of the centralisation.
  • Dedup threshold constants extracted + user-tunable (PR #395, Batch 2 surface area). LINT_DEDUP_JACCARD_LINK_THRESHOLD, LINT_DEDUP_JACCARD_BODY_GATE, LINT_DEDUP_BIGRAM_THRESHOLD extracted from src/wiki/lint/duplicate-detection.ts to src/constants.ts (Lint Performance Knobs block). generateDuplicateCandidates now accepts a DuplicateDetectionThresholds options-object; defaults preserve legacy behavior. Three user-settable inputs in Settings → LLM Configuration → Advanced → Custom ("Duplicate detection thresholds" subsection): shared-link duplicate threshold, body-similarity floor, title/alias similarity threshold. Leave blank = constant default. Tier-1 cutoff (LINT_DEDUP_BIGRAM_TIER1_CUTOFF = 0.6) is NOT user-settable — controls LLM budget allocation; user-tunable would let users silently flood or drop LLM candidates. New bottom "Advanced settings" panel (separate from the LLM Advanced section) hosts the three threshold inputs + lintDedupIncludeSources.

Fixed

  • FolderSuggestModal leaked the wiki folder itself as a pickable source/watched folder (#383 PR #384 follow-up, PR #389). isInFolderScope(folder, wikiFolder, false) is false for the folder itself (a folder is not a descendant of itself); without an explicit identity check, the wiki folder re-entered the folder picker after PR #384 landed. New isAtOrInFolderScope primitive in src/core/folder-scope.ts makes the "folder itself OR anything inside it" semantics a single tested rule.
  • Three of PR #384's per-site regression tests pinned nothing (#383 PR #384 follow-up, PR #389). The delete-empty-stubs, merge-duplicates (deleted — see below), and auto-maintain tests rebuilt the filter expression inside the test file and asserted against the copy — reverting the source line left them green. Rewritten as real production-function tests: deleteEmptyStubs (5 cases: leak direction + substantive content + reviewed:true + protected paths + deleteFile throw), normalizeSourcesInFolder (3 cases: leak direction + clean file + read throw). The merge-duplicates site is intentionally NOT covered by this release — #386 (assigned to DocTpoint) replaces that filter and owns its own coverage.
  • auto-maintain.ts Phase 2 extracted to a module function (normalizeSourcesInFolder in src/core/sources-normalizer.ts). Previously inline in runStartupCheck, which sleeps 3 seconds and depends on the wikiEngine/plugin surfaces — neither is testable from the startup surface. The new module function mirrors the Phase 3 shape (findIncompletePages) so every startup phase follows the same module-function pattern.
  • Dedup-phase parse-failures were silently merged with legitimate-empty results (Batch 7, PR #411). dedup-phase.ts collapsed null (parse-fail / truncated) and {"duplicates": []} (LLM said "no duplicates") into the same [] outcome; neither was routed to dedupFailures. Fix: structured type: 'parse-failure' discriminator on each dedupFailures entry; isRateLimitFailure predicate now accepts a structured item and bails early on type: 'parse-failure'. Both detectRateLimitFailures and the dedup-phase consumer now pass the full item (CR-3 wiring fix — the structured branch was previously unreachable in production because consumers passed f.reason string only; a regression test in rate-limit.test.ts pins the discriminator through detectRateLimitFailures).
  • Two-marker (verb + field) classifier for reasoning-field 400 errors (Batch 6 CR-2). Prior single-substring classifier included the bare word thinking, which collides with model names (kimi-k2-thinking, qwen3-235b-a22b-thinking-2507, glm-4.6-thinking). Any 400 on these models — bad model name, context-length exceeded, max_tokens mismatch — was misclassified as a reasoning-field rejection, permanently marked the baseURL as "strip" (silently disabling force-disable-thinking for the rest of the session), AND consumed the 400 so the token-key fallback never fired. Two-marker classifier (rejection verb + field marker) rejects all four false positives while still catching real rejections.

Performance

  • Lint dedup-phase empty-response retry (Batch 2). Inline retry on null LLM response with 500ms attempt-2 backoff; concurrency-halving on consecutive throttled chunks. Deferred: the concurrency-halving counter (HALVE_AFTER_CONSECUTIVE_CHUNKS = 2) was scoped inside the per-batch for loop body and never reached the threshold — see v1.26.x PATCH CR-1 for the location-only fix. The 979s→365s e2e on the 2141-page vault came from retry/backoff only; the (dormant) halving contributed zero. The 151s additional gain over Batch 2 came from Layers 1-3 of the Batch 6 fallback going live (see Added).
  • Map<string, true>Set<string> on ReasoningStripProber.cache (PR #411 simplify). Removed the dead === true check on every read and the dead invalidate(baseUrl?) overload (zero production callers — only tests used it).

Tests

  • 2928 tests / 213 files passing. +13 net since v1.25.11:
    • +7 __tests__/tools/llm-wiki-cli/main.test.ts (parseCliOptions base contract + boundary-catch USAGE contract, dispatchCli shapes, --thinking-mode enum + legacy deprecation + ambiguity, --round-base + legacy deprecation, numeric validation (safe-integer, empty-value rejection, =-form negatives), boolean plumbing incl. --extract-only--dry-run, applyOverrides (prototype-key guard, single-field patches), resolveApiKey OS guidance, applyThinkingMode, parseNumber)
    • +3 __tests__/root/constants.test.ts (new threshold constants)
    • +11 __tests__/root/i18n-parity.test.ts (Russian locale wiring + 11-way README switcher)
    • +11 __tests__/wiki/lint/duplicate-detection.test.ts (threshold override tests)
    • +3 __tests__/wiki/lint/llm-phases/dedup-phase.test.ts (classifyTiers threshold tests)
    • +3 __tests__/llm-sdk/reasoning-strip-probe.test.ts (two-marker classifier + Set conversion)
    • +1 __tests__/llm-sdk/openai-compat-request-body.test.ts (wire-body regression: reasoning_effort: 'none' IS on the body, not just on the providerOptions argument handed to the SDK)
    • +3 __tests__/core/rate-limit.test.ts (CR-3 wiring + structured-form + discriminator)
    • +4 __tests__/types/settings.test.ts (new threshold settings defaults)
    • +1 __tests__/wiki/lint/fill-empty-page-created.test.ts (#388 regression guard: created: from caller, not content)
    • +1 __tests__/wiki/lint/merge-duplicates-link-retarget.test.ts (#386 regression guard)
    • +2 __tests__/core/folder-scope.test.ts (PR #389 new primitives isAtOrInFolderScope + isExcludedFromSourcePicker)
    • +1 __tests__/core/source-lemma.test.ts (PR #357 source-slug deterministic merge)
    • +2 __tests__/wiki/source-analyzer-thinking.test.ts (Batch 6 per-call thinkingPolicy regression guard: repair callback does NOT pass enableThinking: false)
    • +1 __tests__/wiki/source-analyzer-lemma-guarantee.test.ts (PR #357 invariant)
    • −2 net deletions (removed 2 stale lint-analysis-cache.test.ts + lint-smart-skip.test.ts from PR #406 dead-code cleanup; replaced 1 stale duplicate-detection.test.ts shadow file from the lint sub-package)

Internal

  • Subcommand dispatch via dispatchCli(argv) returning a tagged union { kind: 'tool-help' | 'ingest' | 'unknown' }. Adding a future lint or query subcommand is one case and the compiler will refuse to forget it. Flag-shaped first arguments (e.g. forgetting ingest) surface a hint rather than getting swallowed.
  • Numeric validation collapsed into a single parseNumber(raw, flag, predicate) helper driven by a spec table in parseCliOptions(), so errors surface from the parser (with the ingest USAGE block attached) instead of mid-run. A single boundary catch appends INGEST_USAGE to every escaping error, detecting by a stable marker substring (the exact run-llm-wiki.mjs ingest path) rather than matching on message text.
  • --help is now a pure marker (options.help: boolean) instead of calling process.exit(0) inside the parser, so parseCliOptions is testable. The runIngest runner handles the actual printing.
  • Code-review hardening (8 angles, subagents, both CLI and #383 follow-up): prototype-key guard on the settings-derived granularity path in applyOverrides, empty/whitespace numeric rejection (Number('') coerced to 0 — --max-tokens-per-call "" silently meant "no cap"), --model trim, Number.isSafeInteger for integer flags, -h alias at the ingest subcommand, --vault ENOENT → friendly message, deprecation throws before required-flag checks, folder-scope centralisation closing the same unanchored-prefix leak class on the configDir half. All backward-compatible.
  • Root tsconfig.json gains exclude: ["src/__tests__/tools/**"] because the CLI test files import tools/ source via relative paths and root tsc would otherwise follow those imports into @types/node@16 territory (parseArgs was added in Node 18.3+). vitest still finds them via its own include glob.
  • src/core/folder-scope.ts adds two new primitives alongside isInFolderScope: isAtOrInFolderScope(path, folder, isRoot) (true for the folder itself OR any descendant — fixes the #383 picker leak in one rule) and isExcludedFromSourcePicker(path, wikiFolder, configDir) (the centralised picker rule used by all three pickers).
  • src/core/source-lemma.ts exposes isSourceOwnPageLemma + selectSourceLemma (PR #357 source-slug = page-lemma deterministic merge); src/core/sources-normalizer.ts exposes normalizeSourcesInFolder (PR #389 module-function pattern).
  • src/llm-sdk/reasoning-strip-probe.ts (Batch 6, PR #411) — ReasoningStripProber per-baseURL cache + isReasoningFieldError two-marker classifier (mirrors isPdfRelatedLlmError design in src/wiki/wiki-engine.ts:587-608).
  • src/wiki/lint/duplicate-detection.ts adds DuplicateDetectionThresholds options-object + per-call tier1Cutoff parameter on classifyTiers (PR #395 + PR #410 thread-through).
  • src/ui/settings-sections/advanced-settings-section.ts is the new bottom Advanced settings panel (PR #395); hosts the 3 dedup threshold inputs + lintDedupIncludeSources. Separate from the LLM Advanced section (which retains temperature, repetitionPenalty, forcePdfSupport).
  • src/ui/settings-sections/shared-inputs.ts adds renderNumberInput (consolidated from prior renderNumericInput + renderDedupThresholdInput); regression test in settings-section-helpers.test.ts.
  • src/constants.ts gains 5 new constants: LINT_DEDUP_BUCKET_COUNT, LINT_DEDUP_BUCKET_PREFIX_LEN, LINT_DEDUP_JACCARD_LINK_THRESHOLD, LINT_DEDUP_JACCARD_BODY_GATE, LINT_DEDUP_BIGRAM_THRESHOLD. LINT_DEDUP_BIGRAM_TIER1_CUTOFF (Batch 2) — constant-only, intentionally NOT exposed to Settings.
  • package.json bin field exposes llm-wiki; scripts.llm-wiki mirrors node tools/llm-wiki-cli/run-llm-wiki.mjs.
  • tools/llm-wiki-cli/tsconfig.json (PR #372) — separate tsconfig for the CLI with @types/node@22; root tsconfig excludes src/__tests__/tools/** to keep the parseArgs import below node_modules/@types/node@16 from poisoning root tsc.
  • versions.json gains 1.26.0: 1.11.4 entry.

[1.25.x PATCH series] - 2026-07-18 → 2026-07-31

Super-aggregated per Keep a Changelog spec + CLAUDE.md "ancient versions are pre-aggregated". 11 PATCH releases (v1.25.0 + 1.25.1 → 1.25.11) over 14 days. Per-PR detail preserved in git log --oneline 1.25.0..1.25.12 and memory files (project_v1.25.x_release.md series).

Aggregate scope

  • v1.25.0 (2026-07-18) — MINOR — PDF Ingest Level 1 (content-hash cache + bounded growth + provider gate + Force-PDF-Support escape hatch + local OCR path on Apple Silicon + AbortSignal-cancellable). 2182 tests / 165 files.
  • v1.25.1 → v1.25.11 — 10 PATCHes covering high-ROI bug-fix clusters:
    • Frontmatter data-loss class: #312 part 2 (merge-triage own-source skip override), #356 (unknown-field strip on re-touch), #363 (Mentions [[|]] empty-citation truncation), #365 (freshly-generated pages losing sources: field).
    • Lint pipeline hardening: #367 P0-1 (fix-runners batched by pageGenerationConcurrency), P1-1/P1-2 helpers (LintAnalysisCache, lint-smart-skip — shipped dead per dead-code-as-docs policy; wire-up deferred to v1.26.0).
    • Path-resolution safety: #364 (folder-scope prefix leak — sibling files sharing name prefix matched), #446 follow-up's precursor (alias latch removal).
    • Slug / tag handling: #366 phase 1 (Turkish-aware case fold on slug comparison keys), #368 (custom tag vocabulary documented as LLM hint not enforcement gate), MIN_ALIAS_LENGTH lowered 3 → 2 chars.
    • README + i18n + status-bar i18n: #169 (fine-grained pipeline stage hints in status bar), #375 (relative cross-file README links broken in Obsidian marketplace — switched to absolute GitHub URLs across all 10 locales), 30 dead lintStatus* i18n keys removed, EN banner upgrade (Obsidian Review Perfect Score + Local-first privacy), comparison table dedup 12 → 8 rows, MinerU online conversion added as first item in Ecosystem section of all 10 READMEs.
    • Per-step LLM accounting (Issue #99): #339 follow-up — SecretStorage migration 2-phase wipe (text survives until IO succeeds) + task-label audit on 5 call sites → core/llm-task-usage.ts accumulator.

Aggregate stats

  • 11 releases
  • Test count: 2182 → 2713 / 165 → 202 files (+531 tests across the series)
  • Composition + per-PR detail: git log --oneline 1.25.0..1.25.12 + memory files (project_v1.25.x_release.md series)

[1.25.0] - 2026-07-18

Theme: Cache-only PDF Ingest (Level 1) with provider gate + content-hash cache + bounded growth; prompt centralization for the PDF transcriber; status-bar cancellation via Vercel AI SDK v6 AbortSignal; local model guidance with Apple Silicon OCR path (oMLX + Markitdown + Baidu Unlimited-OCR). 2182 tests passing (165 files). Recommended upgrade for everyone on v1.24.x.

Added

  • PDF Ingest (Level 1). Pick a PDF from your vault — the plugin reads it through your LLM provider's native file input (anthropic / openai / bedrock-anthropic / bedrock-openai natively; any other OpenAI/Anthropic-compatible endpoint via Force PDF Support in Settings → LLM Configuration → Advanced), converts it to Markdown via an OCR-style verbatim transcriber prompt with [illegible] / [figure: ...] / [equation: ...] anti-hallucination markers, and re-enters the regular Markdown ingest pipeline. Every existing entity / concept / alias / [[wiki-link]] workflow applies unchanged. The result is content-hash cached in .obsidian/plugins/karpathywiki/pdf-cache/; the cache key embeds converterVersion so prompt upgrades invalidate stale entries automatically.
  • Bounded cache growth. Three-defense-layer cache housekeeping: single-entry cap (10 MB) pre-write, LRU-by-mtime eviction (100 MB total / 1000 entries) post-write, and prepareBatchIngest() (TTL purge + size enforce) wired into runBatchIngest() via preparePdfCacheForBatchIngest(). Cache only by default — your vault is not modified.
  • Optional vault sidecar. Settings → Wiki Configuration → Wiki Folder → Write PDF Markdown to Vault writes a <basename>.pdf.md sidecar next to the source PDF after conversion. Off by default (cache-only). This is the only user-visible opt-in that touches the vault.
  • Universal Force PDF Support escape hatch. Any non-native provider (custom, anthropic-compatible, ollama, lmstudio, deepseek, kimi, glm, etc.) can attempt PDF conversion when the toggle is on. The endpoint decides; failures surface as a localized sourceRejectedPdfUnsupported Notice guiding the user to disable the toggle or switch provider. The trust boundary is the user — your endpoint either accepts PDF or it doesn't; the toggle tells us to ask it. Switching the provider to a NATIVE one (anthropic / openai / bedrock-*) auto-resets the toggle to false.
  • Local PDF OCR path on Apple Silicon. Documented end-to-end recommended setup for fully-local PDF ingestion: oMLX + Markitdown backend + Baidu Unlimited-OCR (open-sourced 2026-06-22, 3B total / 0.5B active, end-to-end OCR that solves the "slower the longer it generates" failure mode of older OCR models on long documents). Provider: Custom OpenAI-Compatible pointing at oMLX's local server with Force PDF Support on. PDF never leaves the machine.
  • Cancellable PDF ingest. Clicking the status bar mid-conversion aborts the in-flight LLM call through Vercel AI SDK v6 AbortSignal in ~200 ms. Both .catch handlers (selectSourceToIngest and ingestActiveFile) now call dismissProgress() so the persistent "Ingesting: " Notice clears on throw.
  • Local model recommendations. Dedicated ### 🦙 Local Model Recommendations (Ollama / LM Studio) H3 in the Model Selection Guide, covering Qwen3.5 (27B / 35B-A3B / 122B-A10B), Qwen3.6 (27B with 256K+ context / 35B-A3B), Gemma 4 (E2B / E4B / 26B-A4B / 31B), with parameter-vs-quality tradeoff guidance, MLX-vs-GGUF quantization notes, and a context-strategy block. All 10 locales.
  • New Cloud Model Picks H3 in the Model Selection Guide, separating the cloud-vs-local sections explicitly. All 10 locales.
  • PDF transcriber prompt centralized. src/wiki/prompts/pdf.ts houses PDF_CONVERSION_SYSTEM_PROMPT (rewritten as OCR-style verbatim transcriber) plus unwrapFencedMarkdown() cleanup helper (strips ```markdown / ``` / <output> wrappers that small/local models still produce despite instructions). Re-exported via the existing src/prompts.ts barrel — PDF was the last LLM-call site to be folded into the project's prompt barrel.
  • PDF error classifier (isPdfRelatedLlmError). Routes obvious PDF-rejection errors (rejection verb + PDF/media marker) to a localized sourceRejectedPdfUnsupported Notice. Tightened after the initial implementation: requires BOTH a rejection verb (reject / not support / unsupported / invalid / not allowed) AND a PDF/media marker (pdf / application/pdf / file part / mediatype). Pre-fix classifier substring-matched on 'pdf' alone, causing transient 413 size-limit errors and Rust-serde "unknown variant file" schema rejects (no pdf keyword) to be misreported.
  • Three-defense-layer cache filename safety. Physical filename on disk is sha256(logicalKey).slice(0, 16) (Git short-hash style); the logical key retains sha256:model:converterVersion semantics; the converter hashes via new hashCacheKey() helper before cache.get/set. Fixes Windows ERROR_INVALID_NAME + POSIX unintended subpath when model contains / or :.
  • PDF cache directory auto-creation. PdfConversionCache.ensureCacheDir() walks path segments before mkdir. Obsidian's adapter does NOT auto-create parent directories, which left cache writes silently failing in fresh vaults.

Changed

  • Default behavior preserved. No breaking changes since v1.0.0. Old data.json without the new settings fields defaults to false, preserving cache-only behavior. The previously-planned sidecar-by-default approach was withdrawn in favor of cache-only before v1.25.0 ships (architecture pivot documented in project_v1.25.0_pdf_cache_only).
  • PDF dispatch lives in wiki-engine.ts. The separate pdf-ingest-orchestrator.ts file was deleted; ingestPdfSource now feeds convertPdfToMarkdown result into analyzeSource via IngestOptions.contentOverride, reusing the existing Markdown ingest pipeline.
  • 5 dead i18n keys removed across all 10 locales (old "PDF orchestrator" + sidecar-default language).
  • LLMClient.createMessage gained abortSignal?: AbortSignal as an optional parameter. Existing client implementations ignore unknown params (graceful degradation); the project ships a passing thread.

Fixed

  • ENOENT cache dir (Bug A). Obsidian adapter doesn't auto-create parent directories. ensureCacheDir() walks segments before mkdir.
  • AI-SDK cause chain (Bug B). Vercel AI SDK v6 wraps provider rejections inside error.cause.message. The pre-fix isPdfRelatedLlmError classifier inspected only error.message and missed the rejection phrase. inspectCauseChain() walks the cause chain up to 4 levels with cycle protection; classifiers consult both layers. Now also extended to detect Rust-serde schema rejects ("unknown variant file, expected text") which lack any pdf keyword.
  • Stuck "Ingesting: " Notice (Bug H). When an interactive single-file ingest threw (network / vault IO / unexpected error), the persistent progress Notice stayed on screen until the next ingest. Both .catch blocks (selectSourceToIngest line 645, ingestActiveFile line 671) now call this.dismissProgress() after showing the error Notice.
  • Status bar didn't mirror Notice (Bug C). Clicking the status bar during PDF conversion didn't update text — fixed via double-callback pattern (Notice channel + text mirror in onProgress closure).
  • PDF mid-flow cancel ineffective (Bug D). Two-layered bug: setup block re-initialized on re-entry overwrote AbortController, AND convertPdfToMarkdown didn't thread AbortSignal to the LLM call. Fixed with idempotency guard in wiki-engine.ingestSource (if (this.abortController === null)) + abortSignal threading through PdfConversionContext.
  • pdf-cache never written (Bug E). Same root cause as Bug A but in the cache write path. ensureCacheDir() fix covers both directions.
  • Classifier false-positive guards (PR3 follow-up #3). 6 new tests pin the contract — 2 happy-path (route to skip) + 4 false-positive guards (413 / 5xx / null-deref / generic-invalid → re-throw).
  • Markdown wrapper contamination in PDF output. Some local / small models (Qwen3.5-2B-MLX-4bit, Llama 3 8B Instruct, etc.) wrap their PDF-conversion response in markdown ... fences despite the system prompt forbidding them. unwrapFencedMarkdown() heuristic cleaner strips BOM → outermost ```markdown → outermost ```<output> → leading "Here is the converted Markdown:" preamble. Internal python ... blocks survive (regex is single-fence, outermost-only).

Tests

  • 2182 tests passing (165 files). +102 tests since v1.24.1.
  • New tests cover:
    • 30+ PDF ingest end-to-end tests (provider gate, cache hit/miss, settings defaults, sidecar create/update, forcePdfSupport toggle, classifier, cause chain walking, status bar, cancel-mid-PDF)
    • 20 prompt invariant + unwrap helper tests (src/__tests__/wiki/prompts/pdf.test.ts)
    • 6 PDF error classifier regression tests (happy-path + 413/5xx/null-deref/generic-invalid guards)
    • 3 Bug D lifecycle tests (idempotency guard, AbortSignal propagation, dismiss on throw)

[1.24.x PATCH series] - 2026-07-10 → 2026-07-14

Super-aggregated per Keep a Changelog spec. v1.24.1 (single PATCH) over 4 days. Per-PR detail preserved in git log --oneline 1.24.0..1.24.1.

Aggregate scope

  • 5-stage PPR seed-selection cascade (PR #281). Query Wiki now composes context through five complementary stages before generation: (1) lex fast path over entity/concept titles and aliases; (2) LLM keyword generation for synonyms, abbreviations, and token-overlap-resistant terms; (3) local substring scan of generated keywords across titles, aliases, and body snippets; (4) LLM KB fallback that re-seeds top-N candidates semantically when earlier stages are weak; (5) Personalized PageRank (Haveliwala 2002) over the [[wiki-link]] graph starting from the seed set. The cascade auto-truncates at the stage that returns enough signal — no fixed 5-step cost, no LLM calls when lex suffices. Benchmark: PPR @5 = 27.1% vs pure knn baseline 24.1%, zero embedding opt-in.
  • Bedrock Stage 1 providers (PR #277/280). Added bedrock-anthropic and bedrock-openai provider options routed through the AWS bedrock-mantle.<region>.api.aws endpoint. Region selector defaults to us-east-1. Zero new npm deps; bundle delta ~+3 KB. Stage 2/3 (bearer-only @ai-sdk/amazon-bedrock, SSO/profile) remain deferred pending demand.
  • Page-factory split (PR #276). Split src/wiki/page-factory.ts (1252 LOC) into 10 focused modules (aliases.ts, complementary-appends.ts, contextualize.ts, create-page.ts, index.ts, mentions-integration.ts, merge-page.ts, merge-triage.ts, path-resolution.ts, related-page.ts) with 99 new dedicated unit-test files.
  • Consolidated the two "reviewed" protection mechanisms (#244 follow-up, PR #283). Removed the body-level HTML-comment marker (v1.24.0) that protected only a page's ## Mentions in Source section. Protection is now driven solely by frontmatter reviewed: true via the minimal-append path.
  • Non-lossy Mentions re-ingest (#267, PR #269/272). assembleFinalContent previously dropped every earlier source's accumulated mentions (regression from #244). Merge now parses the existing page's mentions and unions them with the new source's (composite (quote, source_path) dedup key) before injecting; fail-safe preserves hand-edited section verbatim.
  • Empty-response quiet path (PR #282). parseJsonResponse gained silentOnEmpty / throwOnEmpty options. Lint batch callers suppress noisy console errors for empty LLM bodies. Seed selector throws EmptyResponseError on empty body as defense-in-depth.
  • LM Studio no-key ingest (PR #269/272). initializeLLMClient, llmReady, testLLMConnection treat LM Studio like Ollama for the API-key gate.
  • Settings unified↔per-task cascade (post-#281 e2e). Three edge cases where toggling Model Scope could leave tempSettings and settings out of sync — fixed.
  • load-pages .md suffix defense (post-#281 e2e). Normalized path handling so wiki-page paths with or without .md suffix resolve consistently.
  • Streaming-chunk debug cleanup (post-#281 e2e). Removed stray console.debug in openai-compat-sdk-client.ts streaming path.
  • Tier C welcome-note recreate bypass (PR #271). recreateWelcomeNote and ensureWelcomeNote accept forceRecreate: true.

Aggregate stats

  • 1 release
  • Test count: 1825 → 2080 (+255 tests)
  • Composition + per-PR detail: git log --oneline 1.24.0..1.24.1

[1.24.0] - 2026-07-10

Theme: Per-task model routing, custom query instructions, four monolith splits, source-note aliases, frontmatter write repair. 1825 tests passing. Recommended upgrade for everyone on v1.23.x.

Added

  • Per-task Models (#208). Three independent settings (ingestModel, lintModel, queryModel) on top of the existing model. Switch via Settings → Wiki → Model Scope dropdown: Unified (one model for all tasks) or Per-Task (independent choice per ingest / lint / query). Empty per-task field falls back to settings.model, so existing v1.23.x data.json continues to work bit-identically. New core/model-resolver.ts (resolveModelForTask(settings, task)) is the single decision point used by all 28 LLM call sites; ui/settings-per-task-helpers.ts owns the UI-scope logic (mode resolution, displayed-model computation, preserve-on-toggle). Each picker uses a sentinel __custom__ ("Custom input…") — leaving the text input blank means "use unified model", matching the original picker behavior.
  • Test Connection multi-probe (#208). When usePerTaskModels === true, the Test Connection button now probes each configured model sequentially (ingest → lint → query) with fail-fast — until every per-task model passes, the connection is considered unhealthy. Console logs include [testLLMConnection] probe plan: ingest=…, lint=…, query=… for verification.
  • Custom Query Instructions (#251, jameses-cyber). Collapsible <details> panel inside the Query Wiki view, between the prompt and the history list. Appends user-supplied instructions to the system prompt at the three Query Wiki send sites (streaming, non-stream fallback, non-stream main). 5000-character defensive cap (centralised CUSTOM_QUERY_INSTRUCTIONS_MAX_CHARS). Strictly scoped to Query Wiki chat — ingest, lint, page generation, save-to-wiki evaluation, duplicate merge, and seed selection are intentionally unaffected. Persisted as customQueryInstructions?: string in data.json. Modes dropdown (Default / Research / Exact Facts / Commitments) + per-conversation override planned for v1.25.0+. Initial UI review used Settings → Query Wiki; shipped UI is the Query-local panel per user review.
  • First-query PPR warmup. Engine-level _cachedGraph (WikiEngine.getOrBuildGraph(allPaths)) loaded once on first query, invalidates on wikiFolder change or invalidatePageCaches. First query now uses Personalized PageRank instead of falling back to lex-only on cold start. QueryView.invalidateGraph() delegates to the engine.
  • fundingUrl in manifest. Adds "fundingUrl": "https://ko-fi.com/greenerdalii" to manifest.json per Obsidian manifest spec. Optional field; Obsidian-side display depends on Community Plugin UI surfacing.

Changed

  • modals.ts 1008-LOC split into directory (PR #257, 4b65450). src/ui/modals.tssrc/ui/modals/ with 7 focused files. External API unchanged (barrel index.ts re-exports). Required after the v1.23.0 P2 modals feature set pushed the file past the 1000-LOC threshold.
  • controller.ts runLintWiki god function split into 3 phase modules (PR #248, ef44a58). src/wiki/lint/controller.ts:runLintWiki (was a monolithic 200+ LOC function) decomposed into Phases A/B/C (src/wiki/lint/llm-phases/analysis-phase.ts, src/wiki/lint/llm-phases/scoring-phase.ts, src/wiki/lint/llm-phases/synthesis-phase.ts). The orchestrator now delegates: analysis → scoring → synthesis.
  • history-modal.ts 1579-LOC single file split into directory (PR #249, fe273a4). src/ui/history-modal.tssrc/ui/history-modal/ with 14 files (~250 LOC each max): types.ts, render-state.ts, HistoryModal-class.ts, 9 renderer modules under src/ui/history-modal/renderers/, and an index.ts re-export shim. External API (HistoryModal class, TEXTS-based HistoryTexts) unchanged. Zero caller-side changes required. 1610 tests passing.
  • query-engine.ts 1373-LOC monolith split into directory (PR #250, 3ff0cc6). src/wiki/query-engine.ts → 15 focused modules under src/wiki/query-engine/. QueryView.buildWikiContext (was 165 LOC inline) decomposes into 4 pure pipeline phases (read-index, load-pages, assemble-context, seed-selector). External API (QueryView, VIEW_TYPE_QUERY, renderThinkingBlocksUI) unchanged via TypeScript directory resolution. 1616 tests passing.
  • 28 LLM call sites wired through resolveModelForTask (#208, e96568e). Sourced via Sliced change-by-change across 11 production files: ingest (14 — source-analyzer, page-factory × 7, conversation-ingest × 4, wiki-engine.createSummaryPage, schema-manager, auto-maintain × 2), lint (9 — analysis-phase, dedup-phase, fill-empty-page, fix-dead-link × 2, fix-runners × 2, link-orphan, merge-duplicates, contradictions), query (5 — QueryView × 3 send sites + save-eval, seed-selector). Five settings.model direct reads intentionally preserved: Test Connection probe plan, 2 log metadata, console.debug, empty-model pre-flight. E2E observability: 6 console.debug lines show the resolved model at each major call site.
  • Source-note aliases propagation (#185, c0f0bc0). Frontmatter aliases: from source notes now propagates into generated sources/<slug> page frontmatter, so downstream [[wiki-link]] matching and alias-aware search reach every quote. Reduces "DSA ≠ DeepSeek-Sparse-Attention" type misses on cross-language aliases.
  • Tier-1 + Tier-2 merge triage (#216, b7bf5f0, DocTpoint). Classify-then-route duplicate-bypass decision: spurious Tier-1 candidates are skipped outright; Tier-2 runs only on the remainder. Reduces Lint merge batch size without sacrificing high-precision matches.

Fixed

  • Frontmatter write repair (4 user-reported bugs, 1d943ea). aliases:[] no longer falsely passes the alias-deficiency lint check; duplicate aliases are collapsed on write via the new replaceFrontmatterArrayField helper; block-style frontmatter is preserved (no longer flattened to inline) via the new mergeFrontmatterArrayField helper; write failures are now logged with the offending field name. Affects Smart Fix and merge paths.
  • Empty-line / trailing-blank-line fix for ## 相关实体 / ## 相关概念 sections (PR #260, 9793efd). Tier-2 per-section append normalized to use a single blank-line separator; previously produced double-blank or zero-blank depending on the input.
  • wikiFolder change propagation (1d943ea, 8d5baf3). saveSettings now invalidates the QueryView graph cache and WikiEngine pagesCache when wikiFolder changes; updateSettings drops the path-keyed caches on wikiFolder change. Stale history migration Notice explains that pre-v1.24.0 query history keeps its old folder paths (clearing history remains the escape hatch).
  • Retrieval label human-readable + persistence (#221 follow-up, b46f7b1 / 81813ae). Retrieval-label text now reads "Found N page(s)" instead of the internal cache key; label is persisted across view re-open.

Maintenance

  • 1825 tests passing (132 test files). 81 tests added during the v1.24.0 cycle.
  • 5 new i18n keys × 10 locales for the per-task model pickers + Model Scope dropdown + Test Connection labels.
  • 8 new i18n keys × 10 locales for the Custom Query Instructions collapsible panel.

[1.23.x PATCH series] - 2026-07-02 → 2026-07-05

Super-aggregated per Keep a Changelog spec. v1.23.2 (single PATCH) over 3 days. Per-PR detail preserved in git log --oneline 1.23.0..1.23.2.

Aggregate scope

  • Semantic progress notification module (#219). New core/progress-notification.ts with decideProgressDisplay(scope, isLong, hasUserAction). Manual operations show Notice + status bar; background operations (watch-mode auto-ingest, periodic lint, startup QuickFixes) show status bar only. Channel selection is derived from operation semantics — no user-facing setting.
  • Query turn indicator (#221). Right-edge vertical dots, one per conversation turn. IntersectionObserver highlights the currently visible turn; clicking scrolls that turn's question to the top via scrollIntoView({ block: 'start' }). Hover reveals the original question text in a tooltip.
  • Retrieval label click-to-expand. The 🔍 N page(s) · … label below each assistant response is clickable — clicking toggles an inline panel listing the retrieved pages.
  • Section header canonicalizer (DocTpoint, PR #241). core/section-header-canonicalizer.ts uses bounded Levenshtein distance to snap LLM-garbled section headers (e.g. Erwägungen…Erwähnungen in der Quelle) back to canonical labels on write. Eliminates silent drop from Tier-B retrieval in wikiLanguage: de clean re-ingest runs.
  • Dynamic lint/fix status bar. wikiEngine.updateStatusBar() is wired to the real Obsidian status bar element. Fix-runners' per-file progress messages (e.g. [3/10] fixing: file.md) reach the status bar during manual lint, watch-mode auto-ingest, and Smart Fix All.
  • wrapWithAdvancedSettings refactor. Replaced .bind() + in-place mutation with composition (Object.create(client) + explicit createMessage override). Preserves prototype chain — class-based SDK clients no longer fall back to non-streaming because spread { ...client } dropped createMessageStream.
  • buildPagesListForPrompt sources-filter (#234). Adds { excludeSources: true } default option. The LLM candidate list no longer includes wiki/sources/ pages.
  • Frontmatter serializer consolidation (DocTpoint, PR #238). mergeFrontmatter / enforceFrontmatterConstraints / mergeDuplicatePages delegate to a single serializeFrontmatter writer.
  • Lint completion Notices respect TTLs. All run*Fixes completion Notices and lintWikiFailed use NOTICE_NORMAL (5s) / NOTICE_ERROR (8s) instead of new Notice(msg, 0).
  • License upgrade to Apache 2.0 + DCO. NOTICE file lists all 6 human code contributors alphabetically. CONTRIBUTING.md includes a License & DCO section. Future commits require Signed-off-by:.

Aggregate stats

  • 1 release (v1.23.2)
  • Test count: 1378 → 1431 (+53 tests)
  • Composition + per-PR detail: git log --oneline 1.23.0..1.23.2

[1.23.0] - 2026-07-02

Theme: Replace the brittle hand-rolled LLM client (v1.22.x 1625-LOC llm-client.ts with 30+ provider-version workarounds accumulated since v1.20.0) with Vercel AI-SDK v6, then ship the Graph Engine PPR primitive on top. Biggest architectural change since 1.0.

Branch state: refactor/v1.23.0-ai-sdk-migration (38 commits ahead of main, 1376 tests passing, 3.17 MB bundle). Folds in the v1.22.6 hotfix series and P2-4 PPR tuning.

Added

  • Vercel AI-SDK v6 migration (P1-7). Replaced hand-rolled OpenAICompatibleClient / AnthropicClient / AnthropicCompatibleClient (1625 LOC) with @ai-sdk/openai@3 / @ai-sdk/anthropic@3 / @ai-sdk/openai-compatible@2 / ai@6. New src/llm-sdk/ (5 files, 1421 LOC: openai-sdk-client.ts 455 LOC, anthropic-sdk-client.ts 300 LOC, openai-compat-sdk-client.ts 449 LOC, token-key-probe.ts 70 LOC, create-llm-client.ts 151 LOC). src/core/obsidian-fetch-bridge.ts (326 LOC) provides activeDocument-aware fetch for jsdom. Deleted 8 old test files (2609 LOC). Eliminates the entire class of provider-version regressions (#137 / #141 / #143 / #147 / #207).
  • Graph Engine (Issue #198). Personalized PageRank over [[wiki-link]] graph — closes #117 (Query Wiki relevance), #157 (hub detection), #175 (link distinctiveness) with one primitive. core/monte-carlo-ppr.ts (Fogaras 2005 MC-PPR, 99 LOC) performs K short random walks per query page at O(K×L) cost independent of |V|. core/ppr-cascade.ts (213 LOC) orchestrates three-tier pipeline (lex fast path → LLM seeds → PPR walks). core/section-extractor.ts (Tier B zero-LLM, 173 LOC). core/hub-detection.ts (134 LOC). core/build-graph.ts (wiki-link graph builder, 13 unit tests).
  • Query Wiki three-tier pipeline (P1-5). Lex fast path → LLM seed selection (only when fast path is weak) → PPR walks. Reduces 99% of LLM seed-selection cost.
  • Hub-link distinctiveness scanner (P1-6, Issue #157 / #175). New lint pass that flags pages whose outgoing links mostly point to low-distinctiveness hubs. 229 LOC + 15 tests. Contributed by @DocTpoint.
  • Hub-retirement crystallization signal (PR #215, @DocTpoint). core/hub-retirement.ts (175 LOC + 12 unit tests + 136 LOC integration tests). Pure percentile-based verdict with dual absolute guards.
  • Unified URL fallback for custom baseURLs. core/url-fallback.ts (395 LOC) auto-resolves missing /v1 in user-entered baseURLs (Kimi Coding Plan, GLM, z.ai). Module-level static cache survives createLLMClient re-creation so Ingest / Lint / Query all benefit.
  • Token-key probe-then-retry (KISS, no regex). src/llm-sdk/token-key-probe.ts (70 LOC) caches working max_tokensmax_completion_tokens key per baseURL on first failure. Triggered by if (statusCode === 400 && !cached) → retry. Addresses root cause of #207 for all OpenAI-compatible gateways.
  • Real-time streaming for all providers (P2). result.textStream true逐块 streaming now works in all three llm-sdk clients. macrotask yield between chunks forces a paint frame per chunk (no more batch-arrival UX). Resolves user Q1 feedback.
  • Welcome note (Phase 5.1.5). Three-tier first-run Welcome note (Tier A empty / Tier B existing / Tier C upgrade). type: welcome frontmatter, createWelcomeNote toggle, Recreate Welcome Note command. D8 LLM dynamic translation writes the note in the user's wiki language at write time — no hardcoded i18n.
  • Multi-File Ingest (Issue #130). Two-pane picker: left = recursive folder tree with per-file checkboxes, right = live ingest queue with status. "Add to queue" two-step flow, per-file cancel, "Cancel all" for pending/running jobs. Reuses runBatchIngest so the per-file loop, dedup, and report modal are shared with folder ingest. New IngestQueue pub/sub store is the single source of truth for in-session ingest lifecycle.
  • LM Studio API-key gate (Issue #223). main.ts:962 now excludes both ollama and lmstudio from API-key validation. Local providers can test connection without an API key.
  • knn baseline analysis (P2-3 eval acceptance gate). DocTpoint ran a knn baseline (bge-m3, no graph) on the same sample-50page fixture per #198 follow-up: cascade R@5 27.1% vs knn 24.1% (3pp gap). Reinforces 2026-06-22 #175 rejection — embeddings permanently rejected.
  • i18n settings rewrite (10 locales). User-first language throughout ("disable thinking") instead of implementation details ("3-tier dialect fallback chain"). 14 new keys per locale for Welcome note + Ingest modal UI.
  • Sponsor section. Ko-fi button + 💖 Support the Project section in all 10 READMEs. https://ko-fi.com/greenerdalii.
  • P2-4 PPR tuning. Real vault (2142 pages) tuning across 6 iterations. Recommended parameters damping=0.05, numWalks=3000, walkLength=20 improve R@5 from 21.5% → 23.8% (+11% relative). See src/__tests__/fixtures/wikis/sample-50page/REAL_VAULT_EVAL.md.

Changed

  • Provider error body now reaches Test Connection UI. window.fetch re-fetch with 5s timeout captures the provider's diagnostic into the Notice. Replaces generic status 400 with e.g. "status 429: You exceeded your current quota".
  • Lint performance knobs centralised in src/constants.ts. Single-file tuning instead of 4-file drift across controller.ts / duplicate-detection.ts / preparation.ts / batch-limits.ts.
  • 429/5xx exponential backoff on Responses API path. Both Chat Completions and Responses API paths now share the same withRetry (3 attempts, 1s/2s/4s + jitter).
  • thinkingControlCache deprecated. Removed the 3-tier dialect probe; AI-SDK handles thinking internally. Cache retained on disk for backward-compat (will be removed in v1.24.0 if no use case surfaces).
  • Real-time streaming UX. Cascade + LLM seed retrieval improvements: reduced tokens per cascade round, tightened seed-selection prompt.
  • Welcome note refactor. Moved LLM config status from in-body text to frontmatter (hidden metadata). Local-check in Welcome note orchestrator (no LLM if config already valid).

Fixed

  • #207 — GPT-5.x models no longer fail Test Connection with 400. Full coverage including -pro variants (v1.22.5 / v1.22.6 hotfixes).
  • #204 — Auto Ingest no longer opens blocking modal. trigger='auto'|'manual' field on IngestReport / IngestOptions routes auto-ingest completion to onAutoIngestDone (Notice) instead of IngestReportModal.
  • #204 — Auto Smart Fix completion is context-aware. Same trigger pattern routes AutoMaintainManager.schedulePeriodicLint completion differently based on autoSmartFix setting.
  • #223 — LM Studio Test Connection no longer requires API key. Local providers excluded from the API-key gate.
  • generation_complete no longer stamped onto log.md / index.md / schema/ (v1.22.3, carried forward). isInWikiContentFolder() guard restricts the stamp to wiki/{entities,concepts,sources}/....
  • Real-time streaming was batched. Fixed via macrotask yield + result.textStream-only consumption (not fullStream then textStream, which buffered all events).

Tests

  • 1376 tests passing across 100 files (+272 since v1.22.0).

Risk Register

  • Bundle size 1.24 MB → 3.17 MB (user accepted 2026-06-29). Obsidian manifest has no size limit; lazy await import() for AI-SDK packages didn't reduce bundle (esbuild CJS inline); future ESM bundle / dynamic chunk can revisit.
  • #207 close decision: user will close manually after real-world testing — separate commit Closes #207, not part of v1.23.0.
  • #213 (configurable page categories): Discussion-only, NOT confirmed for any minor release per user instruction 2026-06-30. Requires broader community/architectural discussion.

[1.22.x PATCH series] - 2026-06-23 → 2026-06-30

Super-aggregated per Keep a Changelog spec + CLAUDE.md "ancient versions are pre-aggregated". 6 PATCH releases (v1.22.1 → v1.22.6) over 7 days. Per-PR detail preserved in git log --oneline 1.22.0..1.22.6 and memory files.

Aggregate scope

  • #204 — Auto-ingest modal suppression: v1.22.2 split onIngestDoneonAutoIngestDone with autoIngestNotificationLevel: 'notice' | 'modal' setting; v1.22.6 wires trigger: 'auto' | 'manual' through IngestReport so watch-mode ingests skip the blocking IngestReportModal. Same trigger pattern applied to runLintWiki for auto vs manual completion dispatch.
  • log.md header language-agnosticism: v1.22.3 replaced text-based detection with structural <!-- llm-wiki-log-header-start --> marker + moved all 10 locale header strings into src/texts/<lang>.ts; auto-migration via isOldFormatLogHeader() / migrateLogHeader().
  • generation_complete stamp scope narrowed: v1.22.3 added isInWikiContentFolder() guard so the stamp no longer pollutes log.md / index.md / schema/ files on every QuickFix run.
  • periodicLint cadence refined: v1.22.2 removed "Hourly" (unrealistic for LLM-based lint), added "Monthly"; auto-migrate hourly saves to daily on next plugin load.
  • #207 — GPT-5.x OpenAI Responses API routing: v1.22.4 introduced max_tokensmax_completion_tokens runtime probe-then-cache + provider error body enrichment; v1.22.5 added isResponsesApiModel() for gpt-5.1+ / o1-o4 reasoning family routing to /v1/responses with reasoning: { effort: 'low' }; v1.22.6 broadened the regex to cover gpt-5.x-pro variants. Test Connection Notice now surfaces provider's actual error body (not bare status code). 429/5xx exponential-backoff retry extended to Responses API path.
  • GPT-5.x-pro path correctness (v1.22.6 follow-up). Regex broadened to ^(gpt-5\.[1-9]\d*(?:-pro)?|o1(?:-mini|-preview)?|o3(?:-mini|-pro)?|o4-mini)$; gpt-5-chat-latest exclusion preserved.
  • Lint performance knobs centralised in src/constants.ts: v1.22.4 unified yield cadences (LINT_YIELD_EVERY_OUTER / _PHASE1 / _COMPARISON), candidate batch sizing, prep batch read, and source-analyzer batch sizing — eliminated drift across controller.ts / duplicate-detection.ts / preparation.ts / batch-limits.ts.

Aggregate stats

  • 6 releases
  • Test count: 1054 → 1118 (+64 tests across the series)
  • Composition + per-PR detail: git log --oneline 1.22.0..1.22.6 + memory files

[1.22.0] - 2026-06-23

Added

  • #97 — One-click schema apply with IDE-style diff Modal + auto-backup. SchemaDiffModal class (dual-pane IDE-style diff, Apply/Cancel/Open file buttons, Regenerate hidden for v1.22). applySchemaSuggestion() with auto-backup to .llm-wiki-backups/schema/ (rotation MAX_BACKUPS=3 via core/backup-rotation.ts). lineDiff() LCS algorithm in core/diff.ts. Lint "Update Schema" button removed from command palette — schema updates flow through Lint Modal only (single entry point).
  • Schema dynamic tag sync. Schema vocabulary is now the single source of truth; tag vocab injected into generation prompts via SchemaContext + buildSchemaSectionTemplate. parse-suggestion.ts for structured LLM response parsing.
  • Traditional Chinese (zh-TW) locale. 10th language (zh-Hant). Parity guard extended to all 10 locales (bidirectional). 8 new i18n keys per language for schema diff modal.
  • #189 — Ingest status bar shows document name + batch progress (PR by @YounianC). Single-file ingest displays <doc> · Ingesting... click to cancel instead of the bare label. Folder batch ingest shows [current/total] <doc> · Ingesting... click to cancel. New pure-function core/status-bar.ts (buildIngestStatusBarText) composes from the existing localized ingestionStatusBar label — no new i18n keys, all 10 locales covered automatically. WikiEngine ingestion-start callback now passes the source basename (optional param, backward-compatible). batchProgress field in main.ts tracks loop position.

Fixed

  • merge.ts hardcoded English section headers (#188). Both mergeEntityPage and mergeConceptPage prompt templates used hardcoded ## Related Entities / ## Related Concepts / ## Basic Information / ## Description / ## Mentions in Source headers, ignoring the configured wikiLanguage. Replaced with {{section_*}} placeholders so applySectionLabels() localizes them consistently across create and merge paths. Non-English vaults no longer get mixed-language section headers.
  • appendAliases block-replace regex left stale items (#186). page-factory.ts:70 regex /^aliases:[\s\S]*?(?=\n\S|\n*$)/m — the m flag caused $ to match end-of-line, so the lookahead succeeded immediately and the lazy quantifier matched zero characters. Only the bare aliases: line was replaced; existing list items survived, producing duplicate entries on every subsequent append. Fixed with /^aliases:[^\n]*(?:\n[ \t]+[^\n]*)*/m which consumes continuation lines by indentation.
  • Lint: apply-suggestion.ts used vault.delete() fallback. Simplified to direct app.fileManager.trashFile call — respects user's file deletion preference per Obsidian review rule obsidianmd/prefer-file-manager-trash-file. Test mock updated accordingly.
  • Lint: parse-suggestion.ts unnecessary type assertion. as LLMSchemaResponse cast removed (receiver already accepts the original type).

Tests

  • 1006 tests passing (was 948 in v1.21.1; +58: schema suite 48 tests + status-bar suite 7 tests + #186/#188 regression tests 3 tests).

[1.21.x PATCH series] - 2026-06-21 → 2026-06-22

Super-aggregated per Keep a Changelog spec. v1.21.1 (single PATCH) over 1 day. Per-PR detail preserved in git log --oneline 1.21.0..1.21.1.

Aggregate scope

  • #173 Symptom A — createOrUpdateFile create-retry loop. When getAbstractFileByPath returned null (e.g. macOS NFC/NFD normalization mismatch), the 3-attempt loop kept calling vault.create instead of first resolving via resolveFileInVault. Now resolves at the earliest attempt, eliminating 3× failed retry overhead. Contributed by @Indexed-Apogrypha (reporting).
  • esbuild 0.28.0 → 0.28.1. Patches GHSA-g7r4-m6w7-qqqr (low severity, dev-only arbitrary file read on Windows).

Aggregate stats

  • 1 release (v1.21.1)
  • Composition + per-PR detail: git log --oneline 1.21.0..1.21.1

[1.21.0] - 2026-06-21

Added

  • Pre-ingest requirements gate (#164). Every source file is now validated before any LLM call — non-empty, compatible file type, and unique — and files that fail are logged and skipped instead of reaching the model. New core/source-requirements.ts holds an extensible, ordered CONTENT_CHECKS registry so future checks (e.g. prompt-injection) can be added as a single entry. Contributed by @Indexed-Apogrypha.
    • Non-empty (isBlankSource): empty, whitespace-only, and frontmatter-only notes are skipped — closing the #164 root cause where small/local models (e.g. Ollama) hallucinated entities/concepts from blank content interpolated into the extraction prompt.
    • Compatible file type: case-insensitive allowlist ['md', 'markdown', 'txt', 'text']. Folder and active-file ingest now accept .txt/.text (was .md-only).
    • Uniqueness (hashBody): content-hash de-duplication (length-prefixed FNV-1a over the normalized body) catches duplicate content even across different file paths, plus within-batch dedup for folder and watcher ingests (both share one createBatchContext()); the hash is stamped into the source page frontmatter as contentHash.
  • Re-ingest confirmation prompt. Interactive ingests (file picker / active file) prompt before re-ingesting a duplicate (new ConfirmModal); folder/watcher ingests auto-skip duplicates. The ingest report now lists skipped files with a localized reason (empty / unsupported type / duplicate content). New i18n keys across all 9 locales. Contributed by @Indexed-Apogrypha.
  • Operation History Panel (#122). Pure-function parseLogEntries + HistoryModal with date grouping, search, filter, clickable page links, and insight-driven visualization. Command palette entry + settings entry.
  • Schema Coherence Phase 1 (#124). SchemaContext shared parsed representation of schema/config.md, used by both system prompts and generation prompts. buildSchemaSectionTemplate extracts user-defined sections. Tag vocabulary injection into system prompt.
  • Incomplete-page cleaner (#170). Wiki pages left in a partial state (interrupted ingest, plugin reload mid-write, LLM error) are automatically cleaned on startup via generation_complete frontmatter flag + QuickFixes Phase 3 self-scan. Pages without the field are treated as legacy (preserved).
  • Italian locale (#159). 9th language added to UI and wiki output. Contributed by @FrancoTampieri.

Fixed

  • Empty notes made small/local LLMs fabricate wiki pages (#164, CRITICAL). Ingesting an empty / whitespace-only / frontmatter-only note no longer produces fabricated entity/concept pages (large models refused the blank input, so it never surfaced in dev). A defense-in-depth isBlankSource guard was also added in source-analyzer.ts before the extraction prompt is built. Contributed by @Indexed-Apogrypha.
  • Hardcoded Chinese error string leaked into non-Chinese UI (#172). wiki-engine.ts createOrUpdateFile final-fallback throw now uses getText('fileWriteFailed') with 9-locale i18n coverage.
  • Duplicate entry in createdPages inflated report count (#173). dedupPages() pure-function helper prevents duplicated surface-forms from inflating the ingest report "Created" listing.

Tests

  • New coverage for the gate: core/source-requirements, isBlankSource/upsertFrontmatterField in core/frontmatter, the #164 reproduction in wiki/source-analyzer, and a new in-memory WikiEngine ingest-gate harness (wiki/wiki-engine-ingest).
  • Watcher batch-context wiring (schema/auto-maintain) and the buildIngestedHashes TTL-cache + write-invalidation paths (wiki/wiki-engine-ingest).
  • Incomplete-page cleaner tests (core/incomplete-page-cleaner): isIncomplete, findIncompletePages, cleanIncompletePages.
  • i18n error message assertion (wiki/wiki-engine-i18n-error).
  • dedupPages ordering/edge-case tests (wiki/wiki-engine-dedup).
  • 939 tests passing (was 791 in v1.20.3). +148 tests, 67 test files.

[1.20.x PATCH series] - 2026-06-18 → 2026-06-20

Super-aggregated per Keep a Changelog spec. 3 PATCH releases (v1.20.1 → v1.20.3) over 2 days. Per-PR detail preserved in git log --oneline 1.20.0..1.20.3.

Aggregate scope

  • #141 / #147 — Anthropic prefill rejection on newer Claude models: v1.20.1 detected the 400 "Prefilling assistant messages is not supported for this model" rejection (Claude Opus 4.8 / 4.7 / 4.6, Sonnet 4.6, Claude Fable 5, Claude Mythos 5 / Preview), cached per-client, and auto-retried without prefill. v1.20.2 also fixed the fallback path: Anthropic's Messages API only accepts user/assistant roles in messages — the no-prefill retry had been putting system into messages causing a second 400 that masked the real fix. All 4 Anthropic fallback paths now use top-level body.system instead.
  • #154 — mergeFrontmatter alias dedup on re-ingest: v1.20.3 fixed unbounded alias array growth on repeated re-ingests (one real-world page had ~15× duplicate alias block / 86 duplicate lines). Mirrors the dedup contract in enforceFrontmatterConstraints (first occurrence wins, empty strings dropped). Contributed by @DocTpoint.
  • #155 / PR #156 — Source provenance slug collision: v1.20.3 added <basename>_<6hex FNV-1a of full path> slug derivation. Two source files sharing a basename across folders (e.g. 11× About this course.md across Academy courses) no longer silently overwrite each other; [[sources/<slug>]] backlinks resolve to the correct source. Pure core/source-slug.ts module. Contributed by @Indexed-Apogrypha.
  • PR #158 — updateRelatedPage ignored reviewed: true lock on Stage-4: v1.20.3 routed reviewed: true pages to appendToReviewedPage (parity with createOrUpdatePage) so re-ingesting an unrelated note cannot LLM-rewrite a curated reviewed page body. Contributed by @DocTpoint.
  • PR #156 follow-up — tsconfig housekeeping: lib bumped to ES2021 (so trimEnd resolves cleanly under newer TS language servers); vestigial baseUrl dropped (clears TS 6/7 deprecation warning).

Aggregate stats

  • 3 releases
  • Test count: 771 → 791 (+20 tests across the series)
  • Composition + per-PR detail: git log --oneline 1.20.0..1.20.3

[1.20.0] - 2026-06-18

Added

  • Collapsible thinking UI in Query Wiki. When thinking-capable models (DeepSeek, etc.) return reasoning content, it's displayed in a collapsed 💭 Thinking process panel above the answer (ChatGPT/Claude.ai style). Fully localized in 8 languages.
  • extractThinkingBlocks() pure function in core/markdown.ts — extracts <think> and <thinking> blocks from LLM responses.
  • wrapReasoningContent() pure function — encodes reasoning_content into <think> tags with escaping for nested closing tags.
  • renderThinkingBlocksUI() — DOM construction for collapsible thinking panel with localized labels.
  • DeepSeek reasoning_content extraction. SSE parser extracts reasoning_content from OpenAI-format deltas. Both streaming and non-streaming paths prepend reasoning as <think> tags for the thinking UI.
  • PROTECTED_FIELDS whitelist in OpenAICompatibleClient — prevents model, messages, stream from being stripped by unsupportedFields even if a 400 error mentions them.

Changed

  • Provider-first thinking control (default disableThinking: false). The plugin no longer sends any thinking-control field by default — the provider decides its own reasoning behavior. Old default was true (sent thinking.type='disabled'). Users who explicitly want to suppress thinking can enable "Disable thinking" in Custom Advanced Settings, which triggers the 3-tier dialect fallback.
  • enableThinking spread consistency. All 22 LLM call sites now use ...(ctx.settings.disableThinking ? { enableThinking: false } : {}) — page-factory, contradictions, conversation-ingest were missing the spread (had comment-only placeholders).
  • AnthropicClient baseUrl normalization. Constructor now strips trailing /v1 and re-appends it, preventing double-path /v1/v1 (fixes #141, #134).
  • listModels() uses this.baseUrl. Anthropic listModels() no longer hardcodes https://api.anthropic.com/v1/models.
  • isGpt5 prefix check tightened. startsWith('gpt-5')=== 'gpt-5' || startsWith('gpt-5-') to avoid matching future unrelated models.
  • .includes('<think') guard is now case-insensitive. Uses .toLowerCase() to catch <Thinking> variants.
  • v1.20.0 migration in loadSettings(). Resets disableThinking from true to false and advancedSettingsMode to 'default' for existing users.

Fixed

  • gpt-5 max_completion_tokens (Issue #143). GPT-5 series models now use max_completion_tokens instead of max_tokens. Truncation retry also preserves the correct token key.
  • Truncation retry loses reasoning_content. extractText callback now wraps retry response's reasoning_content via wrapReasoningContent.
  • Streaming path missing final render. After createMessageStream returns, the full response (including <think> tags) is now rendered via renderMarkdownContent — thinking content was previously only available during non-streaming path.
  • Non-streaming fallback missing chatTemperature. The fallback path when streaming fails now includes the user's configured temperature.
  • if (fullResponse) dropped empty responses. Changed to !== undefined/null guard to handle empty-string responses.
  • Query Wiki respects wikiFolder. Prompt templates and defense-in-depth normalization replace hardcoded wiki/ paths.
  • Query Wiki auto-scroll. Chat scrolls to bottom on open.
  • User message right-align. User bubbles use flex-end alignment with accent background.

[1.19.x PATCH series] - 2026-06-16 → 2026-06-17

Super-aggregated per Keep a Changelog spec. v1.19.1 (single PATCH) over 1 day. Per-PR detail preserved in git log --oneline 1.19.0..1.19.1.

Aggregate scope

  • Gemini HTTP 400 on ingestion (Issue #137). Added a 3-tier thinking-control dialect fallback chain (anthropic → openai → none) so OpenAICompatibleClient auto-discovers the correct field name (thinking.type='disabled' vs reasoning_effort='none' vs none) per baseUrl. Result is cached on the client + in data.json so subsequent requests skip the 400 probe round-trip. thinkingControlCache schema toggles from boolean to dialect string ('anthropic' | 'openai' | 'none').
  • Settings tab auto-save wiped thinkingControlCache on every close. LLMWikiSettingTab.hide() and the explicit Save button used shallow { ...tempSettings } spread that dropped thinkingControlCache. Fix: extract commitTempSettings() helper that preserves untracked probe-mutated fields.
  • Generic 400-field rejection retry (temperature, repetition_penalty, etc.). parseUnknownFields() extracts rejected field names from Gemini-style 400 bodies; unsupportedFields Set pre-strips them on subsequent requests. retryBodyWithStrippedFields() helper deduplicates strip-and-retry logic across non-stream and stream paths.
  • Stream path field-strip retry was dead code. createMessageStream's doRequest lacked an inner 400 catch block; added the same catch+populate pattern that the non-stream path uses.
  • [DEBUG-400] firing on 429 quota errors. Limited to 400-class errors only; 429/5xx go through standard withRetry backoff without the re-fetch overhead.
  • Fallback notices always in English. queueFallbackNotice() hard-coded TEXTS.en; the 3 newly-added fallback notice keys were present in all 8 locale files but never used. Fixed: OpenAICompatibleClient now has a language field wired by createLLMClient.
  • Advanced LLM Settings moved above Test Connection in the settings panel.
  • 400-path diagnostic output silenced from console.error to console.debug.
  • Simplify cleanup: IS_400 regex extracted as module-level constant; retryBodyWithStrippedFields deduplicates strip+change-detect; applyThinkingDialectFallback reuses buildRequestBody; commitTempSettings() extracted; probe success/failure cache write clarified.

Aggregate stats

  • 1 release (v1.19.1)
  • Test count: 728 → 744 (+16 tests, 0 regressions)
  • Composition + per-PR detail: git log --oneline 1.19.0..1.19.1

[1.19.0] - 2026-06-16

Added

  • Compact slug list in analyzeSource prompt (Issue #116). New buildCompactSlugList() injects a sorted slug-only list of existing wiki pages into the prompt so the LLM uses exact paths when creating [[links]], reducing dead-link slug mismatches caused by the verbose 40K-char index cap. Previously, only the first ~50 pages fit. Contributed by @DocTpoint.
  • Quote-grounding lint scanner (Issue #126). New scanQuoteGrounding() pure function verifies that every quote under ## Mentions in Source can be found in the linked source file. Supports both current "quote" — [[sources/slug]] format and historical bare quotes (scans all source files if no link is present). Tier 1 = exact substring match; Tier 2 = normalized (case-fold, punctuation stripped, whitespace collapsed). Report-only, zero token cost. Contributed by @DocTpoint.
  • Advanced LLM parameter settings (Issue #128). Collapsible "Advanced parameter settings" section in LLM Configuration with a Default/Custom mode selector. Default mode keeps all advanced parameters hidden and "disable thinking" on — the right choice for most users. Custom mode reveals the thinking toggle, extraction temperature (range 0–2), query temperature (range 0–2), and repetition penalty (range 0–2). Only sent to the LLM when the user sets a value — cloud providers that ignore the field fall back to their own defaults. The disableThinking field name is preserved in data.json for backward compatibility; production code passes the affirmative enableThinking form internally.
  • Reasoning-only response detection (Issue #99). OpenAICompatibleClient.createMessage now detects when the model returns an empty response with high reasoning tokens (content == '' && finish_reason == 'length' && reasoning_tokens >= 50% of completion_tokens) and throws an actionable error prompting the user to check the disable-thinking toggle or switch models. Also adds automatic 400 fallback: when the provider rejects thinking.type='disabled', the client retries with chat_template_kwargs: {enable_thinking: false} (auto-fallback, no separate user toggle).
  • Status bar mirrors popup during ingest and lint (Issue #110). All ingestion progress messages and lint checkpoints now update both the popup Notice and the Obsidian status bar simultaneously. makeMirroredNotice.hide() clears the status bar text. Fix-runner Notices mirror every setMessage() call to the status bar. Contributed by @dmarchevsky.
  • Auto Smart Fix setting (PR #109). When enabled, lint automatically runs all Smart Fix phases after analysis completes without showing the report modal. Default: off — existing users see no behaviour change.
  • Sources normalization in write path (PR #127). fixPollutedSources() is called from the centralized write chokepoint (WikiEngine.createOrUpdateFile()), so every generated/merged page gets a normalized sources: field. Contributed by @DocTpoint.

Changed

  • Startup quick-fixes Notice simplified. Removed heavy emoji icons and ━━━━━━━━━━━━━━━━ separators; cleaner layout with plain text prefixes. Logs now use English consistently.
  • Lint report summary now includes ungroundedQuotes and tagViolations counts. The report header line shows all current dimensions.
  • Ungrounded quotes section in Lint report. When scanQuoteGrounding finds issues, a new "Ungrounded quotes" section appears in the programmatic findings report.
  • lintTagViolationSection i18n completed. Previously 7 non-English locales showed English placeholder — now fully translated (de/es/fr/ja/ko/pt/zh).
  • Language dropdown labels simplified. Labels now use each language's native name only (e.g. 中文, 日本語, Deutsch) without English sub-labels.

Fixed

  • Advanced settings mode dropdown did not render controls. The onChange handler was missing this.display() (contrast with Tag Vocabulary dropdown which called it). Fixed: choosing "Custom" now properly reveals thinking toggle, temperature, and penalty inputs.
  • Misleading watchedFolders debug logs removed. loadSettings/saveSettings no longer print watchedFolders content, preventing confusion when autoWatchSources is off.
  • Previously-merged PR #110 "click to cancel" status bar affordance. UX fix by @dmarchevsky in PR #110: status bar now shows locale-specific "click to cancel" throughout ingest/lint/fix operations.

Performance

  • Stage 4 no-op skip (PR #131 Tier 1). PageFactory.updateRelatedPage skips the LLM call when new_info resolves to the 'No directly relevant information' fallback string. Removes ~33% of Stage 4 LLM calls. Still updates frontmatter sources + updated programmatically. Contributed by @DocTpoint.

Refactored

  • lint-controller modularization. Extracted phases/preparation.ts, phases/programmatic.ts, report-builder.ts, types.ts from the monolithic controller. lint-controller.ts went from 1069 → 897 lines. 17 new unit tests (728 total).
  • schema-analyze moved to schema/ directory. src/wiki/schema-analyze.tssrc/schema/analyze.ts.
  • LintContext extracted to lint/types.ts. Breaks the latent import cycle between fix-runners.ts and lint-controller.ts; fix-runners now imports from ./types.
  • lint-controller + lint-fixes moved into lint/ directory. src/wiki/lint/controller.ts (was lint-controller.ts), src/wiki/lint/fixer.ts (was lint-fixes.ts). All internal imports updated.

[1.18.x PATCH series] - 2026-06-11 → 2026-06-12

Super-aggregated per Keep a Changelog spec. 2 PATCH releases (v1.18.1 + v1.18.2) over 1 day. Per-PR detail preserved in git log --oneline 1.18.0..1.18.2.

Aggregate scope

  • Custom extraction limits not hard-enforced (Issue #120, v1.18.2). When extractionGranularity was set to custom, the customEntityLimit / customConceptLimit settings were only enforced as soft prompt hints — the LLM routinely returned 12-25 items for a configured cap of 8. After all batches are accumulated and immediately before buildSourceAnalysis(), slice both accumulation.entities and accumulation.concepts to the configured limits. The first N items in extraction order are preserved. No behavior change for default / 1-5 granularity modes.
  • Obsidian Community Plugin review compliance (v1.18.1). Removed document fallback and eslint-disable comments referencing obsidianmd/prefer-active-active-doc from production code. activeDocument stub centralized in test setup file. No user-visible behavior change.

Aggregate stats

  • 2 releases
  • Composition + per-PR detail: git log --oneline 1.18.0..1.18.2

[1.18.0] - 2026-06-11

Added

  • User-Controlled Tag Vocabulary (Issue #85) — chip input UX + end-to-end pipeline (v6). Wiki admins in medical, legal, R&D, and other professional domains can now define a controlled vocabulary for entity/concept frontmatter tags and the LLM actually uses it. The new "Tag Vocabulary" sub-block (embedded in Wiki Configuration — no separate heading) has a Vocabulary Mode dropdown:
    • Default — preserves the original hardcoded subtype tags (person/organization/… for entities, theory/method/… for concepts). The dropdown description now shows the concrete default list inline: Default uses built-in tags: person, organization, project, … (entities) / theory, method, … (concepts).
    • Custom — two chip inputs (Custom Entity Tags + Custom Concept Tags). Add via Enter / , / ;, remove via × click or Backspace on empty input. Nested tags with / (e.g. Arzneimittel/Neurologie) are preserved. Whitespace is trimmed, empty entries filtered, duplicates (case-insensitive) are silently skipped with a brief shake animation. CJK IME composition is respected (event.isComposing guard). Defaults are editable baseline (not preview) — when the persisted custom CSV is empty, the chip input materializes the default vocabulary as fully-editable chips.
  • 🔴 v6: End-to-end prompt injection. New buildActiveTagVocabularySection() + appendTagVocabularyToPrompt() helpers inject the active vocabulary into ingestion (source-analyzer), page generation (page-factory × 3 sites: new page, merge, rebuild), and lint analyze (lint-controller). The LLM now knows exactly which entity/concept types are valid and stops inventing its own. Before v6, the user-defined vocabulary was only used for post-hoc validation; the LLM kept inventing subtype names that got silently dropped at write time.
  • 🔴 v6: Preserve LLM intent on write. enforceFrontmatterConstraints no longer silently drops out-of-vocab tags. It retains all LLM-emitted tags (with a console.debug note when the vocabulary diverges) so the user can see exactly what the model produced and can decide whether to expand their custom vocabulary. Fallback to DEFAULT_ENTITY_TAG / DEFAULT_CONCEPT_TAG only when the tags array is genuinely empty.
  • v1 → v2 migration runs on onload(). New cleanupVocabularyTags() reads customEntityTags / customConceptTags, normalizes them via normalizeVocabularyCsv (trim, dedupe case-insensitively, drop empty), and writes back to data.json so existing users see clean chips on first reload.
  • getActiveEntityTags / getActiveConceptTags pure helpers in utils.ts — the single source of truth for "which tags are valid right now". All call-sites (page-factory, lint-fixes × 2) pass this.ctx.settings.
  • 🔴 v7: Programmatic tag audit + LLM-assisted retag. New scanTagViolations() (pure function in src/wiki/lint/scanners.ts) walks every entity/concept/source page in the wiki at Lint time and reports any page whose frontmatter.tags array contains at least one value not in the active vocabulary. Zero token cost, <50ms on 2000-page vaults. The Lint Report Modal gets a new "🏷️ Retag N page(s) with LLM" button that calls runRetagViolations() (in src/wiki/lint/fix-runners.ts): the LLM is given the page's first-paragraph summary + the active vocabulary section (via appendTagVocabularyToPrompt() from v6), and returns a new tags: string[]. The runner re-validates every returned tag against the active vocabulary (defensive), and only the tags: line of the frontmatter is rewritten — the body is byte-identical. Source pages get a static VALID_SOURCE_TAGS vocabulary (paper / article / book / transcript / clippings / notes / other) — NOT user-configurable per Issue #85 v7 design decision. Smart Fix All now runs retag as Phase 5 (after duplicates / orphans / empty pages).
  • enforceFrontmatterConstraints source-page branch now validates against VALID_SOURCE_TAGS (previously: [] = no validation). Page writes still succeed even with out-of-vocab tags thanks to v6's preserve-LLM-intent behavior (only a console.debug note when divergence is detected).
  • Default vocabulary cross-discipline optimization (v8). Entity locationplace for more natural semantics; Concept +field, +phenomenon, +standard, -technology for better distinction; Source -document (overlapped with article), notes retained. Full backward compatibility via v6 preserve-LLM-intent — removed tags survive in existing frontmatter, flagged by Lint audit for optional LLM-assisted retag.
  • Reviewed-guard (D4 design). enforceFrontmatterConstraints now respects fm.reviewed: true: when a user has marked a page as reviewed, their tag intent (including intentionally empty tags: []) is preserved — the function does NOT auto-fill tags: [other]. Only LLM-hallucinated dates are still stripped (date fields are strictly programmatic). Aligns with existing reviewed-aware code paths (lint-fixes.ts:439, page-factory.ts:288/308, prompts/generation.ts:206-241).
  • 🔴 Layer A complete: disableThinking propagation (Issue #99 v2). The v1.16.2 three-layer defense added disableThinking parameter to the LLM client interface but ZERO of ~22 production createMessage calls passed it. This release completes the wiring: disableThinking is declared in LLMWikiSettings (default true), and all 22 createMessage/createMessageStream calls across 7 engine files now pass disableThinking: settings.disableThinking. Thinking-capable models (Gemma 4, DeepSeek-R1, QwQ) receive thinking: { type: 'disabled' } on every call, preventing mid-response CoT and duplicated body output at the source.
  • AnthropicClient fallback for thinking-mandatory models. Unlike OpenAICompatibleClient which already had try/catch fallback from v1.16.2, AnthropicCompatibleClient and AnthropicClient would throw unconditionally when a provider rejects thinking.type='disabled' (e.g. Claude Fable 5 / Mythos 5). Both clients now wrap the request in try/catch: on 400 + disableThinking=true + isThinkingControlError(), they cache thinkingControlSupported=false and retry the request WITHOUT the thinking field. The redundant ~70-line duplicated request/parse/withTruncationRetry block was refactored into a shared anthropicDoRequest helper.

[1.17.0] - 2026-06-08

Added

  • Long-document ingestion now works end-to-end. Previously, sources over ~200KB were unprocessable due to a hardcoded batch size of 15 items in custom granularity and a max_tokens cap that truncated large responses. The same 619KB Chinese source (史记 / Shiji) that previously failed after 3 minutes and 15 items now completes fully, extracting hundreds of entities and concepts. Key enablers:
    • Custom granularity now dynamically scales initialBatchSize and maxBatchesBase from the user's customEntityLimit + customConceptLimit (was hardcoded to 5/1, capped at 15 items). For caps of 300+300: batchSize=50, maxBatchesBase=12, up to 36 effective batches.
    • max_tokens now scales with batch size (base: 16K → 20K for 50-item batches; retry cap: 60K), avoiding the silent truncation that previously caused later batches to fail with malformed JSON.
    • Truncation retry: if a non-first batch's response is truncated, the system halves the batch size and retries once instead of aborting the whole ingestion.
  • Source pages inherit tags from source note frontmatter (Issue #90). The LLM used to inject arbitrary concept names (e.g. Alzheimer-Demenz, Neuroprotektion) into source pages, polluting the user's tag vocabulary. New extractSourceTags() pure helper reads the source note's frontmatter tags and passes them directly to the summary-page template, falling back to LLM-derived names only when the source has no tags.
  • Default Schema documents the new contracts. Three new sections were added to the default wiki-folder/schema/config.md:
    • ## Source Page Template — mandates tag inheritance from source note, no LLM-derived tags.
    • ## Date Fields — documents that created/updated are filled programmatically (the LLM may produce wrong dates; the system overrides them).
    • ## Mentions Format — academic-footnote style: - "verbatim quote (optional translation)" — [[source-path|display-name]]. Existing user schema files are NOT overwritten; only regenerateDefaultSchema() writes the new template.
  • Lint report persistence with minute-precision timestamps. Lint now writes the full report to wiki-folder/log.md before showing the modal, with a 📋 Full report saved to log.md hint. Log entries have minute-precision timestamps (e.g. [2026-06-08 14:35]) so multiple Lint runs on the same day are distinguishable. The Lint Report Modal also points to the persisted log.
  • Custom granularity upper bound raised from 300 to 500 to support professional knowledge bases (legal, medical, deep research). 8-language i18n text updated accordingly.

Changed

  • Mentions are now footnote-style with explicit source attribution. The "Mentions in Source" section in entity/concept pages now renders each verbatim quote as - "quote" — [[source-path|display-name]], replacing the previous free-form block of untraced quotes. The source link makes every quote traceable to its origin, so future page merges can never mix up which quote came from which source.
  • Setting description for custom entity/concept limit now reads "1-500" (was "1-300") in all 8 languages to match the new hard cap.
  • Test connection no longer persists broken config on failure. When "Test Connection" fails, the previously-saved settings are restored and a 2nd saveData() call re-persists the original. Prevents the user from accidentally saving settings that the test proved broken.

Fixed

  • Provider settings no longer fail to propagate. Switching Provider/API Key/Model in Settings used to fail to reach the wiki engine, so the next Ingest/Lint/Query would silently use the old provider. Root cause: settings.ts was replacing plugin.settings with a NEW object (from tempSettings spread), but the EngineContext passed to all submodules captured the OLD reference at construction time. Fix: WikiEngine.updateSettings() now keeps the EngineContext.settings reference in sync, and all settings paths (saveSettings, test connection, language switch) call it.
  • LLM-hallucinated dates in frontmatter are now stripped. The LLM sometimes invents wrong dates (e.g. a 2025 date on a 2026-06-08 ingestion). enforceFrontmatterConstraints now strips LLM-generated created/updated lines and replaces them with programmatic values: created is preserved on merge (older value kept), updated is always set to today. 3 new TDD tests cover: preserves created, forces updated, adds when missing.
  • Long-source Notice no longer blocks the UI. Was new Notice(..., 0) (persistent, never auto-hides). Now NOTICE_NORMAL (5-second auto-hide) so the user isn't stuck with a forever-visible notice.
  • Lint dedup progress "1/1/1" display bug. The progress template was 批次 {current}/{total} but progressLabel was already passed 1/1 (with the total), causing duplication. Removed the extra /{total} substitution.
  • Folder ingest setDoneCallback not restored on early return. If ingestCount === 0 (no new files), the method returned early without restoring the callback, so subsequent folder ingests used a wrong callback. Now restored before the early return.
  • 5 audit-discovered issues (test settings pollution on connection failure; custom-scaling edge cases; repair-call max_tokens insufficient; constant duplication; comment misleading). All resolved with explicit Gate-4 performance verification.

Closes: #90 — Source pages now inherit tags from the source note frontmatter instead of LLM-generated concept names.

  • Small Schema / prompt / i18n cleanups (new lintLogReference i18n key in 8 languages; prompt updates for the new mentions format; pure helper extractions: extractSourceTags, truncateMentions with sourcePath parameter).

Tests

  • 38 new tests added (549 → 587): 7 in batch-limits.test.ts, 6 in truncateMentions block of utils.test.ts, 3 in enforceFrontmatterConstraints block, 6 in extractSourceTags block, 1 in default-schema.test.ts, plus updates across reorganized test folders. Test suite: 28 files, 587 tests, 0 regressions.

[1.16.x PATCH series] - 2026-06-04 → 2026-06-07

Super-aggregated per Keep a Changelog spec. 3 PATCH releases (v1.16.1 + v1.16.2 + v1.16.3) over 3 days. Per-PR detail preserved in git log --oneline 1.16.0..1.16.3.

Aggregate scope

  • #95 (Anthropic CORS, v1.16.1). Removed @anthropic-ai/sdk (1.3MB) and rewrote AnthropicClient on Obsidian's requestUrl. SDK's internal fetch from app://obsidian.md origin was intermittently blocked by CORS. Prompt caching (cache_control: ephemeral) preserved by emitting the same JSON structure in the raw request body. Streaming is now post-hoc SSE (parseSSEEvents).
  • PR #87 (lowercase slugs, v1.16.1). computeSlug() now lowercases output, preventing case-variant duplicate page creation on case-sensitive filesystems. Removed redundant .toLowerCase() calls in matchExtractedToExisting and conflict-resolver.ts:slugMatchKeys (centralized in computeSlug).
  • PR #87 (case-variant detection, v1.16.1). New caseVariant signal in generateDuplicateCandidates catches pages with case-colliding titles (e.g., Unix vs unix). Wired as Tier 1 in lint-controller.ts.
  • PR #88 (lint false positives, v1.16.1). New bodyWordSet() with BODY_STOPWORDS (45 English function words) gates sharedLinks duplicate candidates by body-text similarity (threshold ≥ 0.2). 20+ unit tests cover English + CJK edge cases.
  • PR #88 (dead links slug norm, v1.16.1). scanDeadLinks normalizes space→hyphen in target basename before lookup. [[entities/Claude Code]] matches entities/Claude-Code.md.
  • Settings UX: drop hardcoded model fallback (v1.16.1). Removed defaultModel from all 12 PREDEFINED_PROVIDERS configs. Switching providers clears model/availableModels/useCustomModel.
  • Settings UX: friendly fetch error classification (v1.16.1). New classifyFetchError() categorizes failures into Auth / Endpoint / Server / Empty / Network with specific Notice per category.
  • #94 (Lint cancellation, v1.16.2). AbortSignal now propagates through all 5 fix-runner functions. All persistent Notices wrapped in try/finally so they dismiss on cancellation.
  • #96 (Lint granularity, v1.16.2). LLM analysis step in lint respects the user's extractionGranularity setting via appendGranularityToPrompt.
  • #99 (Thinking token bleeding, v1.16.2). Three-layer defense: (1) API-level disableThinking sends thinking.type='disabled' uniformly with 400 fallback; (2) parseJsonResponse strips <think>/<think> before JSON extraction; (3) cleanMarkdownResponse discards preamble before \n---\n or \n# markers.
  • #86 (Frontmatter dates, v1.16.2). Root cause was preamble before frontmatter (shared with #99). Fixed by cleanMarkdownResponse Layer B2 preamble detection.
  • #103 (Delete empty stubs, v1.16.2). New "Delete empty stubs" button in Lint report modal. Skips reviewed: true pages.
  • disableThinking?: boolean added (v1.16.2). OpenAICompatibleClient uses thinking.type='disabled' uniformly. Provider 400 errors trigger automatic fallback retry.
  • #94 (Lint cancel status bar regression fix, v1.16.3). v1.16.2 wired AbortSignal but LintReportModal still called this.close() on every fix-button click. Fix gives each fix phase its own lint-operation lifecycle so the status bar persists across fix phases.
  • #94 (batch count display, v1.16.3). Duplicates-check progress Notice now shows actual inner-batch range (1-4/16) instead of outer round counter (X/4).
  • #243 thinkingControlCache key mismatch (v1.16.3). Extracted getThinkingControlCacheKey() helper so read and write paths in main.ts use the same cache key.
  • #244 deleteEmptyStubs error handling (v1.16.3). Now returns {deleted, failed, errors} instead of throwing on first failure.
  • #245 thinkingControlSupported cache after fallback (v1.16.3). OpenAICompatibleClient sets this.thinkingControlSupported = false after successful 400-fallback.
  • #248 isThinkingControlError tightening (v1.16.3). Now requires both HTTP 400 status AND rejected-field/parameter keyword in the message.
  • Batch count display in i18n strings (v1.16.3). Replaced 3 hardcoded English progress strings with i18n keys in 8 locales.
  • de.ts trailing-comma syntax error (v1.16.3). 6 other language files had same issue — all fixed in lockstep.
  • endLintOperation made idempotent (v1.16.3).
  • Test rename (v1.16.3, #246): "omits thinking for Gemini" → "sends thinking.type=disabled for Gemini baseUrl".

Aggregate stats

  • 3 releases
  • Test count: 488 → 549 (+61 tests, 0 regressions)
  • Composition + per-PR detail: git log --oneline 1.16.0..1.16.3

[1.16.0] - 2026-06-04

Added

  • LM Studio provider: New dedicated provider option (PREDEFINED_PROVIDERS.lmstudio). API key is optional — LM Studio runs locally but supports key-based auth. Base URL defaults to http://localhost:1234/v1.
  • Context Window setting: Configurable cap on LLM output tokens to protect local models with limited context (LM Studio 8K, Ollama 4K, etc.). Dropdown options from 4K to 1M. Shown only for local/custom providers (Ollama, LM Studio, custom OpenAI/Anthropic). Sets maxCap on truncation retry for safety.
  • Startup quick fixes: Low-level format repairs run automatically on plugin load: sources field normalization, wiki folder structure verification. Default ON. Detailed 10s Notice with cleanup stats + disable hint.
  • Sources field normalization (Issue #81): 4 new pure functions in src/core/sources-normalizer.ts handle 6 real-world pollution patterns reported by DocTpoint (external paths, .md suffixes, alias pipes, duplicates, inline arrays, empty [[]] links). 22 tests covering both inline and multi-line formats.
  • Lint integration: fixPollutedSources runs in lint section 0.5, normalizes all wiki files before LLM-dependent phases. Reports "Sources normalized" section in lint output.
  • TDD shell test documentation: Mandatory test quality rules added to CLAUDE.md — cover all production paths, assert content mutation (not just return values), re-scan for idempotency verification.

Fixed

  • Issue #81: YAML sources: field generated 3+ inconsistent formats (external paths, .md, \|alias) from different code paths. Root cause: wiki-engine.ts:646 passed file.path to {{source_file}}, and utils.ts:518 normalizeSourcePath only stripped [[]]. Fix: unified normalizeSourcePath with external-path remapping + full frontmatter rewrite.
  • Issue #75: LM Studio HTTP 400 on batch 2+ — source-analyzer.ts:113 had local shadow MAX_TOKENS = 16000 that bypassed centralized MAX_TOKENS_BATCH. Replaced with MAX_TOKENS_BATCH. Plus new capMaxTokens() pure function and maxTokensPerCall setting to cap output explicitly.
  • Issue #76: TOKENS_DEDUP_RESOLUTION=300 caused "empty JSON" with thinking models where reasoning consumed the budget, then stripThinkingTokens removed it leaving zero JSON. Fixed: 300→1000 (insurance). Also TOKENS_QUERY_SAVE_DEDUP: 150→300.
  • Dead code: Removed TOKENS_PAGE_MERGE and TOKENS_RELATED_UPDATE (zero callers). Removed promptIncludesConstraints.
  • Alias language: Replaced hardcoded Chinese↔English translation rules with English-as-linker-language + "do NOT invent established technical translations" rule. 4 examples (Transformer/Vitamin B2/RoPE/Neural Network) prevent LLM outputting non-existent translations like "变换器" for Transformer.
  • withTruncationRetry retry cap: Previously used MAX_TOKENS_BATCH (16000) unconditionally, causing retry HTTP 400 on local 8K models. Now respects maxTokensPerCall setting as maxCap.

Changed

  • Settings UX redesign: New "LLM-Wiki Status" section with inline status indicators. "LLM Provider Configuration" → "LLM Configuration". "Wiki Folder Configuration" → "Wiki Configuration". LLM Concurrency and Batch Delay moved to LLM Configuration section. Startup Quick Fixes toggle moved to first item in Auto Maintenance. Status prefix "LLM Client Status:" removed.
  • Provider dropdown i18n: Non-Chinese languages now display English provider names (international technical convention) instead of falling back to Chinese.
  • CLAUDE.md: TDD section evolved with mandatory test quality rules, TDD shell failure example, and debug template for "stuck counter" symptoms.

Removed

  • Dead constants: TOKENS_PAGE_MERGE, TOKENS_RELATED_UPDATE
  • Dead function: promptIncludesConstraints
  • Shadow constant: source-analyzer.ts:113 local MAX_TOKENS = 16000
  • Redundant "LLM Client Status:" prefix from status indicator

[1.15.0] - 2026-06-03

Added

  • Wiki auto-initialization UX (Issue #80): Wiki structure auto-creates on first successful LLM connection — no more "Generate Default Schema" button doing nothing on empty vaults. Settings panel shows real-time wiki init status (✅/⚠️).
  • saveSummary i18n: Query-to-Wiki save dialog now uses localized summary strings across all 8 languages instead of hardcoded English/Chinese.

Fixed

  • Issue #80: Empty vault → "Generate Default Schema" button silently failed because schema/ folder didn't exist. Now auto-creates via defensive createFolder().
  • withRetry nesting: Removed nested withRetry in truncation retry paths — reduced from max 9 calls to max 3 per client. Outer withRetry handles all network errors.

Changed

  • Core architecture: Extracted 2 new pure function modules to src/core/:
    • sse-parser.ts — shared SSE event parser for streaming responses (Anthropic + OpenAI formats)
    • truncation-retry.ts — shared token truncation retry policy (3 clients → 1 helper)
  • DRY fix: Extracted isWikiInitialized() from duplicate code in settings.ts.
  • Dead code cleanup: promptIncludesConstraints (zero callers) removed; foundAliases Array.isArray check simplified.
  • Constants: PAGES_CACHE_TTL_MS centralized.
  • Test infrastructure: +37 tests (446 total across 21 files), covering SSE parsing, AnthropicClient truncation, wiki initialization.

[1.14.0] - 2026-06-01

Added

  • Model compatibility expansion: DeepSeek-R1, QwQ (reasoning models), and LM Studio now fully supported. Think token stripping (Issue #64) removes ` Schweizer

/blocks from reasoning model outputs. LM Studio compatibility fix (Issue #65) removes unsupportedresponse_format: json_object` parameter.

  • Test infrastructure expansion: Mock infrastructure (createMockContext, createMockFile) enables unit testing of core engine modules without Obsidian runtime. Total tests increased from ~200 to 400 (+200 tests), covering previously untestable core logic.

Fixed

  • TypeScript type safety complete: Fixed 8 type errors in page-factory-core.test.ts (interface completeness, null checks, parameter types). Project achieves TypeScript strict mode compliance.

  • Query engine stability: Page content loading capped at 3000 tokens (MAX_PAGE_CONTENT_CHARS) to prevent token overflow in loadRelevantPages.

  • Dual Gate Verification Mechanism: Upgraded quality gates to require both ESLint and TypeScript passing (0 errors + 0 warnings each). ESLint alone is insufficient for type safety.

Changed

  • Core architecture refactoring: Extracted 4 pure function modules to src/core/ directory:

    • conflict-resolver.ts — zero-IO conflict detection
    • dead-link-detector.ts — dead link identification
    • orphan-matcher.ts — orphan page matching
    • prompt-builders.ts — prompt template builders
  • Constants centralization: Centralized 30+ scattered magic numbers into src/constants.ts (192 lines). Activated semantic constants: WIKI_SUBFOLDERS, notice durations, token budgets, retry parameters.

  • lint-fixes.ts refactoring: Extracted pure logic to core modules, reduced file complexity (~180 lines removed).

  • Documentation upgrades:

    • TDD Standard: "write failing test first, then implementation"
    • Development Protocol: "plan first, then execute"
    • ROADMAP architecture quality upgrade plan
    • Dual Gate Verification documentation (ESLint + TypeScript both required)
  • Code quality: 2576 lines added, 503 lines removed across 44 files. Zero side effects, zero breaking changes, backward-compatible refactorings.

[1.13.0] - 2026-05-31

Added

  • Extraction aliases seeding: Entity and concept extraction now supports aliases field (optional). Pre-generated aliases serve as seeds for page generation and act as signals in multi-round extraction to prevent duplicate extractions. Contributed by @Indexed-Apogrypha (PR #61) and @green-dalii (PR #67).
  • Multi-round extraction context: Non-first extraction rounds now receive a list of already-extracted names and aliases, enabling the LLM to reliably avoid duplicates even on small/local models that struggle to maintain session state.

Fixed

  • Source analysis false abort (#61): First batch gate changed from || to && — only aborts when BOTH entities and concepts are absent. Previously a glossary source (entities only, no concepts) would incorrectly abort. Contributed by @Indexed-Apogrypha (Matthew Harper).
  • Hidden TypeError on non-array LLM output: normalizeBatchResponse uses typed coerceToArray to handle models returning entities: true (or similar non-array truthy values), preventing TypeError in downstream .filter() calls.
  • Alias self-pointing duplication: appendAliases now skips aliases that equal the page's own filename, preventing redundant self-pointing frontmatter entries on cross-type collisions.

Changed

  • NormalizeBatchResponse pure function: Extracted 8 scattered || [] fallbacks into a centralized pure function with BatchValidity enum (unusable/empty/valid), improving testability and fixing edge case handling.
  • Prompt task 0 clarified: Separated "field round restrictions" from "content requirements" — each is now an independent task item with front-loaded scope markers.
  • Generation prompt receives aliases seeds: Page creation template now includes {{extraction_aliases}} field, enabling the LLM to build on pre-extracted alias suggestions.
  • Three-No Principle structured: Replaced abstract manual-check descriptions with actionable evaluation procedures (call-site audit, data flow trace, state mutation analysis, breaking-change matrix).
  • Official blog links added: All 8 READMEs now include links to the official blog (CHN: /zh/blog/, others: /blog/).

[1.12.x PATCH series] - 2026-05-27 → 2026-05-30

Super-aggregated per Keep a Changelog spec. 3 PATCH releases (v1.12.1 + v1.12.5 + v1.12.6) over 3 days. Per-PR detail preserved in git log --oneline 1.12.0..1.12.6.

Aggregate scope

  • Query modal auto-save prompt disabled (v1.12.1). Closing the Query window no longer triggers LLM evaluation and SuggestSaveModal prompt.
  • Lint status bar text corrected (v1.12.1). Status bar now shows "Linting... click to cancel" instead of "Ingesting... click to cancel" during lint operations.
  • Notice toast i18n completed (v1.12.1). All remaining hardcoded English notices converted to i18n (mdOnlyFile, lintPollutedFixed, regenerateIndexCompleted, operationFailed). 8-language coverage.
  • packageManager field added (v1.12.1). Added to package.json for unambiguous pnpm usage.
  • 4 lint scanner functions extracted & tested (v1.12.1). buildKnownTargets, scanDeadLinks, scanOrphans, detectAliasDeficiency extracted to src/wiki/lint/scanners.ts with zero Obsidian dependencies. 15 unit tests.
  • PageFactory error context (v1.12.1). createNewPage, mergePage, appendToReviewedPage now wrap errors with entity name and operation type.
  • Privacy & Transparency sections added (v1.12.1). All 8 READMEs gained localized Privacy & Security + Transparency & Compliance sections. Obsidian score updated to 95/100.
  • Branch protection workflow documented (v1.12.1). In CLAUDE.md and memory.
  • #54 Cross-folder entity/concept duplicates prevented (v1.12.5). resolvePagePath() now checks opposite folder (entities ↔ concepts) when same-type matching fails. Cross-type collision merges new content into existing opposite-type page and appends name as alias. No more duplicate pages for same topic. Contributed by @dmarchevsky.
  • Historical cross-type duplicate detection in Fast path 1 (v1.12.5). When same-type exact slug match hits, opposite folder is also checked. Existing historical duplicates get alias-bridged + warning logged.
  • IngestReportModal displays collisions (v1.12.5). Cross-type collisions section added to batch report.
  • Redundant I/O eliminated (v1.12.5). Cross-type collision detection uses in-memory path matching from allPages instead of additional tryReadFile() call.
  • Type-safe i18n access (v1.12.5). Added getText() helper — replaces 13 instances of as unknown as Record<string, string> across 6 files. 8 unit tests added.
  • README Usage section (v1.12.5). Added sidebar button ingestion method to all 8 language variants.
  • Build verification failure fixed (v1.12.6). CI workflow switched from pnpm install + pnpm build to npm install --legacy-peer-deps + npm run build to match Obsidian's verification system exactly.
  • Dependency version pinning (v1.12.6). All deps use exact versions (no ^ or latest) to prevent lockfile drift between pnpm-lock.yaml and package-lock.json.
  • CI Node version (v1.12.6). Updated from 24.x to 22.x for stability.

Aggregate stats

  • 3 releases
  • Test count: 148 → 173 (+25 tests)
  • Composition + per-PR detail: git log --oneline 1.12.0..1.12.6

[1.12.0] - 2026-05-27

Added

  • Extraction prompt rearchitected: Full page list removed from prompt. Extraction speed independent of wiki size (~80% faster).
  • Dynamic batch limits + convergence detection: Short content finishes in 1–2 batches. Low-yield batches terminate early.
  • Short-content auto-downgrade: Sources <20K chars cap maxTotalItems proportionally.
  • Deterministic related_pages matching: matchExtractedToExisting() uses slug + alias matching — zero LLM cost.
  • build:dev command: One-shot dev build with debug output preserved.
  • Silent slug operations: Eliminates ~30K lines of debug output per ingestion.

Changed

  • esbuild upgraded: 0.17.3 → 0.28.0 (dev-server vulnerability fixed).
  • Production build suppresses console.debug: Clean logs in production.
  • Granularity ≤ notation: 8 languages synchronized.
  • 140 tests across 3 test files (+27 since v1.11.0).

Evaluated & Rejected

  • Hexagonal Architecture — over-engineering for Obsidian plugin
  • Vector search (Ollama embeddings) — <1% of users have this
  • Hash-bucket dedup optimization — no user-reported perf issue
  • page-factory try/catch completion — exceptions handled at wiki-engine level
  • API URL validation — Obsidian's requestUrl already validates

[1.11.0] - 2026-05-26

Added

  • llmReady gating (#42): New users must complete Provider → API Key → Fetch Models → Test Connection before core features unlock.
  • Cancel ingestion mid-run (#43): AbortController with batch boundary checkpoints.
  • Ribbon icon + ingest current file (#44): One-click ingest of active editor tab.
  • Lint double-nested link auto-fix: Programmatic detection across all wiki files, zero LLM cost.
  • Opposite-directory stubs (#40): Slug-equivalence matching in stub safety nets.
  • Extraction prompt rewrite (#34): Graph-centric "wiki-link test". Bibliographic references excluded.
  • mentions_in_source filtering (#39): Capped at 500 chars.
  • 529 Overload retry (#41): All clients cover overload keyword.
  • PageFactory refactoring: 8 methods → 4 generic (563→424 lines, -25%).
  • LLM client retry extraction: Shared withRetry<T> helper.
  • 113 unit tests via vitest.

Fixed

  • #37 Double-nested wiki-links: Three-layer defense.
  • #38 Anthropic prompt caching: Evaluated & rejected — system prompts too small for cache threshold.

[1.10.x] - 2026-05-20

Added (v1.10.0)

  • Aliases support (#30/#31): EntityInfo/ConceptInfo.aliases? for cross-language dedup.
  • Minimal + Custom granularity: 5 levels (Minimal/Coarse/Standard/Fine/Custom).
  • Slug normalization in resolvePagePath (#32): Fast path 2 checks title + aliases.

Fixed (v1.10.x)

  • Custom granularity per-type limits ignored (v1.10.2): In custom mode, entity and concept limits now enforced separately.
  • Numeric inputs accepting text (v1.10.0): Custom limit and conversation history inputs now restricted to numbers.
  • Aliases omitted in duplicate detection (#30): analyzeSource and resolveEntityDedup now include aliases.

[1.9.x] - 2026-05-19

Added (v1.9.0)

  • Pollution defense system (4-layer): Write gate → index purification → stub sanitization → detection & repair.
  • "Fix polluted pages" in Lint report: One-click repair.
  • Missing aliases section in Lint report: Lists each page individually.
  • Long source ingestion notice: Files >1000 lines trigger persistent Notice.

Fixed (v1.9.1)

  • renderComponent memory leak in QueryModal: Fixed dangling component.
  • createMessageStream language type: 3 client implementations now accept 8 languages.
  • Missing i18n keys in zh.ts: Added lintNoIssuesFound and lintContradictionOpen.
  • Batch delay default: 300ms → 500ms.

[1.8.x] - 2026-05-17/18

Added (v1.8.0/v1.8.1)

  • Full i18n for 8 languages: 269+ UI fields. English, Chinese, Japanese, Korean, German, French, Spanish, Portuguese.
  • Dynamic download badge: Real-time counts from Obsidian's community-plugin-stats.json.
  • Complete badge suite: 8 standardized badges across all READMEs.
  • Rate limit detection: HTTP 429 errors trigger actionable suggestions.
  • Smart Fix All completion modal: Per-phase results report.

Fixed (v1.8.1)

  • Single-value aliases crash: YAML frontmatter with aliases: single-value now normalized.
  • README command accuracy: Usage table corrected across all 8 language READMEs.

[1.7.x] - 2026-05-06 to 2026-05-17 (Code Quality Milestone)

Highlights

  • Quality Milestone (v1.7.0): Content truncation protection, lint/command i18n, batch reports.
  • Multi-source merge (v1.7.2): Programmatic frontmatter + LLM intelligent fusion.
  • Ingestion acceleration (v1.7.3): Configurable 1–5 concurrent page generation.
  • Parallelization + path fixes (v1.7.6): Related page update parallelization.
  • Save-to-wiki quality (v1.7.7): Smart batch skip, plugin ID rename llm-wikikarpathywiki.
  • Supply chain security (v1.7.9): GitHub artifact attestations.
  • Knowledge dedup + error resilience (v1.7.10): 5xx retry, persistent notices.
  • Mandatory page aliases (v1.7.11): Alias deficiency detection, "Complete aliases" button.
  • README i18n (8 languages) (v1.7.13): Provider-aware model filtering, alias-aware index.
  • Query modal overhaul (v1.7.14): Cmd+Enter to send, Stop button, Copy button, auto-scroll.
  • Lint UI freeze fix (v1.7.15/17): Async yield points every 50 pages and 500 comparisons.
  • Pollution fix (v1.7.18/20): Folder name leakage defense layer, alias convergence.
  • Lint modular refactoring (v1.7.19): Split monolithic files into 4 focused modules.

Fixed

  • #37 Double-nested wiki-links: Three-layer defense.
  • #40 Opposite-directory stubs: Slug-equivalence matching.
  • #43 Cancel ingestion mid-run: AbortController + batch checkpoints.
  • #14 OpenRouter/Ollama model filtering: Provider-aware smart filter.

[1.6.x] - 2026-04-29 to 2026-05-03 (Internationalization & Performance)

Added

  • Wiki Output Language (8 languages): English LLM prompts with language directive.
  • Iterative batch extraction: Adaptive batch sizing, JSON output enforcement.
  • Dual-layer JSON parsing: Robust error recovery.
  • Query-to-Wiki feedback: Contradiction state machine, conversational ingest.

Changed

  • Schema layer: Auto-maintenance, modular architecture.

Earlier Versions (v1.4.0–v1.5.x)

  • v1.4.0 (2026-04-29): Schema layer, auto-maintenance, ESLint compliance
  • v1.3.0 (2026-04-28): Modular architecture refactor
  • v1.2.0 (2026-04-27): Bidirectional links, entity/concept extraction
  • v1.0.0 (2026-04-26): Multi-page generation, foundational architecture

[0.2.0–0.2.2] - Earlier Beta

  • Initial plugin development and concept validation.