Commit graph

1009 commits

Author SHA1 Message Date
Logan Yang
bd8829f538
release: v3.3.3 (#2494) 2026-05-20 18:27:22 -07:00
Logan Yang
05a0d03fd9
feat(models): add GA gemini-3.5-flash builtin (#2492)
* feat(models): replace gemini-3-flash-preview with GA gemini-3.5-flash

Swap the preview Gemini flash builtin for the now-GA gemini-3.5-flash
across the Google and OpenRouter providers, enable it by default, and
update the user-facing model docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(models): drop older Gemini 2.5 builtins, keep latest per family

Remove the Gemini 2.5 pro/flash builtins (Google + OpenRouter), leaving
only the latest of each family: gemini-3.1-pro-preview, gemini-3.5-flash,
and gemini-3.1-flash-lite. Reassign the default, required, and Google
test model roles from gemini-2.5-flash to gemini-3.5-flash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(models): keep gemini-2.5-pro as the GA Gemini pro builtin

gemini-3.1-pro is preview-only on the public Gemini API, so retain
gemini-2.5-pro (Google + OpenRouter) to guarantee at least one GA Gemini
pro model in the builtin list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(models): keep gemini-2.5-flash default, add gemini-3.5-flash alongside

Restore gemini-2.5-flash (Google + OpenRouter) and its default, required,
core, and Google test-model roles. gemini-3.5-flash stays as an additional
enabled builtin rather than taking over the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:00:00 -07:00
Zero Liu
f28ced14ff
fix(chain): stop writing chainType back inside setChain (apply-Plus freeze) (#2478)
* chore(test-vault): tag deployed manifest with branch + build timestamp

`npm run test:vault` now writes a real `manifest.json` (instead of a symlink
to the worktree copy) with the current git branch and a `yyyymmdd-HHMMSS`
build timestamp appended to `name` and prepended to `description`. Surfaces
the active worktree/build directly in Obsidian's Community plugins list, so
"is my latest build actually loaded?" is a glance instead of a guess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chain): stop writing chainType back inside setChain (apply-Plus freeze)

Applying a Copilot Plus license key froze Obsidian. Root cause: ChainManager
ran multiple concurrent `createChainWithNewModel` invocations (one from the
constructor's `initialize()`, plus one each from the settings, model-key, and
chain-type subscribers firing on `applyPlusSettings`). Each captured
`chainType = getChainType()` at its start, awaited `setChatModel(...)`, then
resumed with a now-stale local value and wrote it back to `chainTypeAtom`
via `setChain` → `setChainType`. The write re-fired the chain-type
subscriber in ProjectManager, which scheduled another stale-closure
rebuild, and the atom bounced indefinitely between `LLM_CHAIN` and
`COPILOT_PLUS_CHAIN`.

Inlined `setChain` into `createChainWithNewModel` and removed the redundant
chainType write-back. The atom is owned by the UI dropdowns and
`applyPlusSettings`; this function only reads from it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(chain): fix outdated comment reference to removed test file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:46:21 -07:00
Zero Liu
58edadefdb
Add npm run test:vault for fast worktree-to-vault plugin reload (#2477)
Adds a one-command dev workflow: install + build + symlink main.js /
manifest.json / styles.css from the current worktree into
$COPILOT_TEST_VAULT_PATH/.obsidian/plugins/copilot/, then reload the
plugin via the Obsidian CLI. Removes the manual build/copy/reload loop
across Conductor worktrees. Docs added to CONTRIBUTING.md and AGENTS.md.
2026-05-19 12:05:49 -07:00
Logan Yang
ba378e8389
release: v3.3.2 (#2473)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:45:28 -07:00
Zero Liu
e03234137d
Enable unsafe member access linting (#2474) 2026-05-15 18:26:40 -07:00
Logan Yang
3f12c14444
fix(anthropic+bedrock): adaptive thinking + summarized display for claude-opus-4-7+ (#2471)
* fix(anthropic): use thinking.type=adaptive for claude-opus-4-7+

claude-opus-4-7 rejects the legacy thinking shape with a 400:
  `thinking.type.enabled` is not supported for this model.
  Use `thinking.type.adaptive` and `output_config.effort`.

Detect opus-4 minor version in getModelInfo and emit
{ type: "adaptive" } for >=7, keeping { type: "enabled", budget_tokens }
for opus-4-6 and earlier, sonnet-4, and 3-7-sonnet.

Bumps @langchain/anthropic ^1.0.0 -> ^1.3.29 to pick up the adaptive
ThinkingConfigParam variant and an updated tool-schema typing that lets
BedrockChatModel.convertTools drop its now-redundant Record cast.

Fixes #2361. Replaces stale PR #2362.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): use thinking.type=adaptive for claude-opus-4-7+

Same 400 from claude-opus-4-7+ as the direct Anthropic path (fixed in
the previous commit), but the Bedrock provider has its own payload
builder in BedrockChatModel.buildRequestBody that was unaffected.

Add the same minor-version detection inside that payload builder.
Uses an unanchored regex /claude-opus-4-(\d+)/ because Bedrock model
IDs carry a provider/profile prefix (e.g.
"global.anthropic.claude-opus-4-7-20260115-v1:0").

Fixes #2384.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: use real sonnet model IDs

claude-sonnet-4-7 doesn't exist; tests were asserting against a
fabricated ID. Switch the non-opus negative cases to a real
claude-sonnet-4-5 (and keep the existing claude-3-7-sonnet test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): set output_config.display=summarized for claude-opus-4-7+

Adaptive thinking on Opus 4.7+ alone restores the request shape but
leaves the UI empty — Anthropic flipped the default for
output_config.display from "summarized" to "omitted" on this model
line, so the API stops emitting visible thinking content blocks.
Pre-4.7 models still default to "summarized" server-side.

Set display: "summarized" alongside thinking: { type: "adaptive" } on
both the direct Anthropic and Bedrock paths so opus-4-7 renders
thinking summaries the same way sonnet 4.6 does today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(thinking): nest display=summarized inside thinking config

The Anthropic API places `display: 'summarized' | 'omitted'` on the
thinking config itself (ThinkingConfigAdaptive / ThinkingConfigEnabled),
not on a top-level `output_config`. The previous commit set
`outputConfig: { display: "summarized" }` on the ChatAnthropic config and
`output_config: { display: "summarized" }` on the Bedrock request body,
which broke the TS build (the SDK's OutputConfig only exposes
effort/format) and would not have produced the intended server behavior
for the adaptive thinking summarization toggle.

- chatModelManager.ts: emit `thinking: { type: "adaptive", display: "summarized" }`
- BedrockChatModel.ts: same, on the request payload
- BedrockChatModel.test.ts: update assertions to the new shape

* fix(thinking): constrain Opus minor regex so dated snapshots don't match

The previous `^claude-opus-4-(\d+)` (Anthropic) and unanchored
`claude-opus-4-(\d+)` (Bedrock) regex captured the date portion of
snapshot IDs like `claude-opus-4-20250514` as if it were the minor
version, so `usesAdaptiveThinking` evaluated as `true` (20250514 >= 7)
for pre-4.7 Opus models. That would send `thinking: { type: "adaptive" }`
for the legacy 4.0 snapshot and could trigger 400s on REASONING-enabled
runs.

Constrain the minor capture to 1-2 digits followed by `-`/`.` or end of
string. Real Anthropic minor versions are single/double digit; dated
snapshots have an 8-digit date that this regex now rejects.

- src/utils.ts: anchored fix for the Anthropic path
- src/LLMProviders/BedrockChatModel.ts: unanchored fix for Bedrock IDs
- regression tests for dated 4.0/4.1 snapshots in both src/utils.test.ts
  and src/LLMProviders/BedrockChatModel.test.ts

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:29:36 -07:00
Logan Yang
475d5b9c18
fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID (#2472)
* fix(bedrock): explain inference-profile requirement when AWS rejects on-demand model ID

When users configure a bare regional model ID (e.g. anthropic.claude-sonnet-4-6) on
Bedrock, AWS returns a 400 with a cryptic JSON blob. The raw error gave no hint that
the fix is to switch to a cross-region inference profile prefix. This rewrites that
specific 400 to a one-line actionable message pointing users to Settings → Models and
listing the global./us./eu./apac. prefix options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bedrock): generalize inference-profile sentinel and provider segment

Addresses two Codex review concerns and a CI build regression on PR #2472:

- Sentinel: match on the apostrophe-free phrases "on-demand throughput" and
  "inference profile" instead of "isn't supported", so we catch AWS error
  bodies that use the curly-apostrophe variant "isn't supported" as well.
- Guidance: derive the prefix-form provider segment (e.g. "anthropic",
  "meta", "amazon") from the bare model ID extracted from the error body,
  so non-Anthropic Bedrock models get the correct global.<provider>.<id>
  guidance instead of being told to switch to global.anthropic.<id>.
- Restore the `as Record<string, unknown>` cast (via `unknown`) on the
  Zod-to-JSON-Schema conversion. The previous reformat dropped the cast
  and broke `tsc -noEmit` with a JsonSchema7Type / Record<string,unknown>
  mismatch, which surfaced as the build (22.x) CI failure on this branch.

New tests:
- "rewrites the error even when AWS uses a curly apostrophe in 'isn't supported'"
- "uses the provider segment from the bare model ID in the prefix guidance"

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:18:00 -07:00
Zero Liu
de443ae5d9
Remove dead ChainFactory and chain validation helpers (#2469)
* Remove dead ChainFactory and chain validation helpers

ChainFactory and the chain accessors on ChainManager (getChain,
getRetrievalChain, validateChainInitialization) have been deprecated
in-source for a while — chain runners call chat models directly and
nothing reads this.chain / this.retrievalChain.

- Delete src/chainFactory.ts.
- Drop the dead chain/retrievalChain fields and accessors on
  ChainManager. The setChain switch collapses to "setChainType plus
  initializeQAChain for the QA-style chains."
- Remove isLLMChain / isRetrievalQAChain / isSupportedChain from
  utils.ts; switch utils.ts's Document import to @langchain/core/documents
  (matching what flows through the retriever pipeline already).
- Drop now-pointless `jest.mock("@/chainFactory")` blocks from five
  test files; they were short-circuiting an import tree the tests
  no longer touch.

No behavior change: 2105 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename initializeQAChain to refreshVaultIndex

initializeQAChain hasn't initialized a chain since the chain plumbing
was removed — its only job now is to re-index the vault when asked.
Rename to refreshVaultIndex and clean up:

- The setChain gate ("only for VAULT_QA / COPILOT_PLUS / PROJECT
  chains") is redundant. The sole caller that sets refreshIndex=true
  (projectManager on chain-type change) already enforces the same gate.
  Call refreshVaultIndex iff refreshIndex is requested.
- Drop the misleading "V3 search builds indexes on demand" inline
  comment in favor of one accurate JSDoc on the renamed function.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:24 -07:00
Zero Liu
5527942100
style(css): expand #ccc to 6-digit hex format for consistency (#2470)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:05:24 -07:00
Zero Liu
6a71cf8586
chore(react): centralize React root creation via createPluginRoot helper (#2467)
* chore(react): centralize React root creation via createPluginRoot helper

Every standalone React root in the plugin must wrap its tree in
AppContext.Provider so descendants can rely on useApp() — PR #2466 fixed
one missing wrap (QuickAskOverlay) but the contract was implicit and any
new createRoot callsite could silently re-introduce the same crash.
Introduce a createPluginRoot(container, app) helper that injects the
provider automatically, migrate all 17 createRoot callsites to use it,
and add a Jest guardrail that fails if any non-helper file imports
createRoot from react-dom/client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): replace createRoot Jest guardrail with ESLint rule

Switch the createPluginRoot enforcement from a Jest test (walks src/
and greps imports) to an ESLint no-restricted-syntax rule. Same
invariant, faster feedback (in-editor instead of on test run), and one
fewer test file to maintain. The helper file is exempted via the
config block's `ignores`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:49:03 -07:00
Zero Liu
01a01e2951
fix(brevilabs): rewrite uploads via requestUrl multipart (W8/9) (#2399)
* fix(brevilabs): rewrite uploads via requestUrl multipart (W8/9)

Ninth of nine source workspaces splitting #2397. Replaces native
fetch(FormData) calls in brevilabsClient with Obsidian's requestUrl
plus a manually-constructed multipart body. This is required because
Obsidian's CORS-bypass path can't handle native FormData streams.

- src/LLMProviders/brevilabsClient.ts: buildMultipartFromFormData
  helper builds a Uint8Array body with explicit boundary; uploads now
  flow through requestUrl with a Content-Type header that includes
  the boundary string
- __mocks__/obsidian.js: requestUrl jest mock + __setRequestUrlImpl
  helper so tests can swap in per-case responses

Manual verification required: image upload, PDF upload, audio
transcription, error path. Plus license needed to exercise real
uploads.

W0 already merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(network): dedupe brevilabs response parsing + bound model-fetch timeout

- Extract parseBrevilabsResponse helper so makeRequest and
  makeFormDataRequest share one parse/error path.
- Wrap fetchModelsForProvider's safeFetch in a 10s Promise.race
  (safeFetch ignores AbortSignal, so a dead base URL could hang
  the model-import dialog forever).
- Preserve real @/utils exports in selfHostServices test mock via
  jest.requireActual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): fix typescript-eslint violations in brevilabs + tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:42:25 -07:00
Zero Liu
19dd1aab40
fix(quick-ask): provide AppContext to overlay panel (#2466)
The Quick Ask overlay creates its own React root, so descendants that
call useApp() (AtMentionCommandPlugin → useAtMentionSearch →
useOpenWebTabs) crashed with "useApp() called outside of an
<AppContext.Provider>" when opening the panel.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:58:01 -07:00
Zero Liu
0c9ce57dd5
chore(deps): drop @langchain/community + bump openai to v6 to dedupe SDKs (#2463)
* chore(deps): drop @langchain/community, hand-roll Jina embeddings

Replaces the single use of @langchain/community (JinaEmbeddings) with a small
hand-rolled implementation that extends @langchain/core/embeddings directly and
calls the Jina /embeddings endpoint via Obsidian's requestUrl (CORS-safe). The
request shape and response handling mirror the upstream Python reference.

* chore(jina): adapt upstream community implementation with attribution

Restores batching, dimensions/normalization defaults, and the multi-modal
input type from the original @langchain/community JinaEmbeddings, with
upstream copyright notice and MIT attribution preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(deps): bump openai to ^6.10.0 to dedupe with @langchain/openai

@langchain/openai@1 pins openai@^6.10.0 while we pinned ^4.95.1, so
esbuild was shipping both copies. Aligning to v6 dedupes the bundle.
Stacks on #2461, which drops @langchain/community (and its stagehand
peer that pinned openai@^4.62.1), so npm install no longer needs
--legacy-peer-deps to resolve.

ChatOpenRouter.ts is the only direct consumer; its imports
(OpenAI default, ChatCompletionChunk/MessageParam/Role types,
chat.completions.create streaming) are all unchanged in v6.

main.js: 3,447,540 -> 3,371,972 bytes (-75 KB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:48:18 -07:00
Zero Liu
adf8c68a73
chore(deps): remove unused deps and dead code to shrink bundle (#2460)
Drop unused runtime deps (huggingface, koa, langchain, react-markdown,
react-syntax-highlighter, next-i18next, p-queue, trie-search, sse,
codemirror-companion-extension, tabler/icons-react, etc.) and demote
build-only packages to devDependencies. Delete dead source files
(quick-ask modes, ListPromptModal, NewChatConfirmModal,
PatternMatchingModal, ContainerContext, command-ui chat-message,
ui/tabs) and unused exports flagged by knip. Add `lint:dead` script
backed by knip and a knip.json config.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:25:59 -07:00
Zero Liu
a92012dc4d
chore(lint): fix no-direct-set-state-in-use-effect violations (#2454)
* chore(lint): fix no-direct-set-state-in-use-effect violations

Replaced 56 setState-in-useEffect anti-patterns across 21 files with
idiomatic React patterns: derived state via useMemo, useSyncExternalStore
for external stores, key-prop resets, render-phase prev-value trackers,
and lazy useState initializers. Deleted dead defensive syncs in
PlusSettings, ModelEditDialog, and the fallback-notice effect in
CustomCommandChatModal. Narrowly suppressed three legitimate cases
(interval-driven animation, DOM measurement, Lexical pill→context absorb).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* address codex comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:23:55 -07:00
Logan Yang
7b62cde598
release: v3.3.1 (#2459) 2026-05-14 15:21:29 -07:00
Logan Yang
efee65ca7a
fix(plus): planner skips getFileTree when active note is attached (#2457)
* fix(plus): planner skips getFileTree when active note is attached (fixes #2456)

In Plus mode, the lightweight tool-planning step in
CopilotPlusChainRunner.planToolCalls() always runs regardless of the
Agent toggle. Its candidate-tools list includes getFileTree, and the
planning prompt guideline ("For file structure queries, use getFileTree
to explore the vault") was vague enough that asking "what's this note
about?" with a note already attached would trigger getFileTree before
the model answered — wasteful and surprising.

The planner had no awareness of attached context: it received only the
bare user message string, so it couldn't tell the difference between
"what's this note" (note attached → answer from context) and
"what's this note" (no attachment → maybe search for it).

Fix:
1. planToolCalls takes a new `hasActiveContextNote` parameter.
2. Caller derives the flag from the envelope's L3_TURN layer by
   testing for `<active_note` markers.
3. Planning prompt:
   - The existing getFileTree guideline tightened from "file structure
     queries" to "ONLY when the user wants to discover or list notes/
     folders" so the planner doesn't read "note" as a file-structure
     cue when content is already in context.
   - When hasActiveContextNote is true, append an explicit hint telling
     the model not to call getFileTree just because the user says "this
     note" / "this file" — and that it should still call getFileTree
     when the user explicitly wants OTHER notes / folders / paths.

This preserves the legitimate cases ("are there other notes similar
to this", "create a new note in the same folder", "list folders in
projects/") because those phrases override the context hint.

Verified:
- npm run lint: pre-existing warnings only
- npm test: 116 suites / 2105 tests pass
- npm run build: succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(plus): scope active-note detection to current-turn segments

Address Codex P2: regexing the entire L3_TURN text is too permissive
because compacted envelopes merge prior-turn context into a single
"compacted_context" segment. An <active_note> tag from an earlier turn
would falsely set hasActiveContextNote=true and incorrectly suppress
getFileTree.

Inspect L3_TURN segments instead and only count those with
metadata.source === "current_turn" (the marker emitted by
parseContextIntoSegments for non-stable, this-turn parsing).
Compacted segments use source: "compacted" and won't match, so the
optimization falls back to the safe default in compacted chats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:58:04 -07:00
Logan Yang
c8a6ad9374
fix(keychain): empty-keychain banner, reset modal copy, lifecycle reset (followup to #2364) (#2455)
* fix(keychain): empty-keychain banner, reset modal copy, lifecycle reset (followup to #2364)

Three issues flagged during inline review of #2364, applied as a
followup. Each addresses a concrete user-facing risk and is mechanical.

1. **Empty-keychain banner** (AdvancedSettings.tsx)

   The five-state storage status machine didn't distinguish "keychain-
   only mode, keychain available, but this device's keychain is empty".
   The result was a green "Obsidian Keychain" pill and no banner — even
   though the user has no usable keys.

   Hits three real scenarios:
   - Desktop migrates → Sync ships stripped data.json to mobile →
     mobile keychain is empty.
   - User restores a vault backup with `_keychainOnly: true` to a new
     device.
   - User opens a synced vault on a third device for the first time.

   Fix: new `keychainAppearsEmpty` derived state. When true, the API
   Key Storage description renders a warning ("No API keys found in
   this device's Obsidian Keychain — re-enter your API keys… this
   device's keychain is separate from other devices.") instead of the
   default "stored in this device's Obsidian Keychain" message.

2. **Reset Settings modal copy** (ResetSettingsConfirmModal.tsx)

   The modal told users that resetting would "lose any custom settings
   you have made including the API keys" — but in keychain-only mode,
   `resetSettings()` deliberately does not touch the keychain (per the
   DESIGN NOTE in model.ts). After reset, keys re-hydrate from the
   keychain on next load, leading to "ghost key" reports and a privacy
   expectation mismatch.

   Fix: rewrite the copy so the user knows API keys are *not* cleared
   and points them at "Delete All Keys" in Advanced Settings → API Key
   Storage if they want the dedicated path.

3. **Module-level state lifecycle** (settingsPersistence.ts, main.ts)

   `writeQueue`, `diskHasSecrets`, `lastPersistedSettings`,
   `suppressNextPersist`, `transactionEpoch`, `pendingTombstones`,
   `persistHadUndecryptableSecrets` are all module singletons. Plugin
   disable→enable doesn't reset them (Node / Electron require cache
   keeps the module instance alive), so a mid-migration disable
   poisons the next session with stale epoch + tombstone state.

   Switching vaults via "Open another vault" without restarting also
   inherits stale baseline state until the next save refreshes it.

   Fix: new exported `resetPersistenceState()` clears every module-
   level variable. Called from `main.ts onunload()` alongside the
   existing `KeychainService.resetInstance()` static method. Cheap
   insurance, no behavioral change for the happy path.

Verification:
- npm run lint: pre-existing warnings only
- npm test: 116 suites / 2105 tests pass
- npm run build: succeeds
- Manual mobile test confirmed (empty-keychain banner appears on
  freshly-synced device; reset copy reads correctly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(keychain): move state reset from onunload to onload (addresses Codex P1)

Codex review caught a real race: onunload is fire-and-forget from
Obsidian's perspective (the method is typed void), so the `await
flushPersistence()` continuation can run AFTER the next onload has
already initialized a fresh KeychainService instance. Our late reset
would then null out the *new* instance, breaking saves and the settings
UI until another full reload.

Same intent (next session starts from clean state), no race: move the
reset to the START of onload instead.

- onload: resetPersistenceState() + KeychainService.resetInstance()
  fire before KeychainService.getInstance(this.app), so the next
  session always starts with a clean slate regardless of what the
  previous onunload's tail did.
- onunload: keep the flushPersistence() but drop the reset calls;
  inline comment explains the move and points back to onload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:30:44 -07:00
Emt-lin
34fa3c5507
feat(keychain): migrate API key storage to Obsidian Keychain (#2364)
* feat(keychain): migrate API key storage to Obsidian Keychain

Replaces the legacy disk-encryption flow with Obsidian's vault-scoped
Keychain (SecretStorage) as the source of truth for API keys. Migration
is explicit and opt-in via Advanced Settings; existing vaults stay on
disk mode until the user clicks "Migrate to Obsidian Keychain".

Architecture
- `KeychainService` (singleton) wraps Obsidian's SecretStorage with a
  per-vault namespace (`copilot-v{8hex}-{field}`) so multiple vaults on
  one device cannot collide.
- `settingsPersistence` is the single owner of load/save:
  - `loadSettingsWithKeychain()` dispatches by `_keychainOnly` flag.
  - `doPersist()` writes either keychain (stripped data.json) or disk
    (plaintext data.json, never new `enc_*`).
  - Write queue + transaction epoch serialise every persist with
    dedicated transactions (forgetAllSecrets, migrate).
- `settingsSecretTransforms` provides the pure helpers shared by both
  sides (stripKeychainFields, cleanupLegacyFields, isKeychainOnly, ...).
- `encryptionService` shrinks to decrypt-only + base64/sensitive-key
  helpers; new encryption writes are gone.

Migration model
- Fresh installs auto-opt-in to keychain mode; existing installs stay
  disk-mode until the user clicks Migrate.
- `migrateDiskSecretsToKeychain()` is a single transaction: write
  keychain -> strip data.json -> flip `_keychainOnly`. Rolls back the
  keychain on partial-write or disk-save failure, and re-derives from
  live settings at transaction end so concurrent edits are preserved.
- Undecryptable legacy `enc_*` values are cleared and reported via
  `MigrationResult.fieldsRequiringReentry`.

Persist hardening
- Stranded vaults (keychain-only mode on a non-SecretStorage build)
  preserve `_keychainOnly` and never load plaintext from disk or
  silently downgrade to disk mode.
- `persistHadUndecryptableSecrets` fail-closed guard blocks disk-clear
  when the last keychain save did not complete safely; a clean
  disk-mode save lifts the lock so migration retry works.
- Destructive flows (clearAllVaultSecrets, forgetAllSecrets) feature-
  detect `listSecrets()` and refuse before stripping disk on builds
  where keychain entries cannot be enumerated.

UI
- New `MigrateConfirmModal` gates migration behind an acknowledgement
  checkbox surfacing the multi-device trade-off.
- API Key Storage panel exposes active / standard / blocked /
  unavailable / stranded states.
- `canClearDiskSecrets()` falls back to in-memory settings so the
  Migrate / Delete-All CTAs surface immediately after a key is typed.

Testing
- New suites for keychainService, settingsPersistence,
  settingsSecretTransforms, MigrateConfirmModal, password-input;
  encryptionService tests rewritten for the decrypt-only surface.
- DESIGN NOTEs lock the trade-offs triaged during review (sparse
  bootstrap write, rollback enc_* replay, hasEncryptionPrefix guard,
  unconditional epoch bump, etc.) to prevent future review churn.

* docs(keychain): document intentional first-stage migration residue

Add a DESIGN NOTE in migrateDiskSecretsToKeychain explaining why
keychain residue from a failed first-stage persist is intentionally
left uncleaned: it stays dormant in disk-mode and self-heals on the
next successful migration or Delete All Keys.

* docs(keychain): clarify Reset Settings does not clear keychain secrets

Reset Settings rewrites data.json to defaults but does not enumerate or
remove Obsidian Keychain entries. The troubleshooting doc still promised
Reset would delete all API keys, which is false for keychain-only vaults.

- Correct the Reset warning to point users to "Delete All Keys" for
  erasing keychain secrets
- Add a DESIGN NOTE on resetSettings() explaining the omission is
  intentional for the first-stage migration

* style(keychain): emphasize data.json and Keychain labels in migrate modal

Add subtle background and accent coloring to the `data.json` and
`Obsidian Keychain` inline code labels so the source and destination
of the migration read more clearly.
2026-05-14 13:57:21 -07:00
Zero Liu
13beaf133b
chore(lint): fix no-array-index-key and other React lint warnings (#2453)
* chore(lint): fix no-array-index-key and other React lint warnings

Re-enable @eslint-react/no-array-index-key, replace index keys with stable
ids where possible (capability enum, action label, file paths, computed
coords), and add per-line eslint-disable with rationale where index is
intentional (diff parts, append-only steps, parsed markdown, duplicate-
tolerant context badges).

Also addresses adjacent React lint warnings: useMemo for context provider
values, stable module-scope defaults for empty array props, lazy useState
initializers, and explicit type="button" on non-submit buttons. Surface
no-direct-set-state-in-use-effect as a warning for follow-up triage.

* fix: dedupe selected image files
2026-05-14 02:19:38 -07:00
Zero Liu
6ca2dc01ea
chore(eslint): enable no-explicit-any; fix ~395 violations (#2452)
* chore(eslint): enable no-explicit-any; fix ~395 violations

Switches @typescript-eslint/no-explicit-any from "off" to "error" and
replaces explicit `any` with proper types or `unknown` + narrowing
across ~100 source files and 15 test files. Eleven eslint-disable
comments remain: one for a BaseLanguageModel prototype patch and ten
for Orama<any> API surfaces where typed alternatives poison Orama's
internal inference to `never`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scripts): cast chainContext to ChainManager in printPromptDebugEntry

The earlier `as any` → `as unknown` rewrite left buildAgentPromptDebugReport's
chainManager argument typed as `unknown`, which CI's `tsc -noEmit` (without
`--skipLibCheck`) flagged. Restore the cast through the real ChainManager type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): fix remaining no-explicit-any and unnecessary-type-assertion errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): drop redundant no-explicit-any rule

Already set to "error" by typescript-eslint/recommendedTypeChecked via
obsidianmd's recommended config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:08:45 -07:00
Zero Liu
e1385d95f1
chore(styles): drop dead CSS, reduce !important, fix duplicate selectors (#2451)
* chore(styles): drop dead CSS, reduce !important, fix duplicate selectors

Removed orphaned chains (.remove-note, .chat-icon-button/.submit-button/.chain-select-button etc., .model-settings-table) that haven't had JSX consumers since PRs #1055 and #1074. Cuts !important from 23 to 14, eliminates two duplicate-selector lint warnings (.message-content pre merged; the dead .submit-button svg duplicate removed), and moves the live .message-content wrapper styles inline so the surviving rule no longer needs !important.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): whitelist Obsidian's 'clickable-icon' for tailwindcss/no-custom-classname

The eslint-plugin-tailwindcss `no-custom-classname` rule treats classes mentioned anywhere in the configured cssFiles as known. Removing the dead `.chat-icon-button.clickable-icon` selector dropped `clickable-icon` from that scanned set, which then failed lint in `button.tsx` and `dialog.tsx` where the Obsidian-provided utility is still used. Whitelist it explicitly so the lint rule no longer relies on a CSS-file mention to recognize it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:43:39 -07:00
Zero Liu
6ae2245830
chore(lint): fix deprecation and template-literal lint warnings (#2450)
* chore(lint): address deprecation and template-literal lint warnings

- Replace deprecated `workspace.activeLeaf` with `workspace.getActiveLeaf()`
- Convert `colorOpacityPlugin` callback to arrow function
- Pass `failedItem.type` as a separate `logWarn` arg to avoid
  `never`-in-template-literal warning in exhaustive switch default

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): revert activeLeaf -> getActiveLeaf swap

The installed obsidian types don't yet expose `Workspace.getActiveLeaf()`,
causing TS2551. Revert to `workspace.activeLeaf`; the deprecation notice in
the JSDoc is benign at runtime and the recommended replacements
(`getActiveViewOfType`, `getLeaf`) don't fit this use case (we need the
currently active leaf of any view type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:23:14 -07:00
Zero Liu
98e8d0316e
chore(icons): drop deprecated lucide Youtube icon; reuse Globe for URLs (#2449)
The `Youtube` brand icon from lucide-react is deprecated and slated for
removal. The YouTube section header and per-row icon already sit next to
the "YouTube" label or full URL, so the brand mark is redundant — collapse
both web and youtube items to the existing `Globe` icon.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:19:41 -07:00
Zero Liu
d6255374cb
chore(deps): swap unmaintained/legacy deps per e18e module-replacements (#2447)
- npm-run-all → npm-run-all2 (dev script uses run-p)
- lint-staged → nano-staged (config block + husky hook)
- lodash.debounce → local src/utils/debounce.ts with cancel/flush/leading/trailing
- dotenv → process.loadEnvFile in integration test bootstrap
- eslint-plugin-react → @eslint-react/eslint-plugin (flat-config migration)
- js-yaml → yaml (mock + jest CJS moduleNameMapper for jsdom)
- crypto-js → pure-JS md5/sha256 in src/utils/hash.ts producing byte-identical
  digests so all on-disk cache keys (PDF, file, project, search index)
  remain valid across the upgrade — no migration or re-parse cost

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:12:52 -07:00
Zero Liu
8239443814
refactor(react): replace global app with useApp() hook in React layer (#2448)
First wave of eliminating global `app` references (per Obsidian's
`obsidianmd/no-restricted-globals` rule). Adds a `useApp()` hook that
reads from `AppContext` and throws if no provider is in scope, then
converts the React layer to use it. Modals that render React content
now wrap their tree with `<AppContext.Provider value={this.app}>` so
descendants pick up the modal-owned app rather than the global one,
which matters in popout windows.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:57:38 -07:00
Zero Liu
7009925585
chore(types): tighten any types and fix langchain getType deprecation (#2444)
* chore(types): tighten any types and fix langchain getType deprecation

- Replace `message.getType()` with `message.type` in BedrockChatModel
- Type `docToSave` as `OramaDocument` and `explanation` as `SearchExplanation`
- Extract a typed `fallbackMetadata` to avoid `any` union warnings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(lint): drop unnecessary `as unknown` cast on upsert return

Now that `docToSave` is typed as `OramaDocument`, it's already assignable
to the `Promise<unknown>` return type without an explicit cast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:53:06 -07:00
Logan Yang
249f9e2472
fix(encryption): deprecate encryption toggle; fix mobile chat failure on desktop-encrypted keys (#2446)
* fix(mobile): handle desktop-encrypted API keys on mobile (follow-up to #2443)

PR #2443 added the Buffer polyfill to base64.ts, utils.ts, and
BedrockChatModel.ts but missed src/encryptionService.ts:119. My
original write-up incorrectly claimed mobile never enters the
DESKTOP_PREFIX branch of getDecryptedKey — it does, whenever a user
encrypts their API keys on desktop and then syncs the vault to mobile
via Obsidian Sync. Mobile reads the same encrypted blob, enters the
DESKTOP_PREFIX branch, and hits `Buffer.from(...)`, which throws
"Can't find variable: Buffer" on iOS WebKit.

User reproducer: "Model request failed: Can't find variable: Buffer"
during normal chat send on mobile after PR #2443 merged.

Two-part fix:

1. Add `import { Buffer } from "buffer"` (the npm polyfill, already in
   dependencies and used in PR #2443) so the literal ReferenceError is
   gone. The legacy ENCRYPTION_PREFIX branch at line 137 also benefits.

2. Even with Buffer available, the next line
   `getSafeStorage()!.decryptString(buffer)` cannot succeed on mobile
   because there is no Electron safeStorage there. The encrypted blob
   uses OS-level encryption (Keychain on macOS, DPAPI on Windows,
   libsecret on Linux) that cannot be replayed from mobile. Add a
   Platform.isMobile guard that throws a clear, actionable error
   asking the user to re-enter their API key on this device instead
   of crashing on the unreachable decryption call.

Out of scope:

- The DESKTOP_PREFIX-on-mobile situation requires the user to re-save
  their key in mobile settings (which will re-encrypt with
  WEBCRYPTO_PREFIX, the cross-platform path that Web Crypto handles).
  Surfacing a clear error is the right user experience until the
  plugin auto-migrates on first mobile launch — that's a larger
  follow-up not appropriate for a same-day hotfix.

- The legacy ENCRYPTION_PREFIX branch at line 137 still references
  Buffer, but it's already gated by
  `getSafeStorage()?.isEncryptionAvailable()` which returns false on
  mobile, so mobile never enters it. The new import covers it
  defensively if that gate ever changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): list desktop-encrypted keys on mobile load + block desktop encryption

Two additions on top of the existing fix in this PR:

1. **Guard the encryption side with Platform.isDesktop.** Mobile should
   NEVER produce DESKTOP_PREFIX keys, even if `require("electron")`
   happens to return a truthy shim. Before this change, the only gate
   was `getSafeStorage()?.isEncryptionAvailable()` — fragile, since
   any mobile environment that exposes a partial Electron-like shim
   could slip through and save a key as DESKTOP_PREFIX that the same
   device then can't decrypt.

2. **Surface a Notice on mobile load** listing every key field that's
   still DESKTOP_PREFIX-encrypted. Without this, the user only finds
   out which key needs re-entry by hitting a chat error and having to
   guess. With it, they see the full list at startup:

     "Copilot: 3 API key(s) encrypted on desktop and unusable on
      mobile. Re-enter in Copilot settings: plusLicenseKey,
      openAIApiKey, activeModels[gpt-5.5]"

   Notice has timeout 0 so it stays until dismissed.

The new exported `findDesktopEncryptedKeyFields(settings)` walks:
- All top-level string settings (catches plusLicenseKey,
  openAIApiKey, anthropicApiKey, githubCopilotToken, etc.)
- settings.activeModels[*].apiKey (per-model custom keys)
- settings.activeEmbeddingModels[*].apiKey

The exact field names returned match the keys in data.json so the
user can grep / inspect / re-enter them confidently.

This addresses the user report after the first commit in this PR:
"despite reentering api key on mobile" — which happened because they
re-entered one key but a *different* key (likely plusLicenseKey or
a model-specific apiKey) was still DESKTOP_PREFIX, causing the chat
to fail on that other key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(encryption): deprecate encryption-at-rest; make encrypt/save a no-op

The encryption toggle has created repeated incidents:

1. Mobile cannot decrypt DESKTOP_PREFIX (Electron safeStorage) keys
   that synced over from desktop, because mobile has no safeStorage.
2. The WebCrypto path is security theater — the AES-GCM key is a
   hardcoded "obsidian-copilot-v1" string, so anyone with data.json
   can decrypt every "encrypted" key in seconds.
3. PRs #2401 and #2403 (3.3.0) introduced bare `Buffer.from` in the
   encryption + base64 paths, which crashed mobile WebView with
   "Can't find variable: Buffer" — see #2443 and earlier commits in
   this PR.
4. Even with that fixed, the eager dispatch-map builders in
   chatModelManager / embeddingManager await getDecryptedKey for
   every provider; a throw on the one bad desktop-encrypted key
   killed chats that used a different provider with a valid key.

Rather than continue patching, deprecate the toggle and stop adding
encryption to new keys. Existing encrypted blobs in data.json remain
readable via getDecryptedKey (for all three legacy formats:
DESKTOP_PREFIX, WEBCRYPTO_PREFIX, and the original `enc_`); they
migrate organically to plaintext the next time the user re-saves any
given key.

Changes:

- `encryptAllKeys` → no-op (returns settings unchanged). 6+ lines of
  per-field encryption gone.
- `getEncryptedKey` → no-op (returns plaintext, strips `dec_` marker
  if present). 25 lines of safeStorage / WebCrypto / DESKTOP_PREFIX
  branching gone.
- `main.ts` settings-save → unconditionally `saveData(next)` instead
  of branching on `enableEncryption`. The setting field stays in the
  CopilotSettings interface for backwards-compat with existing
  data.json files but is no longer read or written.
- `AdvancedSettings.tsx` → "Enable Encryption" toggle removed from
  the UI. Users who had it on get no further encryption; users who
  had it off see no change.

Mobile-specific path inside `getDecryptedKey`:

- DESKTOP_PREFIX keys on mobile return `DESKTOP_KEY_ON_MOBILE_SENTINEL`
  (no longer throw). chatModelManager / embeddingManager's eager
  dispatch-map construction completes; the sentinel sits harmlessly
  in unused config slots; providers actually being used with valid
  plaintext keys work. The startup Notice (already in this PR)
  surfaces which fields the user must re-enter.

Tests:

- `getEncryptedKey` test suite rewritten to verify the new no-op
  behavior (plaintext passthrough, dec_ stripping, empty-string).
- `encryptAllKeys` test suite shrunk to one identity-return assertion.
- Cross-platform encrypt/decrypt round-trip tests removed (encryption
  is gone). Added a legacy WebCrypto decrypt test to confirm we can
  still read existing enc_web_* blobs.
- 1974 tests pass (was 1975; -1 for the dropped round-trip test).

Net change: encryptionService.ts down from 207 to 130 lines.

Risk:

- `data.json` now contains plaintext API keys after first re-save.
  Worth saying explicitly in release notes. The previous "encryption"
  was indistinguishable from plaintext for anyone with filesystem
  access (hardcoded WebCrypto key), so this is a documentation
  change more than a security change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): simplify to a throw; drop sentinel + Notice + helper

User feedback: the sentinel + startup-Notice approach added 70+ lines of
transitional code that we'd have to delete later. Replace with the
lightest viable shape.

- Mobile DESKTOP_PREFIX branch in getDecryptedKey now throws a generic
  but actionable error ("re-enter your API keys on this device") and
  nothing else.
- Removed DESKTOP_KEY_ON_MOBILE_SENTINEL constant.
- Removed findDesktopEncryptedKeyFields function.
- Removed the startup Notice block in main.ts and its import.

Trade-off accepted: with multiple desktop-encrypted keys present, the
chat fails on the first one the eager dispatch-map encounters; user
re-enters that key, retries, hits the next bad key, repeats. The
generic message tells them what to do; they iterate rather than seeing
the full list up front.

Net: -71 lines vs. previous approach. Future cleanup (when no users
have desktop-encrypted keys anymore) is one small if-branch in
getDecryptedKey instead of a sentinel + assert + helper + Notice
spread across four files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:47:59 -07:00
Logan Yang
dd74cf9654
fix(settings): restore version chip + reset button hidden by Obsidian h1 CSS (#2445)
* fix(settings): restore version chip + reset button hidden by Obsidian h1 CSS

The top-of-settings row (Copilot Settings title, version chip, "(up to
date)" annotation, Reset Settings button) is rendered but invisible in
current Obsidian releases. DOM inspection confirms the <h1> wrapping
the row has `display: none` applied with `height: 0`:

  h1Texts: ["Copilot Settingsv3.3.0 (up to date)Reset Settings"]
  h1Styles: [{ display: "none", height: 0, visibility: "visible" }]

Cause: Obsidian's settings modal CSS hides plugin-rendered <h1> elements
because Obsidian reserves the top-level heading slot in a settings tab
for itself. This convention was tightened in a recent Obsidian release,
so the same code that worked at <h1>'s introduction (PR #1194) and
update-notification addition (PR #1415) now silently renders zero-height.

Fix: replace the <h1> with a <div role="heading" aria-level={1}> that
carries the same heading-style classes (tw-text-2xl tw-font-bold) plus
the existing flex/layout classes. The accessibility role/level preserves
the heading semantic for screen readers without triggering Obsidian's
display:none on real <h1>. The small-text version chip is now explicitly
tw-font-normal so it doesn't inherit the wrapper's tw-font-bold.

Verified by DOM diagnostic in live Obsidian: <h1> was being hidden;
<div role="heading"> is not affected by the same CSS rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): shrink restored heading and add spacing below

Per request: make the restored title row much smaller (tw-text-2xl
tw-font-bold → tw-text-base tw-font-semibold) and add tw-mb-4 spacing
below it so it sits comfortably above the tabs row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:52:20 -07:00
Zero Liu
1203a4dc84
chore(test): type test mocks to satisfy @typescript-eslint/no-unsafe-call (#2435)
Replaces `any`-typed mock objects in 26 test files with structured
types using `jest.Mock`, casts requireMock results to typed shapes,
and introduces `internals()` helpers for private-method access. No
production code changes; eliminates 487 of 679 no-unsafe-call
violations in preparation for enabling the rule.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:17:30 -07:00
Logan Yang
7587b4fa4d
fix(mobile): import Buffer from the buffer polyfill so it works in WebView (#2443)
Mobile Obsidian runs on Capacitor + WebView (no Node.js runtime), so the
bare `Buffer` global is undefined. On iOS WebKit this surfaces as the
runtime error "Can't find variable: Buffer" the moment the image
processing path fires. Desktop is unaffected because Electron exposes
Node globals.

Two PRs in 3.3.0 introduced direct `Buffer.from(...)` calls in
image-handling paths assuming "Buffer is global":

- #2403 rewrote src/utils/base64.ts from btoa/atob to bare Buffer.from
- #2401 did the same in src/utils.ts safeFetch arrayBuffer branch and
  src/LLMProviders/BedrockChatModel.ts decodeBase64ToUint8Array

Result: any image attachment on mobile fails with "failed to send
image, please try again". Confirmed via user log:

  ERROR Error sending message: Can't find variable: Buffer

The fix is an explicit `import { Buffer } from "buffer"` in each
affected file. The `buffer` npm package (already in dependencies) is a
browser-compatible polyfill that esbuild bundles into main.js, so the
same Buffer code path works on desktop (where it's effectively the
Electron-provided Buffer) and on mobile (where it's the polyfill).

Two lint rules complicate this:
- Obsidian's official scorecard flags btoa/atob as deprecated and
  asks for Buffer.from. That rules out reverting to btoa/atob.
- The repo's own `import/no-nodejs-modules` rule blocks `import from
  "buffer"`. Suppressed inline at each import site with justification,
  because `buffer` here is the npm polyfill (in package.json
  dependencies), not Node's built-in being used at runtime.

Files touched:
- src/utils/base64.ts (arrayBufferToBase64 / base64ToArrayBuffer)
  This is the call site the image processor hits and the path
  encryptionService.ts uses for WEBCRYPTO_PREFIX (mobile encryption),
  so mobile encryption is also fixed transitively.
- src/utils.ts (safeFetch arrayBuffer fallback)
- src/LLMProviders/BedrockChatModel.ts (decodeBase64ToUint8Array)

Regression test:
- src/utils/base64.test.ts deletes globalThis.Buffer before exercising
  the helpers to confirm the imported polyfill binding is what gets
  used (not a bare global reference). Catches any future regression
  where someone replaces the import with a bare global usage.

Out of scope:
- src/encryptionService.ts has Buffer.from references but they sit
  inside desktop-only branches (`getSafeStorage().isEncryptionAvailable()`
  / DESKTOP_PREFIX) that mobile never enters. Touching them from a
  mobile hotfix would expand blast radius unnecessarily.
- src/LLMProviders/BedrockChatModel.ts:835 is already guarded with
  `typeof Buffer !== "undefined"` so mobile skips it; left alone.

Bundle size unchanged at 3.3 MB (buffer polyfill is small).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:05:29 -07:00
Zero Liu
280d95ebde
chore(eslint): enable no-unsafe-assignment for tests (#2434)
Adds a test-only override enforcing @typescript-eslint/no-unsafe-assignment
on **/*.test.{ts,tsx} and fixes all 378 violations across 44 test files.
Production code (~499 violations) remains a follow-up PR — the rule stays
"off" globally, with the violation count comment updated to reflect that.

Common fix patterns applied:
- Typed accessor helpers (asInternal) for private-method access via
  `as unknown as Shape`, replacing `(instance as any).method(...)`.
- Inline `as <Type>` on mock indexing (mock.calls[i][j]), JSON.parse,
  and jest.requireMock results.
- mockTFile()/mockTFolder() from @/__tests__/mockObsidian replacing
  `Object.create(TFile.prototype)` (also avoids obsidianmd/no-tfile-tfolder-cast).
- `as unknown as RealType` for mock-object casts where `as any` previously
  masked the assignment.
2026-05-13 22:56:46 -07:00
Zero Liu
500bc347a0
chore(eslint): enable no-unsafe-member-access; fix 124 violations (#2438)
Enables @typescript-eslint/no-unsafe-member-access globally. Tests and 41
heavy source files (>5 violations each) are exempted via per-file overrides
with current violation counts annotated for follow-up PRs. Fixes 124
violations across 51 source files using narrow type assertions, instanceof
Error guards, and proper structural types instead of blanket any casts.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:35:38 -07:00
Zero Liu
c5a990e48f
fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons (#2442)
* fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons

The Quick Ask/Command revamp (#2139) replaced an Obsidian Modal with a
standalone DraggableModal. Two regressions slipped through:

- The new modal didn't push a Scope, so any user-bound global Cmd+Enter
  hotkey would fire instead of Replace. Push a Scope in
  `CustomCommandChatModal.open()` that registers Mod+Enter and
  Mod+Shift+Enter as no-op consumers (the React onKeyDown does the real
  work) and pop it in close(), matching what `Modal` did automatically.
- The Insert/Replace buttons lost the inline ⌘↩ / ⌘⇧↩ glyph hints that
  existed in the pre-revamp modal. Re-add them with Lucide icons
  (`Command`, `ArrowBigUp`, `CornerDownLeft`) in `action-buttons.tsx`,
  with a Ctrl-text fallback on non-mac.

Also move the shortcut handler from a document-level keydown listener
back to a React `onKeyDown` on the modal subtree — same pattern as the
original implementation — so it can't be pre-empted by capture-phase
listeners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-command): widen dialog so footer fits Note checkbox + shortcut glyphs

Quick Command mode renders an extra "Note" context checkbox alongside the
model selector and action buttons. With the restored ⌘⇧↩ / ⌘↩ glyphs on
Insert/Replace, the default 500px footer overflows. Bump the initial
width to `min(620px, 92vw)` only when `hideContentAreaOnIdle` (the
Quick Command signal) is set; Custom Commands keep the original width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quick-command): widen modal unconditionally instead of gating on mode

Apply the wider footer to Custom Commands too — same overflow potential
once the Insert/Replace glyph hints are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:29:27 -07:00
Zero Liu
020e24507c
chore(eslint): enable @typescript-eslint/unbound-method (#2439)
Enabled in the TS-only override block. Disabled for test files since
jest assertion patterns (`expect(mock.method).toHaveBeenCalled()`)
reference methods unbound by design and have no clean workaround.

Fixed the single real source violation in colorOpacityPlugin by
calling addUtilities through the plugin API object instead of
destructuring it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:09:34 -07:00
Zero Liu
e3c5e24f1b
chore(eslint): enable @typescript-eslint/no-unsafe-return and fix violations (#2436)
Moves the rule into the TS-only config block (the plugin is only registered
for .ts/.tsx). Fixes 187 violations across 74 files using narrow `as` casts,
explicit return-type annotations on callbacks, typed intermediate variables,
and tightening parameter types from `any` to `unknown` where it didn't break
callers. Also replaces the `@ts-ignore`'d `Electron.SafeStorage` reference
with a local `SafeStorage` interface in `encryptionService.ts`. No prompt
content modified; no `eslint-disable` comments added.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:04:59 -07:00
Zero Liu
4b592f6782
chore(eslint): remove redundant non-TS rule overrides (#2433)
The four obsidianmd typed rules (no-view-references-in-plugin,
no-unsupported-api, prefer-file-manager-trash-file, prefer-instanceof)
are already gated to **/*.ts(x) by the plugin's hybridRecommendedConfig,
so disabling them on non-TS files is a no-op. Only no-plugin-as-component
and @typescript-eslint/no-deprecated actually leak onto non-TS files.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:01:00 -07:00
Zero Liu
8658282aa9
chore(lint): enable 4 type-aware quick-win rules and fix violations (#2424)
Turns on no-unsafe-enum-comparison, no-base-to-string,
no-redundant-type-constituents, and restrict-template-expressions.
Fixes all 24 violations across 13 source files.
2026-05-13 21:57:33 -07:00
Zero Liu
f901343583
chore(eslint): enable @typescript-eslint/no-floating-promises and fix violations (#2437)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:45:35 -07:00
Zero Liu
df6f455662
chore(eslint): enable @typescript-eslint/no-unsafe-argument and fix violations (#2440)
Removes the `off` override and fixes all 368 violations via type casts,
widened parameter types (unknown / string|null|undefined for functions
that defensively handle non-string inputs), and concrete types in place
of `any` for Lexical nodes, mock objects, and option handlers.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:40:26 -07:00
Zero Liu
8499b85a1b
chore(eslint): enable no-unnecessary-type-assertion and no-misused-promises (#2441)
* chore(eslint): enable no-unnecessary-type-assertion and no-misused-promises

Auto-fixed all 40 no-unnecessary-type-assertion violations via `eslint --fix`.

For no-misused-promises, configured `checksVoidReturn` with two relaxations
(documented inline):
- `attributes: false` — `onClick={async () => ...}` is the standard React pattern
- `inheritedMethods: false` — Obsidian's Plugin.onload/onunload are async

Fixed the remaining 10 violations by hand:
- Wrapped async subscribers/listeners with `void` (projectManager, useChatFileDrop,
  main.ts, webViewerServiceSelection)
- Widened ConfirmModal subclass callback types to `() => void | Promise<void>`
- Widened MobileCardDropdownAction.onClick to match parent prop types
- Made SettingsMainV2 handleReset sync (it never awaited anything)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(eslint): move no-misused-promises to TS-only config block

The @typescript-eslint plugin is registered only for .ts/.tsx files via
obsidianmd's recommendedTypeChecked. Enabling the rule in the
JS+TS-shared block caused CI to fail with "could not find plugin
@typescript-eslint" because the plugin isn't loaded for .js/.mjs files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:27:35 -07:00
Logan Yang
3b8af4d341
ci(release): publish artifact attestations for release assets (#2431)
Add a step that signs main.js, manifest.json, and styles.css with the
workflow's OIDC identity and publishes the attestation to Sigstore's
public transparency log via actions/attest-build-provenance@v2.

Why: Obsidian's plugin review tooling flags release assets without an
attestation as a soft warning. Attestations cryptographically prove the
asset was built by this workflow on this commit, defending against an
attacker who gains release-asset-replace permissions and silently swaps
in a compromised main.js. Verification post-release:

  gh attestation verify main.js --owner logancyang --repo logancyang/obsidian-copilot

Placement: the new step runs AFTER the prerelease manifest swap and
BEFORE gh release create. That way the attested manifest.json is
exactly the bytes that get uploaded — for stable releases that's
the committed manifest.json; for prereleases it's the in-runner
copy of manifest-beta.json.

Permissions: adds attestations: write and id-token: write to the job.
The id-token: write permission is what allows the workflow to request
an OIDC token from GitHub; the attest action uses that token as proof
of identity when signing. No secrets to manage.

Free for public repos.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:09:14 -07:00
Logan Yang
bb5a166477
release: v3.3.0 (#2430)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:25:18 -07:00
Logan Yang
28cfff08ef
chore(release): make prerelease tooling stop poisoning master's manifest.json (#2429)
* chore(release): make prerelease tooling stop poisoning master's manifest.json

Background: Obsidian's community plugin store reads manifest.json from master
to decide which GitHub Release to serve installers. The prerelease agent's
npm version bump was rewriting manifest.json with a prerelease version, which
broke plugin installs until hotfix #2428 reverted master's manifest.json to
the stable version.

This PR fixes the underlying tooling so it can't recur.

Changes:

- version-bump.mjs now branches on whether npm_package_version is a prerelease
  (contains a hyphen). Stable: writes to manifest.json + versions.json. Prerelease:
  writes to manifest-beta.json + versions.json, leaving manifest.json untouched.
  Seeds manifest-beta.json from manifest.json when it doesn't exist yet.
  On a stable bump that finds a stale manifest-beta.json, removes it (git rm)
  because the new stable supersedes the in-flight beta. Stages the right files
  itself; package.json no longer needs the explicit git-add step.

- package.json: simplifies the "version" lifecycle script to just
  "node version-bump.mjs" since the script now stages files itself.

- release.yml:
  * "Verify version matches manifest" step now selects the file based on
    is_prerelease (manifest.json for stable, manifest-beta.json for prerelease).
    For stable runs nothing changes.
  * Adds a "Prepare release-asset manifest" step before the release upload that
    copies manifest-beta.json over manifest.json IN THE RUNNER only when the
    release is a prerelease. This makes the uploaded manifest.json asset carry
    the prerelease version so testers sideloading the assets get a consistent
    manifest. The committed master manifest.json is never touched by the
    workflow (the workflow doesn't push back).

- .claude/agents/release.md: adds a Step 0 pre-flight assertion that master's
  manifest.json.version equals the latest non-prerelease GitHub Release tag.
  Catches drift loudly before doing any work. Notes the manifest-beta.json
  auto-removal in the rules.

- .claude/agents/prerelease.md: documents the manifest.json / manifest-beta.json
  split. Adds the same Step 0 drift assertion. Updates the git add step to
  stage manifest-beta.json instead of manifest.json. Adds an explicit guard
  rule that manifest.json must not change during a prerelease bump.

Verified locally by exercising version-bump.mjs in a throwaway repo:
- Stable 3.2.8 -> 3.2.9: writes manifest.json + versions.json, no beta side
- Prerelease 3.2.8 -> 3.2.9-beta.0: creates manifest-beta.json, manifest.json untouched
- Prerelease iteration 3.2.9-beta.0 -> 3.2.9-beta.1: updates manifest-beta.json,
  manifest.json untouched
- Stable supersedes beta 3.2.9-beta.1 -> 3.2.9: writes manifest.json,
  git-rm's manifest-beta.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): address Codex review on PR #2429

1. release.yml: when verifying a prerelease, also assert that the committed
   manifest.json on the merge commit still equals the latest non-prerelease
   GitHub Release tag. This catches an old-style version bump or hand edit
   that accidentally modified master's manifest.json — without this guard,
   the verify step only inspected manifest-beta.json so a poisoned
   manifest.json could merge unnoticed and break the Obsidian plugin store.

2. version-bump.mjs: don't fail stable bumps when manifest-beta.json exists
   on disk but is untracked (or has local modifications). Check git
   trackedness first; use `git rm -f` only when tracked, fall back to a
   plain unlink for working-tree-only files. Logs which path it took.

Verified both scenarios in a throwaway repo:
- Untracked manifest-beta.json + stable bump: unlinked, no git rm error
- Tracked manifest-beta.json + stable bump: staged deletion via git rm -f

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): use /releases/latest API for stable-drift guard

gh release list defaults to 30 results, so a cluster of 30+ prereleases
since the last stable would make the prerelease drift guard return empty
and fail valid prerelease publishes. Switch to GitHub's /releases/latest
endpoint instead, which by design returns only the most-recent
non-prerelease, non-draft release in a single call regardless of how
many prereleases have accumulated.

Applied the same fix to the Step 0 drift check in both the release agent
and prerelease agent doc for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:53:48 -07:00
Logan Yang
fdb338110e
fix(manifest): restore stable version to 3.2.8, split beta into manifest-beta.json (#2428)
URGENT FIX. The prerelease agent's npm version run on PR #2427 left master's
manifest.json with version "3.2.9-beta.0", but Obsidian's community plugin
store reads manifest.json on master to determine which GitHub Release to
serve to new installers. With the stable release at 3.2.8 and master's
manifest pointing at a prerelease tag, plugin installs and updates were
breaking for users.

Fix:
- manifest.json on master: version back to "3.2.8" (the latest stable
  release). minAppVersion 1.7.2 and isDesktopOnly:false are preserved.
- manifest-beta.json (new): full manifest for 3.2.9-beta.0 so BRAT and
  similar beta-installer tools can locate the prerelease without us
  poisoning master's stable manifest.

The longer-term fix (version-bump.mjs + prerelease agent updates so
this can't recur) follows in a separate PR.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:03:22 -07:00
Logan Yang
20d98120e4
prerelease: v3.2.9-beta.0 (#2427) 2026-05-13 14:54:58 -07:00
Logan Yang
54d5c7bbfe
chore(release): add prerelease agent + workflow support, harden release agent pre-flight (#2426)
* chore(release): add prerelease agent + workflow support, harden release agent pre-flight

- release.yml: accept prerelease semver in PR titles (X.Y.Z-tag.N) and
  pass --prerelease to gh release create so prereleases are not offered
  as stable updates via Obsidian's plugin browser.
- .claude/agents/release.md: add a Step 0 pre-flight (clean tree, full
  project check, bundle size guard, manifest integrity, non-empty diff
  since last tag) and explicit rules against silently changing
  minAppVersion/isDesktopOnly or shipping with broken checks.
- .claude/agents/prerelease.md: new agent. Mirrors the stable release
  flow but bumps via npm version prepatch/prerelease with --preid, emits
  prerelease-shaped semver titles, and produces testing-focused release
  notes with explicit "What to Test" and "How to Install" sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): make prerelease regex match full SemVer 2.0.0 grammar

Previous pattern '^[0-9]+\.[0-9]+\.[0-9]+-[a-zA-Z0-9.]+$' rejected hyphens
inside prerelease identifiers, so valid SemVer titles like '3.2.9-beta-hotfix.1'
would fall through to the reject branch and the merged PR would silently skip
publishing a GitHub Release.

New pattern '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$' allows:
- hyphens within identifiers (e.g. beta-hotfix)
- dot-separated multiple identifiers
- single identifier (e.g. 3.2.9-alpha)

And still rejects malformed cases: '3.2.9-', '3.2.9-.1', '3.2.9-x.', '3.2.9-x..y'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): tighten prerelease title to require numeric counter

Previous pattern accepted any SemVer prerelease suffix, including bare
labels like '3.2.9-beta' or '3.2.9-feature' with no counter. That broadened
the workflow's release-trigger surface beyond the documented agent
contract of 'X.Y.Z-<tag>.<N>'.

New pattern '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z-]+\.[0-9]+$' requires
exactly one alphanumeric (hyphen-allowed) identifier followed by a
numeric counter, which is what 'npm version prepatch/prerelease --preid=...'
actually emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:38:43 -07:00
Logan Yang
ca885b03ac
chore(manifest): bump minAppVersion to 1.7.2 and declare mobile support (#2425)
- minAppVersion 1.4.0 -> 1.7.2: matches the actual Obsidian APIs the code
  already calls (Workspace.revealLeaf needs 1.7.2; FileManager.processFrontMatter
  needs 1.4.4). Users on Obsidian < 1.7.2 were already hitting runtime errors
  on those code paths; stating an honest minimum prevents the install.
- Add isDesktopOnly: false: Chat, Vault QA, and Plus modes work on mobile
  (extensive Platform.isMobile guards already in place across dbOperations,
  vectorStoreManager, indexEventHandler, CopilotView, etc.).

Addresses two warnings from the Obsidian plugin preview check:
  * "Manifest is missing optional but recommended field: isDesktopOnly"
  * "'Workspace.revealLeaf' requires Obsidian v1.7.2, but minAppVersion is 1.4.0"
  * "'FileManager.processFrontMatter' requires Obsidian v1.4.4, but minAppVersion is 1.4.0"

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:43:35 -07:00
Zero Liu
602f6d6ac0
chore(eslint): enable @typescript-eslint/await-thenable and fix violations (#2423)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:14:04 -07:00